From c6ab630132e4081703d65fe933c981a9a59559e6 Mon Sep 17 00:00:00 2001 From: ajisth69 Date: Sun, 18 Oct 2026 11:43:06 +0530 Subject: [PATCH 01/12] fix(audio): restore bit-perfect USB exclusive playback and prevent crash on read-only buffers - Revert default of forceSoftwareVolume to false to restore hardware UAC volume control - Guard scalePcmInPlace against read-only buffers to permanently avoid ReadOnlyBufferException - Isolate software PCM scaling in ExclusiveUsbOutput to newly allocated write chunks - Reset pendingVolume to 1.0f across track transitions to eliminate zero-volume traps - Fallback to 24-bit container alt setting if initial alt setting fails on UAC2 DACs --- .../app/playback/ExclusiveUsbOutput.kt | 80 ++++++++++++++++++- .../app/playback/NativeProcessingAudioSink.kt | 14 +--- .../lastwave/app/playback/UacFeatureVolume.kt | 4 +- .../app/playback/usb/InternalDirectPrefs.kt | 2 +- 4 files changed, 82 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/lastwave/app/playback/ExclusiveUsbOutput.kt b/app/src/main/java/com/lastwave/app/playback/ExclusiveUsbOutput.kt index a64b2123a..151c82a69 100644 --- a/app/src/main/java/com/lastwave/app/playback/ExclusiveUsbOutput.kt +++ b/app/src/main/java/com/lastwave/app/playback/ExclusiveUsbOutput.kt @@ -80,7 +80,7 @@ class ExclusiveUsbOutput @Inject constructor( @Volatile private var lastNonMaxListeningGain = Float.NaN @Volatile private var ignoreStreamMusicMax = false /** Fork setting: never let the DAC's Feature Unit own the listening level. */ - @Volatile private var forceSoftwareVolume = true + @Volatile private var forceSoftwareVolume = false private val audioManager: AudioManager? = appContext.getSystemService(Context.AUDIO_SERVICE) as? AudioManager @@ -348,16 +348,25 @@ class ExclusiveUsbOutput @Inject constructor( startMediaTimeNeedsInit = false } recheckClockLocked() + val gain = softwareGain() val chunk = if (isFloatBuffer) { val floats = FloatArray(size / Float.SIZE_BYTES) buffer.order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer().get(floats) buffer.position(buffer.limit()) + if (gain < 1f - 1e-6f && gain >= 0f) { + for (i in floats.indices) { + floats[i] = (floats[i] * gain).coerceIn(-1f, 1f) + } + } QueuedPcm(floats, null, 0, size) } else { val encoding = writeRawEncoding(sourceEncoding) if (encoding < 0) return false val bytes = ByteArray(size) buffer.get(bytes) + if (gain < 1f - 1e-6f && gain >= 0f) { + scalePcmBytes(bytes, encoding, gain) + } QueuedPcm(null, bytes, encoding, size) } synchronized(pcmLock) { @@ -443,6 +452,10 @@ class ExclusiveUsbOutput @Inject constructor( paused = false pcmLock.notifyAll() } + synchronized(lock) { + pendingVolume = 1f + applyVolumeLocked() + } startMediaTimeNeedsInit = true startMediaTimeUs = 0L currentFallbackNegotiated = false @@ -504,6 +517,7 @@ class ExclusiveUsbOutput @Inject constructor( startMediaTimeNeedsInit = true mediaTimeBaseFrames = reuse.framesWritten ensureVolumeObserverLocked() + pendingVolume = 1f syncListeningGainLocked() return true } @@ -542,7 +556,15 @@ class ExclusiveUsbOutput @Inject constructor( var (alt, wireBits) = usbAudio.findAltSetting(effectiveBits, effectiveRate) var rateSetBefore = usbAudio.setSampleRate(effectiveRate) - if (!usbAudio.setAltSetting(alt)) return failLocked("setAltSetting $alt failed") + if (!usbAudio.setAltSetting(alt)) { + val fallbackAlt = usbAudio.findAltSetting(24, effectiveRate) + if (fallbackAlt.first != alt && usbAudio.setAltSetting(fallbackAlt.first)) { + alt = fallbackAlt.first + wireBits = fallbackAlt.second + } else { + return failLocked("setAltSetting $alt failed") + } + } var reported = usbAudio.readSampleRate() if (!rateSetBefore || (reported > 0 && reported != effectiveRate)) { rateSetBefore = usbAudio.setSampleRate(effectiveRate) @@ -643,6 +665,7 @@ class ExclusiveUsbOutput @Inject constructor( active = true lastFailureReason = null paused = false + pendingVolume = 1f startMediaTimeNeedsInit = true startMediaTimeUs = 0L mediaTimeBaseFrames = 0L @@ -988,6 +1011,59 @@ class ExclusiveUsbOutput @Inject constructor( C.ENCODING_PCM_32BIT, RAW_PCM32 -> RAW_PCM32 else -> -1 } + + private fun scalePcmBytes(bytes: ByteArray, rawEncoding: Int, gain: Float) { + if (gain <= 0f) { + bytes.fill(0) + return + } + when (rawEncoding) { + RAW_PCM16 -> { + var i = 0 + while (i + 1 < bytes.size) { + val sample = (bytes[i].toInt() and 0xFF) or (bytes[i + 1].toInt() shl 8) + val scaled = (sample.toShort() * gain + if (sample >= 0) 0.5f else -0.5f).toInt() + .coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()) + bytes[i] = (scaled and 0xFF).toByte() + bytes[i + 1] = ((scaled shr 8) and 0xFF).toByte() + i += 2 + } + } + RAW_PCM24 -> { + var i = 0 + while (i + 2 < bytes.size) { + val b0 = bytes[i].toInt() and 0xFF + val b1 = bytes[i + 1].toInt() and 0xFF + val b2 = bytes[i + 2].toInt() + var sample = (b2 shl 16) or (b1 shl 8) or b0 + if (sample and 0x800000 != 0) sample -= 0x1000000 + val scaled = (sample * gain.toDouble() + if (sample >= 0) 0.5 else -0.5).toInt() + .coerceIn(-0x800000, 0x7FFFFF) + bytes[i] = (scaled and 0xFF).toByte() + bytes[i + 1] = ((scaled shr 8) and 0xFF).toByte() + bytes[i + 2] = ((scaled shr 16) and 0xFF).toByte() + i += 3 + } + } + RAW_PCM32 -> { + var i = 0 + while (i + 3 < bytes.size) { + val b0 = bytes[i].toInt() and 0xFF + val b1 = bytes[i + 1].toInt() and 0xFF + val b2 = bytes[i + 2].toInt() and 0xFF + val b3 = bytes[i + 3].toInt() + val sample = (b3.toLong() shl 24) or (b2.toLong() shl 16) or (b1.toLong() shl 8) or (b0.toLong() and 0xFF) + val scaled = (sample * gain.toDouble()).toLong() + .coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt() + bytes[i] = (scaled and 0xFF).toByte() + bytes[i + 1] = ((scaled shr 8) and 0xFF).toByte() + bytes[i + 2] = ((scaled shr 16) and 0xFF).toByte() + bytes[i + 3] = ((scaled shr 24) and 0xFF).toByte() + i += 4 + } + } + } + } } } diff --git a/app/src/main/java/com/lastwave/app/playback/NativeProcessingAudioSink.kt b/app/src/main/java/com/lastwave/app/playback/NativeProcessingAudioSink.kt index 487a54163..e30591645 100644 --- a/app/src/main/java/com/lastwave/app/playback/NativeProcessingAudioSink.kt +++ b/app/src/main/java/com/lastwave/app/playback/NativeProcessingAudioSink.kt @@ -469,17 +469,7 @@ class NativeProcessingAudioSink( return handleExclusiveConvertedBuffer(buffer, presentationTimeUs) } val fmt = configuredFormat - val gain = exclusiveUsb?.softwareGain() ?: lastVolume - val needsGain = gain < 1f - 1e-6f && gain >= 0f && buffer.hasRemaining() && - fmt?.sampleMimeType == MimeTypes.AUDIO_RAW - if (needsGain && fmt != null) { - if (lastGainBuffer !== buffer) { - scalePcmInPlace(buffer, buffer.position(), buffer.limit(), fmt.pcmEncoding, gain) - lastGainBuffer = buffer - } - } else { - lastGainBuffer = null - } + lastGainBuffer = null val ok = exclusiveUsb?.write( buffer, presentationTimeUs, @@ -570,7 +560,6 @@ class NativeProcessingAudioSink( return activeDelegate.handleBuffer(buffer, presentationTimeUs, encodedAccessUnitCount) } - /** Linear gain on interleaved PCM, in place, with proper rounding/clipping. */ private fun scalePcmInPlace( buffer: ByteBuffer, startPos: Int, @@ -578,6 +567,7 @@ class NativeProcessingAudioSink( media3Encoding: Int, gain: Float, ) { + if (buffer.isReadOnly) return val originalOrder = buffer.order() try { buffer.order(ByteOrder.LITTLE_ENDIAN) diff --git a/app/src/main/java/com/lastwave/app/playback/UacFeatureVolume.kt b/app/src/main/java/com/lastwave/app/playback/UacFeatureVolume.kt index 7cdda64a4..88fab4230 100644 --- a/app/src/main/java/com/lastwave/app/playback/UacFeatureVolume.kt +++ b/app/src/main/java/com/lastwave/app/playback/UacFeatureVolume.kt @@ -62,9 +62,7 @@ class UacFeatureVolume( for (channel in channels) { if (writeVolume(featureUnitId, channel, coded)) ok = true } - if (clamped <= 0f) { - writeMute(featureUnitId, true) - } + writeMute(featureUnitId, clamped <= 0f) if (ok && clamped in 0.001f..0.85f && coded < -512) { val checkChannel = channels.first() val readback = readVolume(featureUnitId, checkChannel) diff --git a/app/src/main/java/com/lastwave/app/playback/usb/InternalDirectPrefs.kt b/app/src/main/java/com/lastwave/app/playback/usb/InternalDirectPrefs.kt index 6554f7c56..c71509e53 100644 --- a/app/src/main/java/com/lastwave/app/playback/usb/InternalDirectPrefs.kt +++ b/app/src/main/java/com/lastwave/app/playback/usb/InternalDirectPrefs.kt @@ -39,7 +39,7 @@ object InternalDirectPrefs { fun forceSoftwareVolumeFlow(context: Context): Flow = context.applicationContext.internalDirectDataStore.data .catch { emit(emptyPreferences()) } - .map { it[KEY_FORCE_SOFTWARE_VOLUME] ?: true } + .map { it[KEY_FORCE_SOFTWARE_VOLUME] == true } suspend fun setForceSoftwareVolume(context: Context, enabled: Boolean) { context.applicationContext.internalDirectDataStore.edit { it[KEY_FORCE_SOFTWARE_VOLUME] = enabled } From 4b789f6144694b91fb03b920686a08c7d7db1deb Mon Sep 17 00:00:00 2001 From: ajisth69 Date: Sun, 18 Oct 2026 12:21:12 +0530 Subject: [PATCH 02/12] fix(audio): eliminate hardware volume channel imbalance and stuck channel mute --- .../lastwave/app/playback/UacFeatureVolume.kt | 83 ++++++++++++++----- 1 file changed, 60 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/lastwave/app/playback/UacFeatureVolume.kt b/app/src/main/java/com/lastwave/app/playback/UacFeatureVolume.kt index 88fab4230..2c5231593 100644 --- a/app/src/main/java/com/lastwave/app/playback/UacFeatureVolume.kt +++ b/app/src/main/java/com/lastwave/app/playback/UacFeatureVolume.kt @@ -42,6 +42,7 @@ class UacFeatureVolume( featureUnitId = id channels = verifiedChannels available = true + writeMute(id, false) Log.i( TAG, "Feature Unit volume verified id=0x${id.toString(16)} " + @@ -164,32 +165,53 @@ class UacFeatureVolume( return raw.toShort().toInt() } - private fun verifyWritableChannels(unitId: Int, found: IntArray): IntArray { - val writable = mutableListOf() + private fun verifyChannelWritable(unitId: Int, channel: Int): Boolean { + val original = readVolume(unitId, channel) ?: return false val step = resRaw.coerceAtLeast(256) val testCandidate = (maxRaw - 12 * step).coerceIn(minRaw, maxRaw - step) - for (channel in found) { - val original = readVolume(unitId, channel) ?: continue - val test = if (kotlin.math.abs(original - testCandidate) >= step) { - testCandidate - } else { - (minRaw + 2 * step).coerceIn(minRaw, maxRaw) - } - if (!writeVolume(unitId, channel, test)) continue - runCatching { Thread.sleep(VERIFY_SLEEP_MS) } - val updated = readVolume(unitId, channel) - writeVolume(unitId, channel, original) - val changed = updated != null && updated != original - Log.i( - TAG, - "verify FU 0x${unitId.toString(16)} ch=$channel original=$original " + - "test=$test readback=$updated writable=$changed", - ) - if (changed) { - writable += channel + val test = if (kotlin.math.abs(original - testCandidate) >= step) { + testCandidate + } else { + (minRaw + 2 * step).coerceIn(minRaw, maxRaw) + } + if (!writeVolume(unitId, channel, test)) return false + runCatching { Thread.sleep(VERIFY_SLEEP_MS) } + val updated = readVolume(unitId, channel) + val restored = writeVolume(unitId, channel, original) + val changed = updated != null && updated != original + Log.i( + TAG, + "verify FU 0x${unitId.toString(16)} ch=$channel original=$original " + + "test=$test readback=$updated restored=$restored writable=$changed", + ) + return changed && restored + } + + private fun verifyWritableChannels(unitId: Int, found: IntArray): IntArray { + // Priority 1: Master channel (channel 0) + // In UAC 1.0 & 2.0, Master channel controls the entire cluster globally. + // Using Channel 0 guarantees both stereo channels stay perfectly balanced. + if (0 in found && verifyChannelWritable(unitId, 0)) { + // Reset discrete channels 1 (Left) and 2 (Right) trims to unity gain (0 dB / maxRaw) + // to ensure no residual attenuation or channel imbalance exists from previous tests or state. + if (1 in found) writeVolume(unitId, 1, maxRaw) + if (2 in found) writeVolume(unitId, 2, maxRaw) + return intArrayOf(0) + } + + // Priority 2: Discrete stereo channels (channel 1 = Left, channel 2 = Right) + // If master channel is absent or non-writable, both Left AND Right channels + // must be independently verified writable. If only one channel is writable, + // fail closed so hardware volume doesn't attenuate only one ear. + if (1 in found && 2 in found) { + val ch1Ok = verifyChannelWritable(unitId, 1) + val ch2Ok = verifyChannelWritable(unitId, 2) + if (ch1Ok && ch2Ok) { + return intArrayOf(1, 2) } } - return writable.toIntArray() + + return intArrayOf() } private fun readVolume(unitId: Int, channel: Int): Int? { @@ -236,7 +258,8 @@ class UacFeatureVolume( val data = byteArrayOf(if (mute) 1 else 0) val wIndex = (unitId shl 8) or controlInterfaceId var ok = false - for (channel in channels) { + val muteChannels = if (channels.contains(0)) intArrayOf(0) else channels + for (channel in muteChannels) { val wValue = MUTE_WVALUE or (channel and 0xFF) val ret = connection.controlTransfer( 0x21, @@ -249,6 +272,20 @@ class UacFeatureVolume( ) if (ret >= 0) ok = true } + if (!mute) { + for (ch in intArrayOf(1, 2)) { + val wValue = MUTE_WVALUE or (ch and 0xFF) + connection.controlTransfer( + 0x21, + 0x01, + wValue, + wIndex, + data, + data.size, + TIMEOUT_MS, + ) + } + } return ok } From f8791385e296a6abbea6dab2289be122d147a783 Mon Sep 17 00:00:00 2001 From: ajisth69 Date: Sun, 18 Oct 2026 12:25:59 +0530 Subject: [PATCH 03/12] fix(audio): prioritize playback feature unit over microphone to prevent volume mismatch --- .../lastwave/app/playback/UacFeatureVolume.kt | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/lastwave/app/playback/UacFeatureVolume.kt b/app/src/main/java/com/lastwave/app/playback/UacFeatureVolume.kt index 2c5231593..42c233106 100644 --- a/app/src/main/java/com/lastwave/app/playback/UacFeatureVolume.kt +++ b/app/src/main/java/com/lastwave/app/playback/UacFeatureVolume.kt @@ -291,7 +291,10 @@ class UacFeatureVolume( private fun parseFeatureUnitIds(): List { val raw = connection.rawDescriptors ?: return emptyList() - val ids = mutableListOf() + val allFeatureUnits = mutableListOf() + val outputTerminals = mutableListOf>() // Pair(terminalType, sourceId) + val unitSources = mutableMapOf() // unitId -> sourceId + var i = 0 var inAudioControl = false while (i + 1 < raw.size) { @@ -305,17 +308,56 @@ class UacFeatureVolume( } if (inAudioControl && type == 0x24 && length >= 4) { val subtype = raw[i + 2].toInt() and 0xFF - if (subtype == FEATURE_UNIT_SUBTYPE) { - ids += raw[i + 3].toInt() and 0xFF + val unitId = raw[i + 3].toInt() and 0xFF + when (subtype) { + FEATURE_UNIT_SUBTYPE -> { + allFeatureUnits += unitId + if (length >= 5) { + unitSources[unitId] = raw[i + 4].toInt() and 0xFF + } + } + OUTPUT_TERMINAL_SUBTYPE -> { + if (length >= 8) { + val termType = (raw[i + 4].toInt() and 0xFF) or ((raw[i + 5].toInt() and 0xFF) shl 8) + val srcId = raw[i + 7].toInt() and 0xFF + outputTerminals += Pair(termType, srcId) + } + } + else -> { + if (length >= 5) { + unitSources[unitId] = raw[i + 4].toInt() and 0xFF + } + } } } i += length } - return ids + + // Trace backwards from physical playback Output Terminals (0x0300..0x03FF: Speaker, Headphones) + val playbackFus = mutableListOf() + for ((termType, srcId) in outputTerminals) { + if ((termType and 0xFF00) == 0x0300) { + var curr = srcId + val visited = mutableSetOf() + while (curr in unitSources && visited.add(curr)) { + if (curr in allFeatureUnits) { + playbackFus += curr + break + } + curr = unitSources[curr] ?: break + } + if (srcId in allFeatureUnits && srcId !in playbackFus) { + playbackFus += srcId + } + } + } + + return (playbackFus + allFeatureUnits).distinct() } companion object { const val TAG = "UacFeatureVolume" + const val OUTPUT_TERMINAL_SUBTYPE = 0x03 const val FEATURE_UNIT_SUBTYPE = 0x06 const val VOLUME_WVALUE = 0x0200 const val MUTE_WVALUE = 0x0100 From 4de22e5646fc6efee0e62117a8ea9f18b1e01dfe Mon Sep 17 00:00:00 2001 From: ajisth69 Date: Sun, 18 Oct 2026 13:02:00 +0530 Subject: [PATCH 04/12] fix(audio): maintain hardware volume on UAC1 DACs and clear persistent blocked device keys --- .../app/playback/ExclusiveUsbOutput.kt | 11 +++++--- .../lastwave/app/playback/UacFeatureVolume.kt | 27 ++++++++----------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/lastwave/app/playback/ExclusiveUsbOutput.kt b/app/src/main/java/com/lastwave/app/playback/ExclusiveUsbOutput.kt index 151c82a69..f83708ba6 100644 --- a/app/src/main/java/com/lastwave/app/playback/ExclusiveUsbOutput.kt +++ b/app/src/main/java/com/lastwave/app/playback/ExclusiveUsbOutput.kt @@ -88,6 +88,7 @@ class ExclusiveUsbOutput @Inject constructor( appContext.getSharedPreferences("lastwave_exclusive_usb", Context.MODE_PRIVATE) init { + clearBlockedDevices() lastNonMaxListeningGain = volumePrefs.getFloat(KEY_LAST_NON_MAX_GAIN, Float.NaN) rememberStreamGain(readStreamMusicGain()) } @@ -719,12 +720,14 @@ class ExclusiveUsbOutput @Inject constructor( @Volatile private var currentDeviceKey: String? = null @Volatile private var sessionSummary: String = "" + private val sessionBlockedDevices = java.util.concurrent.ConcurrentHashMap() private fun blockReasonFor(vendorId: Int, productId: Int): String? = - volumePrefs.getString("$KEY_BLOCK_PREFIX$vendorId:$productId", null) + sessionBlockedDevices["$vendorId:$productId"] - /** Forget every DAC the guard disabled (USB Exclusive toggled off then on). */ + /** Forget every DAC the guard disabled. */ fun clearBlockedDevices() { + sessionBlockedDevices.clear() val editor = volumePrefs.edit() volumePrefs.all.keys.filter { it.startsWith(KEY_BLOCK_PREFIX) }.forEach { editor.remove(it) } editor.apply() @@ -733,7 +736,7 @@ class ExclusiveUsbOutput @Inject constructor( /** * The DAC is not consuming the stream at the rate it was configured for * and its clock was never confirmed: what it plays is garbage (loud noise - * on a UAC1 dongle in the field). Stop now, remember the DAC so the next + * on a UAC1 dongle in the field). Stop now, remember the DAC for this session so the next * track does not blast it again, and let the sink fall back. */ fun abortUnhealthy(reason: String) { @@ -742,7 +745,7 @@ class ExclusiveUsbOutput @Inject constructor( val key = currentDeviceKey Log.w(TAG, "Exclusive USB aborted: $reason (device $key)") if (key != null) { - runCatching { volumePrefs.edit().putString("$KEY_BLOCK_PREFIX$key", reason).apply() } + sessionBlockedDevices[key] = reason } failLocked("stopped for safety: $reason") } diff --git a/app/src/main/java/com/lastwave/app/playback/UacFeatureVolume.kt b/app/src/main/java/com/lastwave/app/playback/UacFeatureVolume.kt index 42c233106..477b382a0 100644 --- a/app/src/main/java/com/lastwave/app/playback/UacFeatureVolume.kt +++ b/app/src/main/java/com/lastwave/app/playback/UacFeatureVolume.kt @@ -64,22 +64,13 @@ class UacFeatureVolume( if (writeVolume(featureUnitId, channel, coded)) ok = true } writeMute(featureUnitId, clamped <= 0f) - if (ok && clamped in 0.001f..0.85f && coded < -512) { - val checkChannel = channels.first() - val readback = readVolume(featureUnitId, checkChannel) - if (readback != null && readback >= maxRaw && maxRaw > coded + 512) { - Log.w( - TAG, - "DAC ignored SET_CUR coded=$coded on ch=$checkChannel (readback=$readback); failing closed", - ) - return false - } - } return ok } private fun discoverChannels(unitId: Int): IntArray { - val found = CHANNELS_TO_TRY.filter { readVolume(unitId, it) != null } + val found = CHANNELS_TO_TRY.filter { ch -> + readVolume(unitId, ch) != null || writeVolume(unitId, ch, maxRaw) + } return found.toIntArray() } @@ -166,10 +157,10 @@ class UacFeatureVolume( } private fun verifyChannelWritable(unitId: Int, channel: Int): Boolean { - val original = readVolume(unitId, channel) ?: return false + val original = readVolume(unitId, channel) val step = resRaw.coerceAtLeast(256) val testCandidate = (maxRaw - 12 * step).coerceIn(minRaw, maxRaw - step) - val test = if (kotlin.math.abs(original - testCandidate) >= step) { + val test = if (original != null && kotlin.math.abs(original - testCandidate) >= step) { testCandidate } else { (minRaw + 2 * step).coerceIn(minRaw, maxRaw) @@ -177,14 +168,18 @@ class UacFeatureVolume( if (!writeVolume(unitId, channel, test)) return false runCatching { Thread.sleep(VERIFY_SLEEP_MS) } val updated = readVolume(unitId, channel) - val restored = writeVolume(unitId, channel, original) + val restored = if (original != null) { + writeVolume(unitId, channel, original) + } else { + writeVolume(unitId, channel, maxRaw) + } val changed = updated != null && updated != original Log.i( TAG, "verify FU 0x${unitId.toString(16)} ch=$channel original=$original " + "test=$test readback=$updated restored=$restored writable=$changed", ) - return changed && restored + return (changed || restored) } private fun verifyWritableChannels(unitId: Int, found: IntArray): IntArray { From f9ab7d024fa8e6f630a8966f6ced553874ccb213 Mon Sep 17 00:00:00 2001 From: ajisth69 Date: Sun, 18 Oct 2026 14:10:21 +0530 Subject: [PATCH 05/12] fix(audio): eliminate startup NullPointerException from ExclusiveUsbOutput initialization order --- .../lastwave/app/playback/ExclusiveUsbOutput.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/lastwave/app/playback/ExclusiveUsbOutput.kt b/app/src/main/java/com/lastwave/app/playback/ExclusiveUsbOutput.kt index f83708ba6..44fa41e55 100644 --- a/app/src/main/java/com/lastwave/app/playback/ExclusiveUsbOutput.kt +++ b/app/src/main/java/com/lastwave/app/playback/ExclusiveUsbOutput.kt @@ -82,6 +82,10 @@ class ExclusiveUsbOutput @Inject constructor( /** Fork setting: never let the DAC's Feature Unit own the listening level. */ @Volatile private var forceSoftwareVolume = false + private val sessionBlockedDevices = java.util.concurrent.ConcurrentHashMap() + @Volatile private var currentDeviceKey: String? = null + @Volatile private var sessionSummary: String = "" + private val audioManager: AudioManager? = appContext.getSystemService(Context.AUDIO_SERVICE) as? AudioManager private val volumePrefs = @@ -718,18 +722,15 @@ class ExclusiveUsbOutput @Inject constructor( // ── Fork: safety guard + diagnostics ── - @Volatile private var currentDeviceKey: String? = null - @Volatile private var sessionSummary: String = "" - private val sessionBlockedDevices = java.util.concurrent.ConcurrentHashMap() - private fun blockReasonFor(vendorId: Int, productId: Int): String? = - sessionBlockedDevices["$vendorId:$productId"] + sessionBlockedDevices[vendorId.toString() + ":" + productId] /** Forget every DAC the guard disabled. */ fun clearBlockedDevices() { sessionBlockedDevices.clear() - val editor = volumePrefs.edit() - volumePrefs.all.keys.filter { it.startsWith(KEY_BLOCK_PREFIX) }.forEach { editor.remove(it) } + val prefs = volumePrefs ?: return + val editor = prefs.edit() + prefs.all?.keys?.filter { it.startsWith(KEY_BLOCK_PREFIX) }?.forEach { editor.remove(it) } editor.apply() } From 8b3ee5aa7fc2838cfbe9074cb92c371633d2ea76 Mon Sep 17 00:00:00 2001 From: ajisth69 Date: Sun, 18 Oct 2026 19:06:25 +0530 Subject: [PATCH 06/12] fix(playback): fix crossfade audio focus pause loop and redesign output detector to Poweramp signal path --- .../app/playback/ExclusiveUsbOutput.kt | 2 + .../com/lastwave/app/playback/MusicPlayer.kt | 6 +- .../com/lastwave/app/playback/SignalPath.kt | 1390 +++++++++-------- .../app/ui/player/SignalPathDialog.kt | 1051 ++++++++----- app/src/main/res/values/strings.xml | 2 +- .../playback/ExclusiveUsbSignalPathTest.kt | 508 +++--- 6 files changed, 1694 insertions(+), 1265 deletions(-) diff --git a/app/src/main/java/com/lastwave/app/playback/ExclusiveUsbOutput.kt b/app/src/main/java/com/lastwave/app/playback/ExclusiveUsbOutput.kt index 44fa41e55..11fe20ffa 100644 --- a/app/src/main/java/com/lastwave/app/playback/ExclusiveUsbOutput.kt +++ b/app/src/main/java/com/lastwave/app/playback/ExclusiveUsbOutput.kt @@ -170,6 +170,8 @@ class ExclusiveUsbOutput @Inject constructor( fun currentRateHz(): Int = if (active) configuredRateHz else 0 + fun activeWireBits(): Int = if (active) activeWireBits else 0 + fun lastHardwareRateHz(): Int = lastHardwareRate fun supportedHardwareRatesHz(): List = usbAudio.querySupportedSampleRates() diff --git a/app/src/main/java/com/lastwave/app/playback/MusicPlayer.kt b/app/src/main/java/com/lastwave/app/playback/MusicPlayer.kt index 0e45b7588..e12796ab9 100644 --- a/app/src/main/java/com/lastwave/app/playback/MusicPlayer.kt +++ b/app/src/main/java/com/lastwave/app/playback/MusicPlayer.kt @@ -3000,7 +3000,10 @@ class MusicPlayer @Inject constructor( outgoingPlayer = outgoing activePlayer = standby standby.addListener(listener) - standby.setAudioAttributes(standby.audioAttributes, true) + // Keep handleAudioFocus = false: LastWave manages audio focus manually via requestMediaAudioFocus(). + // Setting true here activates ExoPlayer's internal AudioFocusManager which steals focus, causing Android + // to send AUDIOFOCUS_LOSS (-1) to LastWave's own focus listener, immediately pausing playback. + standby.setAudioAttributes(standby.audioAttributes, false) standby.play() // The standby is now the live player; the outgoing one is the next // standby. Cleared so the downloader resumes caching the new track. @@ -3564,6 +3567,7 @@ class MusicPlayer @Inject constructor( exclusiveHardwareVolume = hardwareVolume, exclusiveFailureReason = exclusiveUsbOutput.lastFailureReason.takeIf { !exclusive }, clockFallbackResampled = clockFallback, + wireBits = exclusiveUsbOutput.activeWireBits(), ), ) } diff --git a/app/src/main/java/com/lastwave/app/playback/SignalPath.kt b/app/src/main/java/com/lastwave/app/playback/SignalPath.kt index ef4a53850..c4565945a 100644 --- a/app/src/main/java/com/lastwave/app/playback/SignalPath.kt +++ b/app/src/main/java/com/lastwave/app/playback/SignalPath.kt @@ -1,647 +1,743 @@ -package com.lastwave.app.playback - -import android.media.AudioManager -import android.media.AudioTrack -import com.lastwave.app.R - -/** - * Verified USB-DAC signal-path model. - * - * A matching nominal mixer rate does not prove bit-perfect output. USB mixer - * configuration and actual routing must be distinguished from device preference. - */ - -/** A USB audio peripheral visible to the platform mixer. */ -data class UsbDacInfo( - val name: String, - val vendorId: Int = -1, - val productId: Int = -1, - /** Sample rates from the platform audio descriptor; empty = undisclosed. */ - val sampleRatesHz: List = emptyList(), - val channelCounts: List = emptyList(), - /** Direct USB access granted via [UsbDacMonitor.requestPermission]. */ - val usbPermissionGranted: Boolean = false, - /** True when a USB audio peripheral exists on the USB bus itself. */ - val hasUsbPeripheral: Boolean = false, - /** [android.media.AudioDeviceInfo.getId] used for preferred-device routing. */ - val deviceId: Int = -1, -) - -/** Raw inputs for [evaluateSignalPath]; snapshot on the main thread. */ -data class SignalPathInput( - val sourceLabel: String, - val sourceRateHz: Int?, - val sourceBitDepth: Int?, - val isLossless: Boolean, - /** Actual AudioTrack rate the app opened (0 = unresolved). */ - val appOutputRateHz: Int, - /** Platform mixer rate ([AudioTrack.getNativeOutputSampleRate]). */ - val platformMixerRateHz: Int, - val dspBypassEnabled: Boolean, - val crossfadeMixing: Boolean, - val speed: Float, - /** Effective app gain reaching AudioTrack (1 = unity; <1 during duck/fade). */ - val appVolume: Float, - val systemVolume: Int, - val systemVolumeMax: Int, - /** True when volume is fixed/hardware-controlled (no digital scaling). */ - val systemVolumeFixed: Boolean, - val dac: UsbDacInfo?, - val routedToDac: Boolean, - /** True only when the platform granted BIT_PERFECT on the DAC route. */ - val routeVerified: Boolean, - val driftPpm: Double?, - val glitchCount: Long, - val isPlaying: Boolean, - val platformBitPerfectConfigured: Boolean = false, - /** True while usbdevfs exclusive output owns the DAC clock. */ - val usbExclusiveActive: Boolean = false, - /** GET_CUR sample rate matches the requested exclusive rate. */ - val exclusiveClockMatched: Boolean = false, - /** UAC Feature Unit volume is in use (PCM payload unscaled). */ - val exclusiveHardwareVolume: Boolean = false, - /** Last exclusive open failure, if any (openDevice/alt-setting/etc). */ - val exclusiveFailureReason: String? = null, - /** True when exclusive USB resampled through soxr because DAC clock unsupported. */ - val clockFallbackResampled: Boolean = false, - /** Fork: AAudio exclusive (MMAP) stream on the phone's own output, when playing. */ - val internalDirect: InternalDirectSession? = null, - /** Fork: the user asked for the internal direct output on this route. */ - val internalDirectWanted: Boolean = false, - /** Fork: why the internal direct output did not engage, when wanted. */ - val internalDirectFailure: InternalDirectReason? = null, - /** Bit-Perfect (or a bypass mode) requested by the user: the badge says "Mixer" when it is not achieved. */ - val bypassRequested: Boolean = false, -) - -/** - * One measured check. Label/detail are string-resource IDs resolved in the UI - * ([SignalPathDialog]) so the whole signal-path screen follows the app - * language; [detailArgs] are plain numbers/names needing no translation. - */ -data class PathCheck( - val labelRes: Int, - val detailRes: Int, - val detailArgs: List = emptyList(), - val passed: Boolean, -) - -data class SignalPathReport( - val checks: List, - val bitPerfect: Boolean, - val sourceLabel: String, - val sourceRateHz: Int?, - val appRateHz: Int, - val platformRateHz: Int, - val dacName: String?, - val driftPpm: Double?, - val glitchCount: Long, - val isPlaying: Boolean, - val usbExclusiveActive: Boolean = false, - val clockFallbackResampled: Boolean = false, - /** Fork: samples reach the HAL via the internal exclusive stream. */ - val internalDirectActive: Boolean = false, - /** - * Fork verdict for the internal route: every check passed, so the decoded - * samples enter the HAL buffer unchanged. Deliberately NOT [bitPerfect]: - * a vendor DSP after the HAL cannot be verified from Android. - */ - val directToHal: Boolean = false, - val internalSession: InternalDirectSession? = null, - val bypassRequested: Boolean = false, -) { - companion object { - fun initial() = SignalPathReport( - checks = listOf( - PathCheck(R.string.signal_stream, R.string.signal_detail_no_stream, passed = false), - ), - bitPerfect = false, - sourceLabel = "—", - sourceRateHz = null, - appRateHz = 0, - platformRateHz = 0, - dacName = null, - driftPpm = null, - glitchCount = 0L, - isPlaying = false, - usbExclusiveActive = false, - clockFallbackResampled = false, - ) - } -} - -/** Platform mixer rate for music streams; 0 when unreadable. */ -fun AudioManager.mixerRateHz(): Int = runCatching { - AudioTrack.getNativeOutputSampleRate(AudioManager.STREAM_MUSIC) -}.getOrDefault(0) - -fun evaluateSignalPath(i: SignalPathInput): SignalPathReport { - val checks = mutableListOf() - - // 1 — Source format. Exclusive usbdevfs is clocked by the decoded PCM - // rate, so a missing container/tag rate still uses the exclusive output - // rate rather than failing gold as "unknown". - val src = i.sourceRateHz?.takeIf { it > 0 } - ?: i.appOutputRateHz.takeIf { it > 0 && i.usbExclusiveActive } - val labelBitDepth = Regex("""(?:^|[^\d])(16|24|32)\s*(?:[-_]bit)?\s*[/]""", RegexOption.IGNORE_CASE) - .find(i.sourceLabel)?.groupValues?.getOrNull(1)?.toIntOrNull() - val rawBitDepth = labelBitDepth ?: i.sourceBitDepth?.takeIf { it > 0 } - val bitDepth = when { - src != null && src > 192_000 -> 32 - src != null && src > 48_000 -> 24 - rawBitDepth != null && rawBitDepth > 16 -> rawBitDepth - i.sourceLabel.contains("HI-RES", ignoreCase = true) || i.sourceLabel.contains("HI_RES", ignoreCase = true) -> 24 - rawBitDepth != null -> rawBitDepth - else -> null - } - val sourceLabel = if (bitDepth != null && src != null && src > 48_000 && i.sourceLabel.startsWith("16/")) { - i.sourceLabel.replaceFirst("16/", "$bitDepth/") - } else { - i.sourceLabel - } - if (src != null) { - if (bitDepth != null) { - checks += PathCheck( - R.string.signal_label_source, - R.string.signal_detail_src, - listOf(sourceLabel, bitDepth, src), - passed = true, - ) - } else { - checks += PathCheck( - R.string.signal_label_source, - R.string.signal_detail_src_nobit, - listOf(i.sourceLabel, src), - passed = true, - ) - } - } else { - checks += PathCheck( - R.string.signal_label_source, - R.string.signal_detail_src_unknown, - passed = false, - ) - } - - // 2 — App resampler (libsoxr stage inside the native pipeline). - val app = i.appOutputRateHz.takeIf { it > 0 } - if (src != null && app != null) { - if (app == src) { - checks += PathCheck( - R.string.signal_label_resampler, - R.string.signal_detail_resample_bypass, - listOf(src), - passed = true, - ) - } else if (i.usbExclusiveActive && i.clockFallbackResampled) { - checks += PathCheck( - R.string.signal_label_resampler, - R.string.signal_detail_resample_dac_unsupported, - listOf(src, app), - passed = false, - ) - } else if (i.internalDirect != null) { - checks += PathCheck( - R.string.signal_label_resampler, - R.string.signal_detail_internal_resampled, - listOf(src, app), - passed = false, - ) - } else { - checks += PathCheck( - R.string.signal_label_resampler, - R.string.signal_detail_resample_active, - listOf(src, app), - passed = false, - ) - } - } else { - checks += PathCheck( - R.string.signal_label_resampler, - R.string.signal_detail_resample_unresolved, - passed = false, - ) - } - - // 3 — DSP chain (native DSP + AudioFX + crossfade mixer). - if (!i.dspBypassEnabled) { - checks += PathCheck( - R.string.signal_label_dsp, - R.string.signal_detail_dsp_off, - passed = false, - ) - } else if (i.crossfadeMixing) { - checks += PathCheck( - R.string.signal_label_dsp, - R.string.signal_detail_dsp_crossfade, - passed = false, - ) - } else { - checks += PathCheck( - R.string.signal_label_dsp, - R.string.signal_detail_dsp_bypass, - passed = true, - ) - } - - // 4 — Tempo/pitch (resampling by definition when != 1x). - if (i.speed == 1f) { - checks += PathCheck( - R.string.signal_label_tempo, - R.string.signal_detail_tempo_ok, - passed = true, - ) - } else { - checks += PathCheck( - R.string.signal_label_tempo, - R.string.signal_detail_tempo_active, - listOf(i.speed.toString()), - passed = false, - ) - } - - // 5 — App gain staging (fades, ducking and software volume all scale here). - if (i.usbExclusiveActive && i.exclusiveHardwareVolume) { - checks += PathCheck( - R.string.signal_label_appvol, - R.string.signal_detail_usb_hw_vol, - passed = true, - ) - } else if (i.appVolume == 1f) { - checks += PathCheck( - R.string.signal_label_appvol, - R.string.signal_detail_gain_unity, - passed = true, - ) - } else { - checks += PathCheck( - R.string.signal_label_appvol, - R.string.signal_detail_gain_scaled, - listOf(i.appVolume.toString()), - passed = false, - ) - } - - // 6 — System gain staging (digital attenuation happens before the DAC, - // unless the route uses fixed/hardware volume, which never scales PCM). - if (i.usbExclusiveActive && i.exclusiveHardwareVolume) { - checks += PathCheck( - R.string.signal_label_sysvol, - R.string.signal_detail_usb_hw_vol, - passed = true, - ) - } else if (i.systemVolumeFixed) { - checks += PathCheck( - R.string.signal_label_sysvol, - R.string.signal_detail_sysvol_hw, - passed = true, - ) - } else if (i.systemVolumeMax > 0 && i.systemVolume >= i.systemVolumeMax) { - checks += PathCheck( - R.string.signal_label_sysvol, - R.string.signal_detail_sysvol_max, - passed = true, - ) - } else if (i.systemVolumeMax > 0) { - checks += PathCheck( - R.string.signal_label_sysvol, - R.string.signal_detail_sysvol_scaled, - listOf(i.systemVolume, i.systemVolumeMax), - passed = false, - ) - } else { - checks += PathCheck( - R.string.signal_label_sysvol, - R.string.signal_detail_unreadable, - passed = false, - ) - } - - // 7 — Platform mixer. Gold requires exclusive usbdevfs; an Android - // mixer BIT_PERFECT grant is never treated as verified bit-perfect. - val plat = i.platformMixerRateHz.takeIf { it > 0 } - val internal = i.internalDirect - if (internal != null) { - checks += when { - internal.backend == InternalBackend.DIRECT_TRACK && internal.directVerified -> PathCheck( - R.string.signal_label_mixer, - R.string.signal_detail_internal_mixer_direct_track, - passed = true, - ) - internal.backend == InternalBackend.DIRECT_TRACK -> PathCheck( - R.string.signal_label_mixer, - R.string.signal_detail_internal_mixer_direct_unverified, - passed = false, - ) - internal.mmap -> PathCheck( - R.string.signal_label_mixer, - R.string.signal_detail_internal_mixer_bypassed, - passed = true, - ) - else -> PathCheck( - R.string.signal_label_mixer, - R.string.signal_detail_internal_mixer_bypassed_nommap, - passed = true, - ) - } - } else if (i.usbExclusiveActive) { - checks += PathCheck( - R.string.signal_label_mixer, - R.string.signal_detail_usb_exclusive, - passed = true, - ) - } else if (i.platformBitPerfectConfigured) { - checks += PathCheck( - R.string.signal_label_mixer, - R.string.signal_detail_mixer_not_exclusive, - passed = false, - ) - } else if (app != null && plat != null) { - if (plat == app) { - checks += PathCheck( - R.string.signal_label_mixer, - R.string.signal_detail_bit_perfect_unavailable, - listOf(plat), - passed = false, - ) - } else { - checks += PathCheck( - R.string.signal_label_mixer, - R.string.signal_detail_mixer_src, - listOf(app, plat), - passed = false, - ) - } - } else { - checks += PathCheck( - R.string.signal_label_mixer, - R.string.signal_detail_mixer_unreadable, - passed = false, - ) - } - - // Fork — internal direct output: the route is the phone itself, so the - // USB DAC rows below do not apply. Report the wire format the endpoint - // actually runs at and whether it can carry every source bit. - if (internal != null) { - val deviceFormat = InternalDirectStream.labelOf(internal.deviceEncoding) - val routeLabel = internal.routeName.ifBlank { internal.route.name.lowercase() } - val sourceFormat = "${internal.sourceBits}-bit" - when { - !internal.formatConverted -> checks += PathCheck( - R.string.signal_label_output, - R.string.signal_detail_internal_route, - listOf(routeLabel, internal.deviceRateHz, deviceFormat), - passed = true, - ) - internal.formatLossless -> checks += PathCheck( - R.string.signal_label_output, - R.string.signal_detail_internal_route_widened, - listOf(routeLabel, internal.deviceRateHz, sourceFormat, deviceFormat), - passed = true, - ) - else -> checks += PathCheck( - R.string.signal_label_output, - R.string.signal_detail_internal_route_lossy, - listOf(routeLabel, internal.deviceRateHz, deviceFormat), - passed = false, - ) - } - if (internal.route == InternalRoute.BLUETOOTH) { - // The bypass ends at the DSP: the Bluetooth codec re-encodes after it. - checks += PathCheck( - R.string.signal_label_bt_codec, - R.string.signal_detail_bt_codec, - passed = false, - ) - } - return finishReport(i, checks, src, app, plat, dac = i.dac) - } - if (!i.usbExclusiveActive && i.internalDirectFailure != null) { - checks += PathCheck( - R.string.signal_label_internal, - internalFailureDetail(i.internalDirectFailure), - passed = false, - ) - } - - // 8 — Output route must be the USB DAC. - val dac = i.dac - when { - dac == null -> checks += PathCheck( - R.string.signal_label_output, - R.string.signal_detail_no_dac, - passed = false, - ) - !i.routedToDac -> checks += PathCheck( - R.string.signal_label_output, - R.string.signal_detail_not_routed, - listOf(dac.name), - passed = false, - ) - i.usbExclusiveActive -> - checks += PathCheck( - R.string.signal_label_output, - R.string.signal_detail_usb_exclusive_route, - listOf(dac.name), - passed = true, - ) - app != null && dac.sampleRatesHz.isNotEmpty() && app in dac.sampleRatesHz -> - checks += PathCheck( - R.string.signal_label_output, - R.string.signal_detail_usb_ok_accepts, - listOf(dac.name, app), - passed = true, - ) - app != null && dac.sampleRatesHz.isNotEmpty() -> - checks += PathCheck( - R.string.signal_label_output, - R.string.signal_detail_usb_ok_rejects, - listOf(dac.name, app), - passed = false, - ) - else -> checks += PathCheck( - R.string.signal_label_output, - R.string.signal_detail_usb_ok_plain, - listOf(dac.name), - passed = false, - ) - } - - // 9 — Exclusive clock verification. Mixer routing is never gold. - // When exclusive failed to start, name the reason (device/permission/ - // alt-setting/stream start) instead of the generic unverified line so - // the failure is diagnosable from the dialog alone. - when { - !i.usbExclusiveActive && i.exclusiveFailureReason != null -> checks += PathCheck( - R.string.signal_label_output, - R.string.signal_detail_exclusive_failed, - listOf(i.exclusiveFailureReason), - passed = false, - ) - !i.usbExclusiveActive -> checks += PathCheck( - R.string.signal_label_output, - R.string.signal_detail_route_unverified, - passed = false, - ) - i.clockFallbackResampled -> checks += PathCheck( - R.string.signal_label_output, - R.string.signal_detail_usb_clock_fallback, - listOf(src ?: 0, app ?: 0), - passed = false, - ) - i.exclusiveClockMatched || i.routeVerified -> checks += PathCheck( - R.string.signal_label_output, - R.string.signal_detail_usb_clock_ok, - passed = true, - ) - else -> checks += PathCheck( - R.string.signal_label_output, - R.string.signal_detail_usb_clock_mismatch, - passed = false, - ) - } - - return finishReport(i, checks, src, app, plat, dac) -} - -private fun finishReport( - i: SignalPathInput, - checks: List, - src: Int?, - app: Int?, - plat: Int?, - dac: UsbDacInfo?, -): SignalPathReport { - val internal = i.internalDirect - return SignalPathReport( - checks = checks, - bitPerfect = internal == null && - i.usbExclusiveActive && - !i.clockFallbackResampled && - (i.exclusiveClockMatched || i.routeVerified) && - checks.all { it.passed }, - sourceLabel = i.sourceLabel, - sourceRateHz = src, - appRateHz = app ?: 0, - platformRateHz = plat ?: 0, - dacName = dac?.name, - driftPpm = i.driftPpm, - glitchCount = i.glitchCount, - isPlaying = i.isPlaying, - usbExclusiveActive = i.usbExclusiveActive, - clockFallbackResampled = i.clockFallbackResampled || internal?.resampled == true, - internalDirectActive = internal != null, - directToHal = internal != null && checks.all { it.passed }, - internalSession = internal, - bypassRequested = i.bypassRequested, - ) -} - -/** One sentence per refusal reason, shown as the "Internal direct output" row. */ -fun internalFailureDetail(reason: InternalDirectReason): Int = when (reason) { - InternalDirectReason.BLUETOOTH -> R.string.internal_reason_bluetooth - InternalDirectReason.USB -> R.string.internal_reason_usb - InternalDirectReason.EXCLUSIVE_REFUSED -> R.string.internal_reason_exclusive_refused - InternalDirectReason.NO_AAUDIO -> R.string.internal_reason_no_aaudio - InternalDirectReason.ANDROID_TOO_OLD -> R.string.internal_reason_android_old - InternalDirectReason.NATIVE_MISSING -> R.string.internal_reason_native_missing - InternalDirectReason.CHECKING -> R.string.internal_reason_checking - InternalDirectReason.AVAILABLE -> R.string.internal_reason_available - InternalDirectReason.ACTIVE -> R.string.internal_reason_active - InternalDirectReason.MIXED_BY_ANDROID -> R.string.internal_reason_mixed -} - -/** - * Playback-clock health from ExoPlayer position vs wall clock. Measures the - * effective stream rate (reveals resampling/speed anomalies) and counts - * glitches (backward jumps/stalls while playing). Timing-neutral: it never - * touches audio, it only observes. - */ -class StreamHealthTracker { - var driftPpm: Double? = null - private set - var glitchCount: Long = 0L - private set - - private var lastPositionMs = -1L - private var lastWallMs = 0L - private var exclusiveOriginFrames = -1L - private var exclusiveOriginWallMs = 0L - - fun reset() { - driftPpm = null - lastPositionMs = -1L - lastWallMs = 0L - exclusiveOriginFrames = -1L - exclusiveOriginWallMs = 0L - } - - /** - * Returns the smoothed drift in PPM, or null until enough data exists. - * Seeks re-baseline silently; backward jumps/stalls while playing count - * as glitches. - */ - fun sample(positionMs: Long, wallMs: Long, playing: Boolean): Double? { - if (!playing || positionMs < 0) { - lastPositionMs = -1L - if (!playing) driftPpm = null - return driftPpm - } - if (lastPositionMs < 0) { - lastPositionMs = positionMs - lastWallMs = wallMs - return driftPpm - } - val wallDelta = wallMs - lastWallMs - val posDelta = positionMs - lastPositionMs - lastPositionMs = positionMs - lastWallMs = wallMs - if (wallDelta < 400L || wallDelta > 3_000L) return driftPpm - if (kotlin.math.abs(posDelta - wallDelta) > 1_500L) { - // DASH/FLAC timelines jump by seconds while audio keeps playing. - // Re-baseline (lastPosition already moved) but keep the last PPM. - // Clearing it here left lossless stuck on "measuring…". - if (posDelta < -250L) glitchCount++ - return driftPpm - } - if (posDelta <= 0L) { - if (wallDelta >= 800L) glitchCount++ - return driftPpm - } - val instant = (posDelta - wallDelta).toDouble() / wallDelta * 1_000_000.0 - driftPpm = if (driftPpm == null) instant else driftPpm!! * 0.85 + instant * 0.15 - return driftPpm - } - - /** - * Exclusive usbdevfs clock vs wall. Do not reuse [sample]: lossless - * handleBuffer blocks for seconds inside USB write(), so the Java-visible - * frame count jumps >1.5 s and the ExoPlayer seek detector stuck drift - * on "measuring…". Compare cumulative frames to wall from a baseline. - */ - fun sampleExclusive(framesWritten: Long, rateHz: Int, wallMs: Long, playing: Boolean): Double? { - if (!playing) { - exclusiveOriginFrames = -1L - driftPpm = null - return null - } - if (rateHz <= 0 || framesWritten < 0L) { - exclusiveOriginFrames = -1L - return driftPpm - } - if (exclusiveOriginFrames < 0L || framesWritten < exclusiveOriginFrames) { - exclusiveOriginFrames = framesWritten - exclusiveOriginWallMs = wallMs - return driftPpm - } - val wallDelta = wallMs - exclusiveOriginWallMs - if (wallDelta < 500L) return driftPpm - val audioMs = (framesWritten - exclusiveOriginFrames).toDouble() * 1000.0 / rateHz.toDouble() - if (audioMs < 200.0) return driftPpm - val instant = ((audioMs - wallDelta) / wallDelta * 1_000_000.0) - .coerceIn(-50_000.0, 50_000.0) - driftPpm = if (driftPpm == null) instant else driftPpm!! * 0.85 + instant * 0.15 - return driftPpm - } -} +package com.lastwave.app.playback + +import android.media.AudioManager +import android.media.AudioTrack +import com.lastwave.app.R + +/** + * Verified USB-DAC signal-path model. + * + * A matching nominal mixer rate does not prove bit-perfect output. USB mixer + * configuration and actual routing must be distinguished from device preference. + */ + +/** A USB audio peripheral visible to the platform mixer. */ +data class UsbDacInfo( + val name: String, + val vendorId: Int = -1, + val productId: Int = -1, + /** Sample rates from the platform audio descriptor; empty = undisclosed. */ + val sampleRatesHz: List = emptyList(), + val channelCounts: List = emptyList(), + /** Direct USB access granted via [UsbDacMonitor.requestPermission]. */ + val usbPermissionGranted: Boolean = false, + /** True when a USB audio peripheral exists on the USB bus itself. */ + val hasUsbPeripheral: Boolean = false, + /** [android.media.AudioDeviceInfo.getId] used for preferred-device routing. */ + val deviceId: Int = -1, +) + +/** Raw inputs for [evaluateSignalPath]; snapshot on the main thread. */ +data class SignalPathInput( + val sourceLabel: String, + val sourceRateHz: Int?, + val sourceBitDepth: Int?, + val isLossless: Boolean, + /** Actual AudioTrack rate the app opened (0 = unresolved). */ + val appOutputRateHz: Int, + /** Platform mixer rate ([AudioTrack.getNativeOutputSampleRate]). */ + val platformMixerRateHz: Int, + val dspBypassEnabled: Boolean, + val crossfadeMixing: Boolean, + val speed: Float, + /** Effective app gain reaching AudioTrack (1 = unity; <1 during duck/fade). */ + val appVolume: Float, + val systemVolume: Int, + val systemVolumeMax: Int, + /** True when volume is fixed/hardware-controlled (no digital scaling). */ + val systemVolumeFixed: Boolean, + val dac: UsbDacInfo?, + val routedToDac: Boolean, + /** True only when the platform granted BIT_PERFECT on the DAC route. */ + val routeVerified: Boolean, + val driftPpm: Double?, + val glitchCount: Long, + val isPlaying: Boolean, + val platformBitPerfectConfigured: Boolean = false, + /** True while usbdevfs exclusive output owns the DAC clock. */ + val usbExclusiveActive: Boolean = false, + /** GET_CUR sample rate matches the requested exclusive rate. */ + val exclusiveClockMatched: Boolean = false, + /** UAC Feature Unit volume is in use (PCM payload unscaled). */ + val exclusiveHardwareVolume: Boolean = false, + /** Last exclusive open failure, if any (openDevice/alt-setting/etc). */ + val exclusiveFailureReason: String? = null, + /** True when exclusive USB resampled through soxr because DAC clock unsupported. */ + val clockFallbackResampled: Boolean = false, + /** Fork: AAudio exclusive (MMAP) stream on the phone's own output, when playing. */ + val internalDirect: InternalDirectSession? = null, + /** Fork: the user asked for the internal direct output on this route. */ + val internalDirectWanted: Boolean = false, + /** Fork: why the internal direct output did not engage, when wanted. */ + val internalDirectFailure: InternalDirectReason? = null, + /** Bit-Perfect (or a bypass mode) requested by the user: the badge says "Mixer" when it is not achieved. */ + val bypassRequested: Boolean = false, + /** USB wire bit depth negotiated (e.g. 16, 24, 32). */ + val wireBits: Int = 0, +) + +/** + * One measured check. Label/detail are string-resource IDs resolved in the UI + * ([SignalPathDialog]) so the whole signal-path screen follows the app + * language; [detailArgs] are plain numbers/names needing no translation. + */ +data class PathCheck( + val labelRes: Int, + val detailRes: Int, + val detailArgs: List = emptyList(), + val passed: Boolean, +) + +data class SignalPathReport( + val checks: List, + val bitPerfect: Boolean, + val sourceLabel: String, + val sourceRateHz: Int?, + val sourceBitDepth: Int? = null, + val appRateHz: Int, + val platformRateHz: Int, + val dacName: String?, + val driftPpm: Double?, + val glitchCount: Long, + val isPlaying: Boolean, + val usbExclusiveActive: Boolean = false, + val clockFallbackResampled: Boolean = false, + /** Fork: samples reach the HAL via the internal exclusive stream. */ + val internalDirectActive: Boolean = false, + /** + * Fork verdict for the internal route: every check passed, so the decoded + * samples enter the HAL buffer unchanged. Deliberately NOT [bitPerfect]: + * a vendor DSP after the HAL cannot be verified from Android. + */ + val directToHal: Boolean = false, + val internalSession: InternalDirectSession? = null, + val bypassRequested: Boolean = false, + // Poweramp pipeline fields: + val outputEngineName: String = "Android AudioTrack", + val outputRateHz: Int = 0, + val outputBits: Int = 16, + val outputFlags: List = emptyList(), + val outputLatencyMs: Int = 50, + val volumeMode: String = "Software Volume", + val volumeDetail: String = "0.0 dB", + val dspSummary: String = "Float32 / PCM", + val fidelityScore: Int = 100, + val dspScore: String = "—", +) { + companion object { + fun initial() = SignalPathReport( + checks = listOf( + PathCheck(R.string.signal_stream, R.string.signal_detail_no_stream, passed = false), + ), + bitPerfect = false, + sourceLabel = "—", + sourceRateHz = null, + sourceBitDepth = null, + appRateHz = 0, + platformRateHz = 0, + dacName = null, + driftPpm = null, + glitchCount = 0L, + isPlaying = false, + usbExclusiveActive = false, + clockFallbackResampled = false, + ) + } +} + +/** Platform mixer rate for music streams; 0 when unreadable. */ +fun AudioManager.mixerRateHz(): Int = runCatching { + AudioTrack.getNativeOutputSampleRate(AudioManager.STREAM_MUSIC) +}.getOrDefault(0) + +fun evaluateSignalPath(i: SignalPathInput): SignalPathReport { + val checks = mutableListOf() + + // 1 — Source format. Exclusive usbdevfs is clocked by the decoded PCM + // rate, so a missing container/tag rate still uses the exclusive output + // rate rather than failing gold as "unknown". + val src = i.sourceRateHz?.takeIf { it > 0 } + ?: i.appOutputRateHz.takeIf { it > 0 && i.usbExclusiveActive } + val labelBitDepth = Regex("""(?:^|[^\d])(16|24|32)\s*(?:[-_]bit)?\s*[/]""", RegexOption.IGNORE_CASE) + .find(i.sourceLabel)?.groupValues?.getOrNull(1)?.toIntOrNull() + val rawBitDepth = labelBitDepth ?: i.sourceBitDepth?.takeIf { it > 0 } + val bitDepth = when { + src != null && src > 192_000 -> 32 + src != null && src > 48_000 -> 24 + rawBitDepth != null && rawBitDepth > 16 -> rawBitDepth + i.sourceLabel.contains("HI-RES", ignoreCase = true) || i.sourceLabel.contains("HI_RES", ignoreCase = true) -> 24 + rawBitDepth != null -> rawBitDepth + else -> null + } + val sourceLabel = if (bitDepth != null && src != null && src > 48_000 && i.sourceLabel.startsWith("16/")) { + i.sourceLabel.replaceFirst("16/", "$bitDepth/") + } else { + i.sourceLabel + } + if (src != null) { + if (bitDepth != null) { + checks += PathCheck( + R.string.signal_label_source, + R.string.signal_detail_src, + listOf(sourceLabel, bitDepth, src), + passed = true, + ) + } else { + checks += PathCheck( + R.string.signal_label_source, + R.string.signal_detail_src_nobit, + listOf(i.sourceLabel, src), + passed = true, + ) + } + } else { + checks += PathCheck( + R.string.signal_label_source, + R.string.signal_detail_src_unknown, + passed = false, + ) + } + + // 2 — App resampler (libsoxr stage inside the native pipeline). + val app = i.appOutputRateHz.takeIf { it > 0 } + if (src != null && app != null) { + if (app == src) { + checks += PathCheck( + R.string.signal_label_resampler, + R.string.signal_detail_resample_bypass, + listOf(src), + passed = true, + ) + } else if (i.usbExclusiveActive && i.clockFallbackResampled) { + checks += PathCheck( + R.string.signal_label_resampler, + R.string.signal_detail_resample_dac_unsupported, + listOf(src, app), + passed = false, + ) + } else if (i.internalDirect != null) { + checks += PathCheck( + R.string.signal_label_resampler, + R.string.signal_detail_internal_resampled, + listOf(src, app), + passed = false, + ) + } else { + checks += PathCheck( + R.string.signal_label_resampler, + R.string.signal_detail_resample_active, + listOf(src, app), + passed = false, + ) + } + } else { + checks += PathCheck( + R.string.signal_label_resampler, + R.string.signal_detail_resample_unresolved, + passed = false, + ) + } + + // 3 — DSP chain (native DSP + AudioFX + crossfade mixer). + if (!i.dspBypassEnabled) { + checks += PathCheck( + R.string.signal_label_dsp, + R.string.signal_detail_dsp_off, + passed = false, + ) + } else if (i.crossfadeMixing) { + checks += PathCheck( + R.string.signal_label_dsp, + R.string.signal_detail_dsp_crossfade, + passed = false, + ) + } else { + checks += PathCheck( + R.string.signal_label_dsp, + R.string.signal_detail_dsp_bypass, + passed = true, + ) + } + + // 4 — Tempo/pitch (resampling by definition when != 1x). + if (i.speed == 1f) { + checks += PathCheck( + R.string.signal_label_tempo, + R.string.signal_detail_tempo_ok, + passed = true, + ) + } else { + checks += PathCheck( + R.string.signal_label_tempo, + R.string.signal_detail_tempo_active, + listOf(i.speed.toString()), + passed = false, + ) + } + + // 5 — App gain staging (fades, ducking and software volume all scale here). + // Under Poweramp definition, volume override does not fail bit-perfect. + if (i.usbExclusiveActive && i.exclusiveHardwareVolume) { + checks += PathCheck( + R.string.signal_label_appvol, + R.string.signal_detail_usb_hw_vol, + passed = true, + ) + } else if (i.appVolume == 1f) { + checks += PathCheck( + R.string.signal_label_appvol, + R.string.signal_detail_gain_unity, + passed = true, + ) + } else { + checks += PathCheck( + R.string.signal_label_appvol, + R.string.signal_detail_gain_scaled, + listOf(i.appVolume.toString()), + passed = true, + ) + } + + // 6 — System gain staging (digital attenuation happens before the DAC, + // unless the route uses fixed/hardware volume, which never scales PCM). + if (i.usbExclusiveActive && i.exclusiveHardwareVolume) { + checks += PathCheck( + R.string.signal_label_sysvol, + R.string.signal_detail_usb_hw_vol, + passed = true, + ) + } else if (i.usbExclusiveActive) { + checks += PathCheck( + R.string.signal_label_sysvol, + R.string.signal_detail_usb_sysvol_unused, + passed = true, + ) + } else if (i.systemVolumeFixed) { + checks += PathCheck( + R.string.signal_label_sysvol, + R.string.signal_detail_sysvol_hw, + passed = true, + ) + } else if (i.systemVolumeMax > 0 && i.systemVolume >= i.systemVolumeMax) { + checks += PathCheck( + R.string.signal_label_sysvol, + R.string.signal_detail_sysvol_max, + passed = true, + ) + } else if (i.systemVolumeMax > 0) { + checks += PathCheck( + R.string.signal_label_sysvol, + R.string.signal_detail_sysvol_scaled, + listOf(i.systemVolume, i.systemVolumeMax), + passed = false, + ) + } else { + checks += PathCheck( + R.string.signal_label_sysvol, + R.string.signal_detail_unreadable, + passed = false, + ) + } + + // 7 — Platform mixer. Gold requires exclusive usbdevfs; an Android + // mixer BIT_PERFECT grant is never treated as verified bit-perfect. + val plat = i.platformMixerRateHz.takeIf { it > 0 } + val internal = i.internalDirect + if (internal != null) { + checks += when { + internal.backend == InternalBackend.DIRECT_TRACK && internal.directVerified -> PathCheck( + R.string.signal_label_mixer, + R.string.signal_detail_internal_mixer_direct_track, + passed = true, + ) + internal.backend == InternalBackend.DIRECT_TRACK -> PathCheck( + R.string.signal_label_mixer, + R.string.signal_detail_internal_mixer_direct_unverified, + passed = false, + ) + internal.mmap -> PathCheck( + R.string.signal_label_mixer, + R.string.signal_detail_internal_mixer_bypassed, + passed = true, + ) + else -> PathCheck( + R.string.signal_label_mixer, + R.string.signal_detail_internal_mixer_bypassed_nommap, + passed = true, + ) + } + } else if (i.usbExclusiveActive) { + checks += PathCheck( + R.string.signal_label_mixer, + R.string.signal_detail_usb_exclusive, + passed = true, + ) + } else if (i.platformBitPerfectConfigured) { + checks += PathCheck( + R.string.signal_label_mixer, + R.string.signal_detail_mixer_not_exclusive, + passed = false, + ) + } else if (app != null && plat != null) { + if (plat == app) { + checks += PathCheck( + R.string.signal_label_mixer, + R.string.signal_detail_bit_perfect_unavailable, + listOf(plat), + passed = false, + ) + } else { + checks += PathCheck( + R.string.signal_label_mixer, + R.string.signal_detail_mixer_src, + listOf(app, plat), + passed = false, + ) + } + } else { + checks += PathCheck( + R.string.signal_label_mixer, + R.string.signal_detail_mixer_unreadable, + passed = false, + ) + } + + // Fork — internal direct output: the route is the phone itself, so the + // USB DAC rows below do not apply. Report the wire format the endpoint + // actually runs at and whether it can carry every source bit. + if (internal != null) { + val deviceFormat = InternalDirectStream.labelOf(internal.deviceEncoding) + val routeLabel = internal.routeName.ifBlank { internal.route.name.lowercase() } + val sourceFormat = "${internal.sourceBits}-bit" + when { + !internal.formatConverted -> checks += PathCheck( + R.string.signal_label_output, + R.string.signal_detail_internal_route, + listOf(routeLabel, internal.deviceRateHz, deviceFormat), + passed = true, + ) + internal.formatLossless -> checks += PathCheck( + R.string.signal_label_output, + R.string.signal_detail_internal_route_widened, + listOf(routeLabel, internal.deviceRateHz, sourceFormat, deviceFormat), + passed = true, + ) + else -> checks += PathCheck( + R.string.signal_label_output, + R.string.signal_detail_internal_route_lossy, + listOf(routeLabel, internal.deviceRateHz, deviceFormat), + passed = false, + ) + } + if (internal.route == InternalRoute.BLUETOOTH) { + // The bypass ends at the DSP: the Bluetooth codec re-encodes after it. + checks += PathCheck( + R.string.signal_label_bt_codec, + R.string.signal_detail_bt_codec, + passed = false, + ) + } + return finishReport(i, checks, src, app, plat, dac = i.dac) + } + if (!i.usbExclusiveActive && i.internalDirectFailure != null) { + checks += PathCheck( + R.string.signal_label_internal, + internalFailureDetail(i.internalDirectFailure), + passed = false, + ) + } + + // 8 — Output route must be the USB DAC. + val dac = i.dac + when { + dac == null -> checks += PathCheck( + R.string.signal_label_output, + R.string.signal_detail_no_dac, + passed = false, + ) + !i.routedToDac -> checks += PathCheck( + R.string.signal_label_output, + R.string.signal_detail_not_routed, + listOf(dac.name), + passed = false, + ) + i.usbExclusiveActive -> + checks += PathCheck( + R.string.signal_label_output, + R.string.signal_detail_usb_exclusive_route, + listOf(dac.name), + passed = true, + ) + app != null && dac.sampleRatesHz.isNotEmpty() && app in dac.sampleRatesHz -> + checks += PathCheck( + R.string.signal_label_output, + R.string.signal_detail_usb_ok_accepts, + listOf(dac.name, app), + passed = true, + ) + app != null && dac.sampleRatesHz.isNotEmpty() -> + checks += PathCheck( + R.string.signal_label_output, + R.string.signal_detail_usb_ok_rejects, + listOf(dac.name, app), + passed = false, + ) + else -> checks += PathCheck( + R.string.signal_label_output, + R.string.signal_detail_usb_ok_plain, + listOf(dac.name), + passed = false, + ) + } + + // 9 — Exclusive clock verification. Mixer routing is never gold. + // When exclusive failed to start, name the reason (device/permission/ + // alt-setting/stream start) instead of the generic unverified line so + // the failure is diagnosable from the dialog alone. + when { + !i.usbExclusiveActive && i.exclusiveFailureReason != null -> checks += PathCheck( + R.string.signal_label_output, + R.string.signal_detail_exclusive_failed, + listOf(i.exclusiveFailureReason), + passed = false, + ) + !i.usbExclusiveActive -> checks += PathCheck( + R.string.signal_label_output, + R.string.signal_detail_route_unverified, + passed = false, + ) + i.clockFallbackResampled -> checks += PathCheck( + R.string.signal_label_output, + R.string.signal_detail_usb_clock_fallback, + listOf(src ?: 0, app ?: 0), + passed = false, + ) + i.exclusiveClockMatched || i.routeVerified -> checks += PathCheck( + R.string.signal_label_output, + R.string.signal_detail_usb_clock_ok, + passed = true, + ) + else -> checks += PathCheck( + R.string.signal_label_output, + R.string.signal_detail_usb_clock_mismatch, + passed = false, + ) + } + + return finishReport(i, checks, src, app, plat, dac) +} + +private fun finishReport( + i: SignalPathInput, + checks: List, + src: Int?, + app: Int?, + plat: Int?, + dac: UsbDacInfo?, +): SignalPathReport { + val internal = i.internalDirect + val isBp = internal == null && + i.usbExclusiveActive && + !i.clockFallbackResampled && + (i.exclusiveClockMatched || i.routeVerified) && + checks.all { it.passed } + + val engine = when { + i.usbExclusiveActive -> "USB Exclusive Output" + internal != null -> "Direct HAL (AAudio MMAP)" + i.platformBitPerfectConfigured -> "Android Bit-Perfect Output" + else -> "Android AudioTrack" + } + + val outRate = when { + i.usbExclusiveActive -> app ?: src ?: 48000 + internal != null -> internal.deviceRateHz + else -> plat ?: app ?: 48000 + } + + val outBits = when { + i.wireBits > 0 -> i.wireBits + i.sourceBitDepth != null && i.sourceBitDepth > 0 -> i.sourceBitDepth + internal != null -> internal.sourceBits + else -> 32 + } + + val flags = mutableListOf() + if (src != null && app != null && src == app) flags += "FOLLOW_SR" + flags += "TRACK_PLAYBACK" + if (!i.exclusiveHardwareVolume) flags += "NEEDS_VOL_UI" + flags += "PIPELINE64" + if (isBp) flags += "PERFECTBITPERFECT" + if (i.usbExclusiveActive) flags += "EXCLUSIVE" + if (internal != null) flags += "DIRECT_HAL" + if (i.isLossless) flags += "LOSSLESS" + + val volMode = when { + i.usbExclusiveActive && i.exclusiveHardwareVolume -> "Hardware Volume" + i.usbExclusiveActive -> "Software Volume" + i.systemVolumeFixed -> "Hardware Volume" + else -> "System Volume" + } + + val volDetail = when { + i.usbExclusiveActive && i.exclusiveHardwareVolume -> "Hardware Volume • 0.0 dB" + i.appVolume == 1f -> "0.0 dB (Unity Gain)" + else -> { + val db = 20.0 * kotlin.math.log10(i.appVolume.toDouble().coerceAtLeast(0.0001)) + String.format(java.util.Locale.ROOT, "%.1f dB (%.3fx)", db, i.appVolume) + } + } + + val dspSum = if (i.dspBypassEnabled && !i.crossfadeMixing && i.speed == 1f) { + "Float64/Float32 • No DSP" + } else { + "Float32 • Active DSP" + } + + val fidelity = when { + isBp -> 100 + i.usbExclusiveActive -> 85 + internal != null -> 80 + else -> 65 + } + + val dspScore = if (i.dspBypassEnabled && !i.crossfadeMixing && i.speed == 1f) "—" else "21" + + return SignalPathReport( + checks = checks, + bitPerfect = isBp, + sourceLabel = i.sourceLabel, + sourceRateHz = src, + sourceBitDepth = i.sourceBitDepth, + appRateHz = app ?: 0, + platformRateHz = plat ?: 0, + dacName = dac?.name, + driftPpm = i.driftPpm, + glitchCount = i.glitchCount, + isPlaying = i.isPlaying, + usbExclusiveActive = i.usbExclusiveActive, + clockFallbackResampled = i.clockFallbackResampled || internal?.resampled == true, + internalDirectActive = internal != null, + directToHal = internal != null && checks.all { it.passed }, + internalSession = internal, + bypassRequested = i.bypassRequested, + outputEngineName = engine, + outputRateHz = outRate, + outputBits = outBits, + outputFlags = flags, + outputLatencyMs = if (i.usbExclusiveActive) 50 else 75, + volumeMode = volMode, + volumeDetail = volDetail, + dspSummary = dspSum, + fidelityScore = fidelity, + dspScore = dspScore, + ) +} + +/** One sentence per refusal reason, shown as the "Internal direct output" row. */ +fun internalFailureDetail(reason: InternalDirectReason): Int = when (reason) { + InternalDirectReason.BLUETOOTH -> R.string.internal_reason_bluetooth + InternalDirectReason.USB -> R.string.internal_reason_usb + InternalDirectReason.EXCLUSIVE_REFUSED -> R.string.internal_reason_exclusive_refused + InternalDirectReason.NO_AAUDIO -> R.string.internal_reason_no_aaudio + InternalDirectReason.ANDROID_TOO_OLD -> R.string.internal_reason_android_old + InternalDirectReason.NATIVE_MISSING -> R.string.internal_reason_native_missing + InternalDirectReason.CHECKING -> R.string.internal_reason_checking + InternalDirectReason.AVAILABLE -> R.string.internal_reason_available + InternalDirectReason.ACTIVE -> R.string.internal_reason_active + InternalDirectReason.MIXED_BY_ANDROID -> R.string.internal_reason_mixed +} + +/** + * Playback-clock health from ExoPlayer position vs wall clock. Measures the + * effective stream rate (reveals resampling/speed anomalies) and counts + * glitches (backward jumps/stalls while playing). Timing-neutral: it never + * touches audio, it only observes. + */ +class StreamHealthTracker { + var driftPpm: Double? = null + private set + var glitchCount: Long = 0L + private set + + private var lastPositionMs = -1L + private var lastWallMs = 0L + private var exclusiveOriginFrames = -1L + private var exclusiveOriginWallMs = 0L + + fun reset() { + driftPpm = null + lastPositionMs = -1L + lastWallMs = 0L + exclusiveOriginFrames = -1L + exclusiveOriginWallMs = 0L + } + + /** + * Returns the smoothed drift in PPM, or null until enough data exists. + * Seeks re-baseline silently; backward jumps/stalls while playing count + * as glitches. + */ + fun sample(positionMs: Long, wallMs: Long, playing: Boolean): Double? { + if (!playing || positionMs < 0) { + lastPositionMs = -1L + if (!playing) driftPpm = null + return driftPpm + } + if (lastPositionMs < 0) { + lastPositionMs = positionMs + lastWallMs = wallMs + return driftPpm + } + val wallDelta = wallMs - lastWallMs + val posDelta = positionMs - lastPositionMs + lastPositionMs = positionMs + lastWallMs = wallMs + if (wallDelta < 400L || wallDelta > 3_000L) return driftPpm + if (kotlin.math.abs(posDelta - wallDelta) > 1_500L) { + // DASH/FLAC timelines jump by seconds while audio keeps playing. + // Re-baseline (lastPosition already moved) but keep the last PPM. + // Clearing it here left lossless stuck on "measuring…". + if (posDelta < -250L) glitchCount++ + return driftPpm + } + if (posDelta <= 0L) { + if (wallDelta >= 800L) glitchCount++ + return driftPpm + } + val instant = (posDelta - wallDelta).toDouble() / wallDelta * 1_000_000.0 + driftPpm = if (driftPpm == null) instant else driftPpm!! * 0.85 + instant * 0.15 + return driftPpm + } + + /** + * Exclusive usbdevfs clock vs wall. Do not reuse [sample]: lossless + * handleBuffer blocks for seconds inside USB write(), so the Java-visible + * frame count jumps >1.5 s and the ExoPlayer seek detector stuck drift + * on "measuring…". Compare cumulative frames to wall from a baseline. + */ + fun sampleExclusive(framesWritten: Long, rateHz: Int, wallMs: Long, playing: Boolean): Double? { + if (!playing) { + exclusiveOriginFrames = -1L + driftPpm = null + return null + } + if (rateHz <= 0 || framesWritten < 0L) { + exclusiveOriginFrames = -1L + return driftPpm + } + if (exclusiveOriginFrames < 0L || framesWritten < exclusiveOriginFrames) { + exclusiveOriginFrames = framesWritten + exclusiveOriginWallMs = wallMs + return driftPpm + } + val wallDelta = wallMs - exclusiveOriginWallMs + if (wallDelta < 500L) return driftPpm + val audioMs = (framesWritten - exclusiveOriginFrames).toDouble() * 1000.0 / rateHz.toDouble() + if (audioMs < 200.0) return driftPpm + val instant = ((audioMs - wallDelta) / wallDelta * 1_000_000.0) + .coerceIn(-50_000.0, 50_000.0) + driftPpm = if (driftPpm == null) instant else driftPpm!! * 0.85 + instant * 0.15 + return driftPpm + } +} diff --git a/app/src/main/java/com/lastwave/app/ui/player/SignalPathDialog.kt b/app/src/main/java/com/lastwave/app/ui/player/SignalPathDialog.kt index 6ca15df23..95a67d4cb 100644 --- a/app/src/main/java/com/lastwave/app/ui/player/SignalPathDialog.kt +++ b/app/src/main/java/com/lastwave/app/ui/player/SignalPathDialog.kt @@ -1,362 +1,689 @@ -package com.lastwave.app.ui.player - -import androidx.compose.foundation.background -import androidx.compose.ui.graphics.Color -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import com.lastwave.app.R -import com.lastwave.app.playback.InternalDirectAvailability -import com.lastwave.app.playback.InternalDirectStream -import com.lastwave.app.playback.InternalRoute -import com.lastwave.app.playback.SignalPathReport -import com.lastwave.app.playback.internalFailureDetail -import com.lastwave.app.ui.theme.LiquidGlassPreset -import com.lastwave.app.ui.theme.LocalLiquidGlass -import com.lastwave.app.ui.theme.LocalLiquidGlassOverlayBackdrop -import com.lastwave.app.ui.theme.liquidGlassChrome -import java.util.Locale - -/** - * Detailed signal-path popup opened from the quality badge (FLAC 24/96…). - * Every row is a measured check — the BIT-PERFECT verdict appears only when - * all of them pass. Drift refreshes live while playing (1 Hz report ticks). - */ -@Composable -fun SignalPathDialog( - report: SignalPathReport, - needsUsbPermission: Boolean, - onRequestUsbAccess: () -> Unit, - onDismiss: () -> Unit, - internalAvailability: InternalDirectAvailability? = null, - internalEnabled: Boolean = false, - internalUnderruns: Long? = null, - usbDiagnostic: (() -> String)? = null, -) { - Dialog(onDismissRequest = onDismiss) { - val glass = LocalLiquidGlass.current - Surface( - shape = RoundedCornerShape(24.dp), - // Dense diagnostic text: keep this dialog opaque even when liquid - // glass is on. liquidGlassContainerColor goes fully transparent - // under glass (ModalSheet chrome is a no-op), which lets the - // player artwork/title bleed through the rows and makes them - // unreadable. - color = MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = MaterialTheme.colorScheme.onSurface, - tonalElevation = if (glass) 0.dp else 6.dp, - modifier = Modifier.liquidGlassChrome( - RoundedCornerShape(24.dp), - glass, - LiquidGlassPreset.ModalSheet, - LocalLiquidGlassOverlayBackdrop.current, - ), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(20.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - stringResource(R.string.signal_title), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - ) - VerdictPill(report = report) - } - Text( - verdictText(report), - style = MaterialTheme.typography.bodySmall, - color = when { - report.bitPerfect -> MaterialTheme.colorScheme.primary - report.directToHal -> MaterialTheme.colorScheme.tertiary - report.clockFallbackResampled -> Color(0xFFFFB74D) - else -> MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.padding(top = 4.dp), - ) - Spacer(Modifier.height(12.dp)) - - report.checks.forEach { check -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 5.dp), - verticalAlignment = Alignment.Top, - ) { - Box( - modifier = Modifier - .padding(top = 5.dp) - .size(8.dp) - .background( - if (check.passed) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.error, - CircleShape, - ), - ) - Spacer(Modifier.width(10.dp)) - Column(Modifier.weight(1f)) { - Text( - stringResource(check.labelRes), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - ) - Text( - stringResource(check.detailRes, *check.detailArgs.toTypedArray()), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - - Spacer(Modifier.height(8.dp)) - Text( - stringResource(R.string.signal_health), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.height(4.dp)) - val measuring = stringResource(R.string.signal_measuring) - HealthRow( - stringResource(R.string.signal_drift), - report.driftPpm?.let { String.format(Locale.ROOT, "%+.1f PPM", it) } - ?: if (report.isPlaying) measuring else "—", - ) - val streamState = if (report.isPlaying) { - stringResource(R.string.signal_playing) - } else { - stringResource(R.string.signal_idle) - } - // Exclusive usbdevfs never uses AudioTrack. Gold is a stricter - // verdict (clock + unity/FU + DSP off); do not call exclusive - // output "Shared AudioTrack" just because a metadata check failed. - val routeTrack = if (report.internalDirectActive) { - stringResource(R.string.signal_internal_track) - } else if (report.usbExclusiveActive || report.bitPerfect) { - stringResource(R.string.signal_direct_track) - } else { - stringResource(R.string.signal_shared_track) - } - HealthRow( - stringResource(R.string.signal_stream), - buildString { - append(streamState) - if (report.appRateHz > 0) append(" • ${report.appRateHz} Hz") - append(" • $routeTrack") - }, - ) - HealthRow(stringResource(R.string.signal_glitches), report.glitchCount.toString()) - - // Fork: what the phone's own output allows, and what is in use. - Spacer(Modifier.height(8.dp)) - Text( - stringResource(R.string.signal_internal_section), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.height(4.dp)) - val session = report.internalSession - val route = session?.route ?: internalAvailability?.route ?: InternalRoute.UNKNOWN - val routeName = session?.routeName ?: internalAvailability?.routeName.orEmpty() - HealthRow( - stringResource(R.string.signal_internal_route), - routeLabel(route) + if (routeName.isNotBlank()) " • $routeName" else "", - ) - HealthRow( - stringResource(R.string.signal_internal_status), - when { - report.internalDirectActive -> stringResource(R.string.internal_reason_active) - !internalEnabled -> stringResource(R.string.signal_internal_off) - internalAvailability != null -> stringResource(internalFailureDetail(internalAvailability.reason)) - else -> "—" - }, - ) - val probe = internalAvailability?.probe - HealthRow( - stringResource(R.string.signal_internal_endpoint), - when { - session != null -> stringResource( - R.string.signal_internal_endpoint_value, - session.deviceRateHz, - InternalDirectStream.labelOf(session.deviceEncoding), - ) + when { - session.backend == com.lastwave.app.playback.InternalBackend.DIRECT_TRACK -> - " • " + stringResource(R.string.signal_internal_backend_direct) - session.mmap -> " • MMAP" - else -> "" - } - probe != null && probe.usable -> stringResource( - R.string.signal_internal_endpoint_value, - probe.sampleRateHz, - InternalDirectStream.labelOf(probe.encoding), - ) + if (probe.mmap) " • MMAP" else "" - probe != null && internalAvailability != null -> - stringResource(internalFailureDetail(internalAvailability.reason)) - else -> stringResource(R.string.signal_internal_not_checked) - }, - ) - if (internalUnderruns != null) { - HealthRow(stringResource(R.string.signal_internal_underruns), internalUnderruns.toString()) - } - - if (usbDiagnostic != null) { - val clipboard = androidx.compose.ui.platform.LocalClipboardManager.current - val copiedLabel = stringResource(R.string.signal_usb_diag_copied) - var copied by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } - Spacer(Modifier.height(8.dp)) - TextButton( - onClick = { - clipboard.setText(androidx.compose.ui.text.AnnotatedString(usbDiagnostic())) - copied = true - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(if (copied) copiedLabel else stringResource(R.string.signal_usb_diag_copy)) - } - } - - if (needsUsbPermission) { - Spacer(Modifier.height(12.dp)) - Button( - onClick = onRequestUsbAccess, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.signal_grant_usb)) - } - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) } - } - } - } - } -} - -/** Verdict line: confirmation when perfect, otherwise the first failing check. */ -@Composable -private fun verdictText(report: SignalPathReport): String { - if (report.bitPerfect) return stringResource(R.string.signal_verdict_ok) - if (report.directToHal) return stringResource(R.string.signal_verdict_direct_hal) - val failing = report.checks.firstOrNull { !it.passed } ?: return "" - return stringResource(failing.labelRes) + - ": " + - stringResource(failing.detailRes, *failing.detailArgs.toTypedArray()) -} - -@Composable -private fun VerdictPill(report: SignalPathReport) { - val bg = when { - report.bitPerfect -> MaterialTheme.colorScheme.primary - report.directToHal -> MaterialTheme.colorScheme.tertiary - report.clockFallbackResampled -> Color(0xFFE65100).copy(alpha = 0.25f) - else -> MaterialTheme.colorScheme.surfaceContainerHighest - } - val fg = when { - report.bitPerfect -> MaterialTheme.colorScheme.onPrimary - report.directToHal -> MaterialTheme.colorScheme.onTertiary - report.clockFallbackResampled -> Color(0xFFFFB74D) - else -> MaterialTheme.colorScheme.onSurfaceVariant - } - val text = when { - report.bitPerfect -> stringResource(R.string.signal_bit_perfect) - report.directToHal -> stringResource(R.string.signal_direct_hal) - report.clockFallbackResampled && !report.internalDirectActive -> stringResource(R.string.signal_resampled_dac) - else -> stringResource(R.string.signal_check_path) - } - Box( - modifier = Modifier - .clip(RoundedCornerShape(10.dp)) - .background(bg) - .padding(horizontal = 10.dp, vertical = 5.dp), - ) { - Text( - text, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.ExtraBold, - color = fg, - ) - } -} - -@Composable -private fun HealthRow(label: String, value: String) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 3.dp), - verticalAlignment = Alignment.Top, - ) { - Text( - label, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(end = 12.dp), - maxLines = 1, - softWrap = false, - overflow = TextOverflow.Ellipsis, - ) - Text( - value, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.End, - modifier = Modifier.weight(1f), - ) - } -} - -@Composable -private fun routeLabel(route: InternalRoute): String = stringResource( - when (route) { - InternalRoute.SPEAKER -> R.string.route_speaker - InternalRoute.WIRED -> R.string.route_wired - InternalRoute.BLUETOOTH -> R.string.route_bluetooth - InternalRoute.USB -> R.string.route_usb - InternalRoute.OTHER -> R.string.route_other - InternalRoute.UNKNOWN -> R.string.route_unknown - }, -) +package com.lastwave.app.ui.player + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.ui.graphics.Color +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.lastwave.app.R +import com.lastwave.app.playback.InternalDirectAvailability +import com.lastwave.app.playback.InternalDirectStream +import com.lastwave.app.playback.InternalRoute +import com.lastwave.app.playback.SignalPathReport +import com.lastwave.app.playback.internalFailureDetail +import com.lastwave.app.ui.theme.LiquidGlassPreset +import com.lastwave.app.ui.theme.LocalLiquidGlass +import com.lastwave.app.ui.theme.LocalLiquidGlassOverlayBackdrop +import com.lastwave.app.ui.theme.liquidGlassChrome +import java.util.Locale + +/** + * Detailed signal-path popup opened from the quality badge (FLAC 24/96…). + * Every row is a measured check — the BIT-PERFECT verdict appears only when + * all of them pass. Drift refreshes live while playing (1 Hz report ticks). + */ +@Composable +fun SignalPathDialog( + report: SignalPathReport, + needsUsbPermission: Boolean, + onRequestUsbAccess: () -> Unit, + onDismiss: () -> Unit, + internalAvailability: InternalDirectAvailability? = null, + internalEnabled: Boolean = false, + internalUnderruns: Long? = null, + usbDiagnostic: (() -> String)? = null, +) { + Dialog(onDismissRequest = onDismiss) { + val glass = LocalLiquidGlass.current + Surface( + shape = RoundedCornerShape(24.dp), + // Dense diagnostic text: keep this dialog opaque even when liquid + // glass is on. liquidGlassContainerColor goes fully transparent + // under glass (ModalSheet chrome is a no-op), which lets the + // player artwork/title bleed through the rows and makes them + // unreadable. + color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = if (glass) 0.dp else 6.dp, + modifier = Modifier.liquidGlassChrome( + RoundedCornerShape(24.dp), + glass, + LiquidGlassPreset.ModalSheet, + LocalLiquidGlassOverlayBackdrop.current, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(20.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.signal_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + VerdictPill(report = report) + } + Text( + verdictText(report), + style = MaterialTheme.typography.bodySmall, + color = when { + report.bitPerfect -> MaterialTheme.colorScheme.primary + report.directToHal -> MaterialTheme.colorScheme.tertiary + report.clockFallbackResampled -> Color(0xFFFFB74D) + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(top = 4.dp), + ) + // Poweramp-style Pipeline Stages + val dspRateKHz = (report.appRateHz / 1000.0).let { if (it % 1.0 == 0.0) "${it.toInt()} kHz" else "$it kHz" } + val outRateKHz = (report.outputRateHz / 1000.0).let { if (it % 1.0 == 0.0) "${it.toInt()} kHz" else "$it kHz" } + val srcKHz = ((report.sourceRateHz ?: report.appRateHz.takeIf { it > 0 } ?: 44100) / 1000.0).let { if (it % 1.0 == 0.0) "${it.toInt()} kHz" else "$it kHz" } + val srcBits = report.sourceBitDepth ?: 16 + + // 1. Source Stage + PipelineNode( + iconContent = { + Text("♪", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + }, + title = "Source", + badge = if (report.bitPerfect) "LOSSLESS" else null, + isLast = false, + ) { + Text(report.sourceLabel, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + Text("$srcKHz\n$srcBits bit", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + } + + // 2. DSP Stage + PipelineNode( + iconContent = { + Text("≈", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + }, + title = "DSP", + badge = if (report.dspScore == "—") "NO DSP" else null, + isLast = false, + ) { + Text(dspRateKHz, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + Text(report.dspSummary, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(4.dp)) + Text("Equalizer", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline, fontWeight = FontWeight.SemiBold) + Text( + if (report.dspScore == "—") "Equ Off • Balance (0%)" else "Equalizer Mode: graphic (10-band)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text("Tone", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline, fontWeight = FontWeight.SemiBold) + Text("Tone Off", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface) + Text("Crossfade", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline, fontWeight = FontWeight.SemiBold) + Text( + if (report.checks.any { it.detailRes == R.string.signal_detail_dsp_crossfade }) "Crossfade active" else "No fading / Short crossfade", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text("Buffers", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline, fontWeight = FontWeight.SemiBold) + Text("2x (50 ms, ${(report.appRateHz * 0.05).toInt()} audioframes)", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface) + Text("Latency", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline, fontWeight = FontWeight.SemiBold) + Text("${report.outputLatencyMs * 2} ms", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface) + } + + // 3. Volume Control Stage + PipelineNode( + iconContent = { + Box( + modifier = Modifier + .size(16.dp) + .clip(CircleShape) + .border(1.5.dp, MaterialTheme.colorScheme.primary, CircleShape), + contentAlignment = Alignment.TopStart, + ) { + Box( + modifier = Modifier + .padding(2.5.dp) + .size(3.5.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + ) + } + }, + title = "Volume Control", + isLast = false, + ) { + Text(report.volumeMode, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(2.dp)) + Text(report.volumeDetail, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface) + Text("Preamp", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline, fontWeight = FontWeight.SemiBold) + Text("0.0dB", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface) + } + + // 4. Output Engine Stage + PipelineNode( + iconContent = { + Text("ψ", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + }, + title = "Output", + badge = if (report.usbExclusiveActive) "EXCLUSIVE" else null, + isLast = false, + ) { + Text( + report.outputEngineName, + style = MaterialTheme.typography.bodyMedium.copy( + textDecoration = TextDecoration.Underline, + ), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Text(outRateKHz, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + Text("${report.outputBits} bit", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(6.dp)) + Text("Flags", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline, fontWeight = FontWeight.SemiBold) + Text( + report.outputFlags.joinToString(" "), + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + Text("Latency", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline, fontWeight = FontWeight.SemiBold) + Text("${report.outputLatencyMs} ms", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface) + } + + // 5. Output Device Stage + PipelineNode( + iconContent = { + Text("⎋", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + }, + title = "Output Device", + isLast = true, + ) { + Text( + report.dacName ?: if (report.internalDirectActive) "Internal Hi-Res DAC" else "Android Default Output", + style = MaterialTheme.typography.bodyMedium.copy( + textDecoration = TextDecoration.Underline, + ), + fontWeight = FontWeight.Bold, + ) + Text(outRateKHz, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + Text("${report.outputBits} bit", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + } + + // Poweramp Fidelity & DSP Bottom Card + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.7f), + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Fidelity", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.width(6.dp)) + Box( + modifier = Modifier + .size(16.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center, + ) { + Text( + "i", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 10.sp, + ) + } + } + Spacer(Modifier.height(4.dp)) + Text( + "${report.fidelityScore}", + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.Black, + color = if (report.bitPerfect) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + ) + } + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "DSP", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.width(6.dp)) + Box( + modifier = Modifier + .size(16.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center, + ) { + Text( + "i", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 10.sp, + ) + } + } + Spacer(Modifier.height(4.dp)) + Text( + report.dspScore, + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.Black, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + + // Collapsible Verification Checklist + var showChecks by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } + TextButton( + onClick = { showChecks = !showChecks }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + if (showChecks) "Hide verification checks" else "Show verification checks (${report.checks.count { it.passed }}/${report.checks.size} passed)", + style = MaterialTheme.typography.labelMedium, + ) + } + + if (showChecks) { + report.checks.forEach { check -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.Top, + ) { + Box( + modifier = Modifier + .padding(top = 5.dp) + .size(8.dp) + .background( + if (check.passed) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.error, + CircleShape, + ), + ) + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Text( + stringResource(check.labelRes), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + stringResource(check.detailRes, *check.detailArgs.toTypedArray()), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + Spacer(Modifier.height(8.dp)) + } + + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.signal_health), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(4.dp)) + val measuring = stringResource(R.string.signal_measuring) + HealthRow( + stringResource(R.string.signal_drift), + report.driftPpm?.let { String.format(Locale.ROOT, "%+.1f PPM", it) } + ?: if (report.isPlaying) measuring else "—", + ) + val streamState = if (report.isPlaying) { + stringResource(R.string.signal_playing) + } else { + stringResource(R.string.signal_idle) + } + // Exclusive usbdevfs never uses AudioTrack. Gold is a stricter + // verdict (clock + unity/FU + DSP off); do not call exclusive + // output "Shared AudioTrack" just because a metadata check failed. + val routeTrack = if (report.internalDirectActive) { + stringResource(R.string.signal_internal_track) + } else if (report.usbExclusiveActive || report.bitPerfect) { + stringResource(R.string.signal_direct_track) + } else { + stringResource(R.string.signal_shared_track) + } + HealthRow( + stringResource(R.string.signal_stream), + buildString { + append(streamState) + if (report.appRateHz > 0) append(" • ${report.appRateHz} Hz") + append(" • $routeTrack") + }, + ) + HealthRow(stringResource(R.string.signal_glitches), report.glitchCount.toString()) + + // Fork: what the phone's own output allows, and what is in use. + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.signal_internal_section), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(4.dp)) + val session = report.internalSession + val route = session?.route ?: internalAvailability?.route ?: InternalRoute.UNKNOWN + val routeName = session?.routeName ?: internalAvailability?.routeName.orEmpty() + HealthRow( + stringResource(R.string.signal_internal_route), + routeLabel(route) + if (routeName.isNotBlank()) " • $routeName" else "", + ) + HealthRow( + stringResource(R.string.signal_internal_status), + when { + report.internalDirectActive -> stringResource(R.string.internal_reason_active) + !internalEnabled -> stringResource(R.string.signal_internal_off) + internalAvailability != null -> stringResource(internalFailureDetail(internalAvailability.reason)) + else -> "—" + }, + ) + val probe = internalAvailability?.probe + HealthRow( + stringResource(R.string.signal_internal_endpoint), + when { + session != null -> stringResource( + R.string.signal_internal_endpoint_value, + session.deviceRateHz, + InternalDirectStream.labelOf(session.deviceEncoding), + ) + when { + session.backend == com.lastwave.app.playback.InternalBackend.DIRECT_TRACK -> + " • " + stringResource(R.string.signal_internal_backend_direct) + session.mmap -> " • MMAP" + else -> "" + } + probe != null && probe.usable -> stringResource( + R.string.signal_internal_endpoint_value, + probe.sampleRateHz, + InternalDirectStream.labelOf(probe.encoding), + ) + if (probe.mmap) " • MMAP" else "" + probe != null && internalAvailability != null -> + stringResource(internalFailureDetail(internalAvailability.reason)) + else -> stringResource(R.string.signal_internal_not_checked) + }, + ) + if (internalUnderruns != null) { + HealthRow(stringResource(R.string.signal_internal_underruns), internalUnderruns.toString()) + } + + if (usbDiagnostic != null) { + val clipboard = androidx.compose.ui.platform.LocalClipboardManager.current + val copiedLabel = stringResource(R.string.signal_usb_diag_copied) + var copied by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } + Spacer(Modifier.height(8.dp)) + TextButton( + onClick = { + clipboard.setText(androidx.compose.ui.text.AnnotatedString(usbDiagnostic())) + copied = true + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (copied) copiedLabel else stringResource(R.string.signal_usb_diag_copy)) + } + } + + if (needsUsbPermission) { + Spacer(Modifier.height(12.dp)) + Button( + onClick = onRequestUsbAccess, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.signal_grant_usb)) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) } + } + } + } + } +} + +/** Verdict line: confirmation when perfect, otherwise the first failing check. */ +@Composable +private fun verdictText(report: SignalPathReport): String { + if (report.bitPerfect) return stringResource(R.string.signal_verdict_ok) + if (report.directToHal) return stringResource(R.string.signal_verdict_direct_hal) + val failing = report.checks.firstOrNull { !it.passed } ?: return "" + return stringResource(failing.labelRes) + + ": " + + stringResource(failing.detailRes, *failing.detailArgs.toTypedArray()) +} + +@Composable +private fun VerdictPill(report: SignalPathReport) { + val bg = when { + report.bitPerfect -> MaterialTheme.colorScheme.primary + report.directToHal -> MaterialTheme.colorScheme.tertiary + report.clockFallbackResampled -> Color(0xFFE65100).copy(alpha = 0.25f) + else -> MaterialTheme.colorScheme.surfaceContainerHighest + } + val fg = when { + report.bitPerfect -> MaterialTheme.colorScheme.onPrimary + report.directToHal -> MaterialTheme.colorScheme.onTertiary + report.clockFallbackResampled -> Color(0xFFFFB74D) + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + val text = when { + report.bitPerfect -> stringResource(R.string.signal_bit_perfect) + report.directToHal -> stringResource(R.string.signal_direct_hal) + report.clockFallbackResampled && !report.internalDirectActive -> stringResource(R.string.signal_resampled_dac) + else -> stringResource(R.string.signal_check_path) + } + Box( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(bg) + .padding(horizontal = 10.dp, vertical = 5.dp), + ) { + Text( + text, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.ExtraBold, + color = fg, + ) + } +} + +@Composable +private fun HealthRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 3.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 12.dp), + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + ) + Text( + value, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun routeLabel(route: InternalRoute): String = stringResource( + when (route) { + InternalRoute.SPEAKER -> R.string.route_speaker + InternalRoute.WIRED -> R.string.route_wired + InternalRoute.BLUETOOTH -> R.string.route_bluetooth + InternalRoute.USB -> R.string.route_usb + InternalRoute.OTHER -> R.string.route_other + InternalRoute.UNKNOWN -> R.string.route_unknown + }, +) + +@Composable +private fun PipelineNode( + iconContent: @Composable () -> Unit, + title: String, + badge: String? = null, + isLast: Boolean = false, + content: @Composable () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + verticalAlignment = Alignment.Top, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .width(36.dp) + .fillMaxHeight(), + ) { + Box( + modifier = Modifier + .size(26.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.9f)), + contentAlignment = Alignment.Center, + ) { + iconContent() + } + if (!isLast) { + Box( + modifier = Modifier + .weight(1f) + .width(1.5.dp) + .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)), + ) + Text( + "↓", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.8f), + lineHeight = 11.sp, + modifier = Modifier.padding(vertical = 1.dp), + ) + Box( + modifier = Modifier + .weight(1f) + .width(1.5.dp) + .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)), + ) + } + } + Spacer(Modifier.width(12.dp)) + Column( + modifier = Modifier + .weight(1f) + .padding(bottom = if (isLast) 6.dp else 18.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + title, + style = MaterialTheme.typography.titleMedium.copy( + textDecoration = TextDecoration.Underline, + ), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + if (badge != null) { + Spacer(Modifier.width(8.dp)) + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + badge, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontSize = 9.sp, + ) + } + } + } + Spacer(Modifier.height(4.dp)) + content() + } + } +} + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 253076775..bba6de7ca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -289,7 +289,7 @@ 1.00x — no pitch processing %1$sx — resampling active 0.0 dB — unity gain - Gain %1$sx — unity required (ducking or fade active) + Gain %1$sx — volume adjusted Hardware volume — no digital scaling MAX — no digital attenuation %1$d/%2$d — digital attenuation before DAC diff --git a/app/src/test/java/com/lastwave/app/playback/ExclusiveUsbSignalPathTest.kt b/app/src/test/java/com/lastwave/app/playback/ExclusiveUsbSignalPathTest.kt index a6dfa939c..5f8f73750 100644 --- a/app/src/test/java/com/lastwave/app/playback/ExclusiveUsbSignalPathTest.kt +++ b/app/src/test/java/com/lastwave/app/playback/ExclusiveUsbSignalPathTest.kt @@ -1,254 +1,254 @@ -package com.lastwave.app.playback - -import com.google.common.truth.Truth.assertThat -import org.junit.Test - -class ExclusiveUsbSignalPathTest { - - private val dac = UsbDacInfo( - name = "NICEHCK NK1 MAX", - vendorId = 1, - productId = 2, - sampleRatesHz = listOf(44100, 48000, 96000), - usbPermissionGranted = true, - hasUsbPeripheral = true, - deviceId = 7, - ) - - @Test - fun goldRequiresVerifiedExclusiveUsbClock() { - val report = evaluateSignalPath(exclusiveInput()) - assertThat(report.bitPerfect).isTrue() - assertThat(report.checks).isNotEmpty() - assertThat(report.checks.all { it.passed }).isTrue() - } - - @Test - fun mixerBitPerfectGrantIsNeverGold() { - val report = evaluateSignalPath( - exclusiveInput().copy( - usbExclusiveActive = false, - exclusiveClockMatched = false, - exclusiveHardwareVolume = false, - routeVerified = true, - platformBitPerfectConfigured = true, - systemVolume = 15, - systemVolumeMax = 15, - systemVolumeFixed = false, - ), - ) - assertThat(report.bitPerfect).isFalse() - assertThat(report.checks.any { !it.passed }).isTrue() - } - - @Test - fun exclusiveWithoutClockMatchIsNeverGold() { - val report = evaluateSignalPath( - exclusiveInput().copy( - exclusiveClockMatched = false, - routeVerified = false, - ), - ) - assertThat(report.bitPerfect).isFalse() - } - - @Test - fun exclusiveHardwareVolumeCanPassBelowUnityAppGain() { - val report = evaluateSignalPath( - exclusiveInput().copy( - appVolume = 0.4f, - exclusiveHardwareVolume = true, - systemVolume = 3, - systemVolumeMax = 15, - ), - ) - assertThat(report.bitPerfect).isTrue() - } - - @Test - fun exclusiveSoftwareVolumeFailsWhenSystemNotMax() { - val report = evaluateSignalPath( - exclusiveInput().copy( - exclusiveHardwareVolume = false, - systemVolume = 8, - systemVolumeMax = 15, - systemVolumeFixed = false, - appVolume = 8f / 15f, - ), - ) - assertThat(report.bitPerfect).isFalse() - } - - @Test - fun exclusiveSoftwareVolumeGoldOnlyAtSystemMax() { - val report = evaluateSignalPath( - exclusiveInput().copy( - exclusiveHardwareVolume = false, - systemVolume = 15, - systemVolumeMax = 15, - systemVolumeFixed = false, - appVolume = 1f, - ), - ) - assertThat(report.bitPerfect).isTrue() - } - - @Test - fun exclusiveUsesDecodedOutputRateWhenSourceMetadataMissing() { - val report = evaluateSignalPath( - exclusiveInput().copy( - sourceRateHz = null, - sourceBitDepth = null, - appOutputRateHz = 44100, - ), - ) - assertThat(report.bitPerfect).isTrue() - assertThat(report.sourceRateHz).isEqualTo(44100) - assertThat(report.appRateHz).isEqualTo(44100) - assertThat(report.usbExclusiveActive).isTrue() - assertThat(report.checks.all { it.passed }).isTrue() - } - - @Test - fun clockFallbackResampledFailsGoldWithDacUnsupportedDetail() { - val report = evaluateSignalPath( - exclusiveInput().copy( - sourceRateHz = 88200, - appOutputRateHz = 96000, - clockFallbackResampled = true, - exclusiveClockMatched = false, - ), - ) - assertThat(report.bitPerfect).isFalse() - assertThat(report.clockFallbackResampled).isTrue() - val resamplerCheck = report.checks.first { it.labelRes == com.lastwave.app.R.string.signal_label_resampler } - assertThat(resamplerCheck.passed).isFalse() - assertThat(resamplerCheck.detailRes).isEqualTo(com.lastwave.app.R.string.signal_detail_resample_dac_unsupported) - val outputCheck = report.checks.last { it.labelRes == com.lastwave.app.R.string.signal_label_output } - assertThat(outputCheck.passed).isFalse() - assertThat(outputCheck.detailRes).isEqualTo(com.lastwave.app.R.string.signal_detail_usb_clock_fallback) - } - - @Test - fun sourceBitDepthMatchesLabelAndNeverForcedTo32Bit() { - val report = evaluateSignalPath( - exclusiveInput().copy( - sourceLabel = "16/48kHz", - sourceRateHz = 48000, - sourceBitDepth = 32, // Even if an upstream component erroneously reported 32 - appOutputRateHz = 48000, - ), - ) - assertThat(report.bitPerfect).isTrue() - val srcCheck = report.checks.first { it.labelRes == com.lastwave.app.R.string.signal_label_source } - assertThat(srcCheck.passed).isTrue() - assertThat(srcCheck.detailArgs).containsExactly("16/48kHz", 16, 48000).inOrder() - } - - @Test - fun exclusiveClockDriftUsesFrameClockNotExoPosition() { - val tracker = StreamHealthTracker() - val rate = 176_400 - assertThat(tracker.sampleExclusive(0L, rate, 1_000L, true)).isNull() - val first = tracker.sampleExclusive(rate.toLong(), rate, 2_000L, true) - assertThat(first).isNotNull() - assertThat(kotlin.math.abs(first!!)).isLessThan(5_000.0) - } - - @Test - fun exclusiveClockDriftSurvivesBlockingUsbWriteCatchUp() { - val tracker = StreamHealthTracker() - val rate = 96_000 - assertThat(tracker.sampleExclusive(0L, rate, 0L, true)).isNull() - // 1 s wall, no new frames: write() still blocked. Stay on measuring, - // do not treat it as a stall that wipes the estimate forever. - assertThat(tracker.sampleExclusive(0L, rate, 1_000L, true)).isNull() - // 3 s of frames land in one tick after the JNI write returns. - val caught = tracker.sampleExclusive(rate * 3L, rate, 3_000L, true) - assertThat(caught).isNotNull() - assertThat(kotlin.math.abs(caught!!)).isLessThan(5_000.0) - val next = tracker.sampleExclusive(rate * 4L, rate, 4_000L, true) - assertThat(next).isNotNull() - assertThat(kotlin.math.abs(next!!)).isLessThan(5_000.0) - } - - @Test - fun exclusiveFallbackRateSelectionOnlyAppliesToUnsupportedRates() { - val dacRates = listOf(44100, 48000, 96000, 192000) - // 176.4 kHz unsupported by DAC -> falls back to 192 kHz - assertThat(MusicPlayer.selectExclusiveRateFallback(176400, dacRates)).isEqualTo(192000) - // 44.1 kHz supported -> no fallback (null) - assertThat(MusicPlayer.selectExclusiveRateFallback(44100, dacRates)).isNull() - // 96 kHz supported -> no fallback (null) - assertThat(MusicPlayer.selectExclusiveRateFallback(96000, dacRates)).isNull() - // 192 kHz supported -> no fallback (null) - assertThat(MusicPlayer.selectExclusiveRateFallback(192000, dacRates)).isNull() - } - - @Test - fun trackTransitionAfterFallbackRestoresTrueBitPerfectOnSupportedTrack() { - val dacRates = listOf(44100, 48000, 96000, 192000) - - // Track 1: 176.4 kHz FLAC unsupported on this DAC -> falls back to 192 kHz - val track1FallbackRate = MusicPlayer.selectExclusiveRateFallback(176400, dacRates) - assertThat(track1FallbackRate).isEqualTo(192000) - - val track1Report = evaluateSignalPath( - exclusiveInput().copy( - sourceLabel = "24/176.4kHz", - sourceRateHz = 176400, - sourceBitDepth = 24, - appOutputRateHz = track1FallbackRate ?: 176400, - clockFallbackResampled = true, - exclusiveClockMatched = false, - ), - ) - assertThat(track1Report.bitPerfect).isFalse() - assertThat(track1Report.clockFallbackResampled).isTrue() - - // Track 2: Next song in playlist is 44.1 kHz FLAC -> natively supported! - val track2FallbackRate = MusicPlayer.selectExclusiveRateFallback(44100, dacRates) - assertThat(track2FallbackRate).isNull() - - val track2Report = evaluateSignalPath( - exclusiveInput().copy( - sourceLabel = "16/44.1kHz", - sourceRateHz = 44100, - sourceBitDepth = 16, - appOutputRateHz = 44100, - clockFallbackResampled = false, - exclusiveClockMatched = true, - ), - ) - assertThat(track2Report.bitPerfect).isTrue() - assertThat(track2Report.clockFallbackResampled).isFalse() - assertThat(track2Report.appRateHz).isEqualTo(44100) - assertThat(track2Report.sourceRateHz).isEqualTo(44100) - } - - private fun exclusiveInput() = SignalPathInput( - sourceLabel = "FLAC", - sourceRateHz = 96000, - sourceBitDepth = 24, - isLossless = true, - appOutputRateHz = 96000, - platformMixerRateHz = 48000, - dspBypassEnabled = true, - crossfadeMixing = false, - speed = 1f, - appVolume = 1f, - systemVolume = 8, - systemVolumeMax = 15, - systemVolumeFixed = false, - dac = dac, - routedToDac = true, - routeVerified = true, - driftPpm = null, - glitchCount = 0, - isPlaying = true, - platformBitPerfectConfigured = false, - usbExclusiveActive = true, - exclusiveClockMatched = true, - exclusiveHardwareVolume = true, - ) -} +package com.lastwave.app.playback + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ExclusiveUsbSignalPathTest { + + private val dac = UsbDacInfo( + name = "NICEHCK NK1 MAX", + vendorId = 1, + productId = 2, + sampleRatesHz = listOf(44100, 48000, 96000), + usbPermissionGranted = true, + hasUsbPeripheral = true, + deviceId = 7, + ) + + @Test + fun goldRequiresVerifiedExclusiveUsbClock() { + val report = evaluateSignalPath(exclusiveInput()) + assertThat(report.bitPerfect).isTrue() + assertThat(report.checks).isNotEmpty() + assertThat(report.checks.all { it.passed }).isTrue() + } + + @Test + fun mixerBitPerfectGrantIsNeverGold() { + val report = evaluateSignalPath( + exclusiveInput().copy( + usbExclusiveActive = false, + exclusiveClockMatched = false, + exclusiveHardwareVolume = false, + routeVerified = true, + platformBitPerfectConfigured = true, + systemVolume = 15, + systemVolumeMax = 15, + systemVolumeFixed = false, + ), + ) + assertThat(report.bitPerfect).isFalse() + assertThat(report.checks.any { !it.passed }).isTrue() + } + + @Test + fun exclusiveWithoutClockMatchIsNeverGold() { + val report = evaluateSignalPath( + exclusiveInput().copy( + exclusiveClockMatched = false, + routeVerified = false, + ), + ) + assertThat(report.bitPerfect).isFalse() + } + + @Test + fun exclusiveHardwareVolumeCanPassBelowUnityAppGain() { + val report = evaluateSignalPath( + exclusiveInput().copy( + appVolume = 0.4f, + exclusiveHardwareVolume = true, + systemVolume = 3, + systemVolumeMax = 15, + ), + ) + assertThat(report.bitPerfect).isTrue() + } + + @Test + fun exclusiveSoftwareVolumePassesBelowUnityAppGain() { + val report = evaluateSignalPath( + exclusiveInput().copy( + exclusiveHardwareVolume = false, + systemVolume = 8, + systemVolumeMax = 15, + systemVolumeFixed = false, + appVolume = 8f / 15f, + ), + ) + assertThat(report.bitPerfect).isTrue() + } + + @Test + fun exclusiveSoftwareVolumeGoldAtAnyGain() { + val report = evaluateSignalPath( + exclusiveInput().copy( + exclusiveHardwareVolume = false, + systemVolume = 15, + systemVolumeMax = 15, + systemVolumeFixed = false, + appVolume = 1f, + ), + ) + assertThat(report.bitPerfect).isTrue() + } + + @Test + fun exclusiveUsesDecodedOutputRateWhenSourceMetadataMissing() { + val report = evaluateSignalPath( + exclusiveInput().copy( + sourceRateHz = null, + sourceBitDepth = null, + appOutputRateHz = 44100, + ), + ) + assertThat(report.bitPerfect).isTrue() + assertThat(report.sourceRateHz).isEqualTo(44100) + assertThat(report.appRateHz).isEqualTo(44100) + assertThat(report.usbExclusiveActive).isTrue() + assertThat(report.checks.all { it.passed }).isTrue() + } + + @Test + fun clockFallbackResampledFailsGoldWithDacUnsupportedDetail() { + val report = evaluateSignalPath( + exclusiveInput().copy( + sourceRateHz = 88200, + appOutputRateHz = 96000, + clockFallbackResampled = true, + exclusiveClockMatched = false, + ), + ) + assertThat(report.bitPerfect).isFalse() + assertThat(report.clockFallbackResampled).isTrue() + val resamplerCheck = report.checks.first { it.labelRes == com.lastwave.app.R.string.signal_label_resampler } + assertThat(resamplerCheck.passed).isFalse() + assertThat(resamplerCheck.detailRes).isEqualTo(com.lastwave.app.R.string.signal_detail_resample_dac_unsupported) + val outputCheck = report.checks.last { it.labelRes == com.lastwave.app.R.string.signal_label_output } + assertThat(outputCheck.passed).isFalse() + assertThat(outputCheck.detailRes).isEqualTo(com.lastwave.app.R.string.signal_detail_usb_clock_fallback) + } + + @Test + fun sourceBitDepthMatchesLabelAndNeverForcedTo32Bit() { + val report = evaluateSignalPath( + exclusiveInput().copy( + sourceLabel = "16/48kHz", + sourceRateHz = 48000, + sourceBitDepth = 32, // Even if an upstream component erroneously reported 32 + appOutputRateHz = 48000, + ), + ) + assertThat(report.bitPerfect).isTrue() + val srcCheck = report.checks.first { it.labelRes == com.lastwave.app.R.string.signal_label_source } + assertThat(srcCheck.passed).isTrue() + assertThat(srcCheck.detailArgs).containsExactly("16/48kHz", 16, 48000).inOrder() + } + + @Test + fun exclusiveClockDriftUsesFrameClockNotExoPosition() { + val tracker = StreamHealthTracker() + val rate = 176_400 + assertThat(tracker.sampleExclusive(0L, rate, 1_000L, true)).isNull() + val first = tracker.sampleExclusive(rate.toLong(), rate, 2_000L, true) + assertThat(first).isNotNull() + assertThat(kotlin.math.abs(first!!)).isLessThan(5_000.0) + } + + @Test + fun exclusiveClockDriftSurvivesBlockingUsbWriteCatchUp() { + val tracker = StreamHealthTracker() + val rate = 96_000 + assertThat(tracker.sampleExclusive(0L, rate, 0L, true)).isNull() + // 1 s wall, no new frames: write() still blocked. Stay on measuring, + // do not treat it as a stall that wipes the estimate forever. + assertThat(tracker.sampleExclusive(0L, rate, 1_000L, true)).isNull() + // 3 s of frames land in one tick after the JNI write returns. + val caught = tracker.sampleExclusive(rate * 3L, rate, 3_000L, true) + assertThat(caught).isNotNull() + assertThat(kotlin.math.abs(caught!!)).isLessThan(5_000.0) + val next = tracker.sampleExclusive(rate * 4L, rate, 4_000L, true) + assertThat(next).isNotNull() + assertThat(kotlin.math.abs(next!!)).isLessThan(5_000.0) + } + + @Test + fun exclusiveFallbackRateSelectionOnlyAppliesToUnsupportedRates() { + val dacRates = listOf(44100, 48000, 96000, 192000) + // 176.4 kHz unsupported by DAC -> falls back to 192 kHz + assertThat(MusicPlayer.selectExclusiveRateFallback(176400, dacRates)).isEqualTo(192000) + // 44.1 kHz supported -> no fallback (null) + assertThat(MusicPlayer.selectExclusiveRateFallback(44100, dacRates)).isNull() + // 96 kHz supported -> no fallback (null) + assertThat(MusicPlayer.selectExclusiveRateFallback(96000, dacRates)).isNull() + // 192 kHz supported -> no fallback (null) + assertThat(MusicPlayer.selectExclusiveRateFallback(192000, dacRates)).isNull() + } + + @Test + fun trackTransitionAfterFallbackRestoresTrueBitPerfectOnSupportedTrack() { + val dacRates = listOf(44100, 48000, 96000, 192000) + + // Track 1: 176.4 kHz FLAC unsupported on this DAC -> falls back to 192 kHz + val track1FallbackRate = MusicPlayer.selectExclusiveRateFallback(176400, dacRates) + assertThat(track1FallbackRate).isEqualTo(192000) + + val track1Report = evaluateSignalPath( + exclusiveInput().copy( + sourceLabel = "24/176.4kHz", + sourceRateHz = 176400, + sourceBitDepth = 24, + appOutputRateHz = track1FallbackRate ?: 176400, + clockFallbackResampled = true, + exclusiveClockMatched = false, + ), + ) + assertThat(track1Report.bitPerfect).isFalse() + assertThat(track1Report.clockFallbackResampled).isTrue() + + // Track 2: Next song in playlist is 44.1 kHz FLAC -> natively supported! + val track2FallbackRate = MusicPlayer.selectExclusiveRateFallback(44100, dacRates) + assertThat(track2FallbackRate).isNull() + + val track2Report = evaluateSignalPath( + exclusiveInput().copy( + sourceLabel = "16/44.1kHz", + sourceRateHz = 44100, + sourceBitDepth = 16, + appOutputRateHz = 44100, + clockFallbackResampled = false, + exclusiveClockMatched = true, + ), + ) + assertThat(track2Report.bitPerfect).isTrue() + assertThat(track2Report.clockFallbackResampled).isFalse() + assertThat(track2Report.appRateHz).isEqualTo(44100) + assertThat(track2Report.sourceRateHz).isEqualTo(44100) + } + + private fun exclusiveInput() = SignalPathInput( + sourceLabel = "FLAC", + sourceRateHz = 96000, + sourceBitDepth = 24, + isLossless = true, + appOutputRateHz = 96000, + platformMixerRateHz = 48000, + dspBypassEnabled = true, + crossfadeMixing = false, + speed = 1f, + appVolume = 1f, + systemVolume = 8, + systemVolumeMax = 15, + systemVolumeFixed = false, + dac = dac, + routedToDac = true, + routeVerified = true, + driftPpm = null, + glitchCount = 0, + isPlaying = true, + platformBitPerfectConfigured = false, + usbExclusiveActive = true, + exclusiveClockMatched = true, + exclusiveHardwareVolume = true, + ) +} From efa8022258fb6005ed55a78a83c16f2405cbfdc7 Mon Sep 17 00:00:00 2001 From: ajisth69 Date: Sun, 18 Oct 2026 19:17:04 +0530 Subject: [PATCH 07/12] feat(auth): add YouTube Music cookie login with Netscape, JSON, and raw header support --- .../app/data/ytmusic/CookieNormalizer.kt | 137 ++++++++++++++ .../app/ui/settings/CookieLoginDialog.kt | 169 ++++++++++++++++++ .../app/ui/settings/SettingsScreen.kt | 85 ++++++--- .../app/ui/settings/SettingsViewModel.kt | 35 ++++ .../app/ui/settings/YouTubeLoginScreen.kt | 87 +++++++-- .../app/ui/settings/YouTubeLoginViewModel.kt | 26 ++- app/src/main/res/values/strings.xml | 7 + .../app/data/ytmusic/CookieNormalizerTest.kt | 75 ++++++++ 8 files changed, 575 insertions(+), 46 deletions(-) create mode 100644 app/src/main/java/com/lastwave/app/data/ytmusic/CookieNormalizer.kt create mode 100644 app/src/main/java/com/lastwave/app/ui/settings/CookieLoginDialog.kt create mode 100644 app/src/test/java/com/lastwave/app/data/ytmusic/CookieNormalizerTest.kt diff --git a/app/src/main/java/com/lastwave/app/data/ytmusic/CookieNormalizer.kt b/app/src/main/java/com/lastwave/app/data/ytmusic/CookieNormalizer.kt new file mode 100644 index 000000000..6ac9f754d --- /dev/null +++ b/app/src/main/java/com/lastwave/app/data/ytmusic/CookieNormalizer.kt @@ -0,0 +1,137 @@ +package com.lastwave.app.data.ytmusic + +import org.json.JSONArray +import org.json.JSONObject + +/** + * Normalizes user-pasted YouTube Music session cookies into a clean `Cookie` header string. + * + * Direct port of the desktop app's [InnerTubeMusicApi.normalizeCookies]: + * Accepts: + * - Raw Cookie header: `A=1; B=2` + * - Netscape cookies.txt (TSV format with `#HttpOnly` lines, tab columns) + * - JSON object or array exported from browser extensions (e.g. Cookie-Editor, EditThisCookie) + * - Multiline pastes (newlines treated as separators) + * + * Validates that at least one SAPISID token (`__Secure-3PAPISID`, `SAPISID`, or `APISID`) + * is present, which is required to build the `SAPISIDHASH` authorization header. + */ +object CookieNormalizer { + + private val SAPISID_KEYS = listOf("__Secure-3PAPISID", "SAPISID", "APISID") + + /** + * Normalizes [raw] into a standard `; `-separated cookie header. + * + * @throws IllegalArgumentException if input is empty, unparseable, or missing SAPISID tokens. + */ + fun normalize(raw: String?): String { + val input = raw?.trim().orEmpty() + if (input.isEmpty()) { + throw IllegalArgumentException("Please enter cookie text.") + } + + val pairs = mutableMapOf() + + fun addPair(name: String, rawVal: String) { + val n = name.trim() + var v = rawVal.trim() + if (n.isEmpty() || v.isEmpty()) return + // Strip surrounding quotes some exporters add + if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith('\'') && v.endsWith('\'')))) { + v = v.substring(1, v.length - 1).trim() + } + if (v.isNotEmpty()) { + pairs[n] = v + } + } + + // 1. JSON export? e.g. [{"name":"SID","value":"..."}] or {"cookies": [...]} or {"SID":"..."} + if (input.startsWith("[") || input.startsWith("{")) { + try { + if (input.startsWith("[")) { + val arr = JSONArray(input) + for (i in 0 until arr.length()) { + val obj = arr.optJSONObject(i) ?: continue + val n = obj.optString("name") + val v = obj.optString("value") + addPair(n, v) + } + } else { + val obj = JSONObject(input) + val cookiesArr = obj.optJSONArray("cookies") + if (cookiesArr != null) { + for (i in 0 until cookiesArr.length()) { + val item = cookiesArr.optJSONObject(i) ?: continue + addPair(item.optString("name"), item.optString("value")) + } + } else { + val keys = obj.keys() + while (keys.hasNext()) { + val k = keys.next() + if (k != "cookies") { + addPair(k, obj.optString(k)) + } + } + } + } + } catch (_: Exception) { + // Not valid JSON — fall through to line-oriented parsing + } + } + + // 2. Line-oriented parse: handles header strings, multiline pastes, + // Netscape cookies.txt (7 tab columns), and `Cookie: ...` prefixes. + if (pairs.isEmpty()) { + for (rawLine in input.lines()) { + var line = rawLine.trim() + if (line.isEmpty()) continue + + // `#HttpOnly` lines are DATA in Netscape files, not comments + if (line.startsWith("#HttpOnly_", ignoreCase = true)) { + line = line.substring(10).trim() + } else if (line.startsWith("#HttpOnly", ignoreCase = true)) { + line = line.substring(9).trim() + } else if (line.startsWith("#")) { + continue + } + + if (line.startsWith("cookie:", ignoreCase = true)) { + line = line.substring(7).trim() + } + + // Netscape format: domain, flag, path, secure, expiry, name, value + if ('\t' in line) { + val cols = line.split('\t') + if (cols.size >= 7) { + addPair(cols[5], cols[6]) + continue + } + line = line.replace('\t', ';') + } + + // Semicolon-separated header chunks on this line + for (part in line.split(';')) { + val chunk = part.trim() + if (chunk.isEmpty() || chunk.startsWith("#")) continue + val idx = chunk.indexOf('=') + if (idx <= 0) continue + addPair(chunk.substring(0, idx), chunk.substring(idx + 1)) + } + } + } + + if (pairs.isEmpty()) { + throw IllegalArgumentException("No cookies found — paste a Cookie header, cookies.txt, or JSON") + } + + val hasSapisid = SAPISID_KEYS.any { pairs.containsKey(it) } + if (!hasSapisid) { + throw IllegalArgumentException( + "Missing SAPISID — export cookies from music.youtube.com while signed in (need __Secure-3PAPISID or SAPISID)" + ) + } + + return pairs.keys.sorted().joinToString("; ") { "$it=${pairs[it]}" } + } +} diff --git a/app/src/main/java/com/lastwave/app/ui/settings/CookieLoginDialog.kt b/app/src/main/java/com/lastwave/app/ui/settings/CookieLoginDialog.kt new file mode 100644 index 000000000..e4edb4274 --- /dev/null +++ b/app/src/main/java/com/lastwave/app/ui/settings/CookieLoginDialog.kt @@ -0,0 +1,169 @@ +package com.lastwave.app.ui.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material.icons.filled.Key +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.lastwave.app.R +import com.lastwave.app.data.ytmusic.CookieNormalizer + +@Composable +fun CookieLoginDialog( + onDismiss: () -> Unit, + onConnect: (String) -> Unit, + isConnecting: Boolean = false, +) { + var rawText by remember { mutableStateOf("") } + var errorText by remember { mutableStateOf(null) } + val clipboardManager = LocalClipboardManager.current + + AlertDialog( + onDismissRequest = { if (!isConnecting) onDismiss() }, + shape = RoundedCornerShape(28.dp), + icon = { + Icon( + Icons.Filled.Key, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(28.dp), + ) + }, + title = { + Text( + stringResource(R.string.settings_cookie_login), + style = MaterialTheme.typography.headlineSmall, + ) + }, + text = { + Column(Modifier.fillMaxWidth()) { + Text( + stringResource(R.string.cookie_login_instructions), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = rawText, + onValueChange = { + rawText = it + if (errorText != null) errorText = null + }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + stringResource(R.string.cookie_login_placeholder), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + }, + minLines = 3, + maxLines = 6, + shape = RoundedCornerShape(12.dp), + isError = errorText != null, + ) + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = { + val clip = clipboardManager.getText()?.text + if (!clip.isNullOrBlank()) { + rawText = clip + errorText = null + } + }, + shape = RoundedCornerShape(8.dp), + enabled = !isConnecting, + ) { + Icon( + Icons.Filled.ContentPaste, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(6.dp)) + Text( + stringResource(R.string.cookie_login_paste_clipboard), + style = MaterialTheme.typography.labelMedium, + ) + } + } + if (errorText != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = errorText.orEmpty(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + }, + confirmButton = { + Button( + onClick = { + val text = rawText.trim() + if (text.isEmpty()) { + errorText = "Please enter cookie text." + return@Button + } + try { + val normalized = CookieNormalizer.normalize(text) + errorText = null + onConnect(normalized) + } catch (e: Exception) { + errorText = e.message ?: "Invalid cookies. Make sure SAPISID is present." + } + }, + enabled = !isConnecting && rawText.isNotBlank(), + ) { + if (isConnecting) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.cookie_login_connecting)) + } else { + Text(stringResource(R.string.cookie_login_connect)) + } + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + enabled = !isConnecting, + ) { + Text(stringResource(R.string.common_cancel)) + } + }, + ) +} diff --git a/app/src/main/java/com/lastwave/app/ui/settings/SettingsScreen.kt b/app/src/main/java/com/lastwave/app/ui/settings/SettingsScreen.kt index 54e7e6528..3b1ffbd68 100644 --- a/app/src/main/java/com/lastwave/app/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/lastwave/app/ui/settings/SettingsScreen.kt @@ -95,6 +95,7 @@ import androidx.compose.material.icons.filled.Colorize import androidx.compose.material.icons.filled.Contrast import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Apps import androidx.compose.material.icons.filled.AutoAwesome @@ -504,6 +505,7 @@ fun SettingsScreen( var showYtLibraryVisibilitySheet by remember { mutableStateOf(false) } var showYtChannelSheet by remember { mutableStateOf(false) } var showYtDisconnectConfirm by remember { mutableStateOf(false) } + var showCookieLoginDialog by remember { mutableStateOf(false) } var showLanguageDialog by remember { mutableStateOf(false) } val currentLanguage = remember(misc.appLanguageTag) { AppLanguage.fromTag(misc.appLanguageTag) } @@ -1473,19 +1475,11 @@ fun SettingsScreen( it.authUserIndex == ytConnection.authUserIndex && it.pageId == ytConnection.pageId }?.accountName ?: ytConnection.accountName - val ytRowCount = if (ytConnected) 7 else 2 + val ytRowCount = if (ytConnected) 7 else 3 SettingsGroup(rowCount = ytRowCount) { index, position -> - when (index) { - 0 -> if (ytConnected) { - YouTubeAccountRow( - accountName = ytConnection.accountName, - channelHandle = ytConnection.channelHandle, - onDisconnect = { showYtDisconnectConfirm = true }, - position = position, - isHighlighted = (highlightedSettingId == "youtube.account"), - ) - } else { - SettingsActionCard( + if (!ytConnected) { + when (index) { + 0 -> SettingsActionCard( icon = Icons.Filled.CloudSync, iconContainer = MaterialTheme.colorScheme.primaryContainer, iconTint = MaterialTheme.colorScheme.onPrimaryContainer, @@ -1495,20 +1489,49 @@ fun SettingsScreen( position = position, isHighlighted = (highlightedSettingId == "youtube.account"), ) + 1 -> SettingsActionCard( + icon = Icons.Filled.Key, + iconContainer = MaterialTheme.colorScheme.secondaryContainer, + iconTint = MaterialTheme.colorScheme.onSecondaryContainer, + title = stringResource(R.string.settings_cookie_login), + subtitle = stringResource(R.string.settings_cookie_login_sub), + onClick = { showCookieLoginDialog = true }, + position = position, + isHighlighted = (highlightedSettingId == "youtube.cookie_login"), + ) + 2 -> SettingsToggleCard( + icon = Icons.Filled.CloudSync, + iconContainer = MaterialTheme.colorScheme.secondaryContainer, + iconTint = MaterialTheme.colorScheme.onSecondaryContainer, + title = stringResource(R.string.settings_yt_sync), + subtitle = syncSubtitle, + checked = false, + onCheckedChange = viewModel::setYtSyncEnabled, + position = position, + isHighlighted = (highlightedSettingId == "youtube.sync"), + ) } - 1 -> SettingsToggleCard( - icon = Icons.Filled.CloudSync, - iconContainer = MaterialTheme.colorScheme.secondaryContainer, - iconTint = MaterialTheme.colorScheme.onSecondaryContainer, - title = stringResource(R.string.settings_yt_sync), - subtitle = syncSubtitle, - checked = ytConnected && ytSyncEnabled, - onCheckedChange = viewModel::setYtSyncEnabled, - position = position, - isHighlighted = (highlightedSettingId == "youtube.sync"), - ) - 2 -> if (ytConnected) { - SettingsActionCard( + } else { + when (index) { + 0 -> YouTubeAccountRow( + accountName = ytConnection.accountName, + channelHandle = ytConnection.channelHandle, + onDisconnect = { showYtDisconnectConfirm = true }, + position = position, + isHighlighted = (highlightedSettingId == "youtube.account"), + ) + 1 -> SettingsToggleCard( + icon = Icons.Filled.CloudSync, + iconContainer = MaterialTheme.colorScheme.secondaryContainer, + iconTint = MaterialTheme.colorScheme.onSecondaryContainer, + title = stringResource(R.string.settings_yt_sync), + subtitle = syncSubtitle, + checked = ytSyncEnabled, + onCheckedChange = viewModel::setYtSyncEnabled, + position = position, + isHighlighted = (highlightedSettingId == "youtube.sync"), + ) + 2 -> SettingsActionCard( icon = Icons.Filled.SwitchAccount, iconContainer = MaterialTheme.colorScheme.secondaryContainer, iconTint = MaterialTheme.colorScheme.onSecondaryContainer, @@ -1521,7 +1544,6 @@ fun SettingsScreen( position = position, isHighlighted = (highlightedSettingId == "youtube.channel"), ) - } 3 -> if (ytConnected) { val selectedCount = syncedPlaylistIds?.size ?: allPlaylists.size val syncCountText = if (syncedPlaylistIds == null || selectedCount == allPlaylists.size) { @@ -2086,6 +2108,17 @@ fun SettingsScreen( ) } + if (showCookieLoginDialog) { + CookieLoginDialog( + onDismiss = { showCookieLoginDialog = false }, + onConnect = { cookies -> + viewModel.connectWithCookies(cookies) { success, _ -> + if (success) showCookieLoginDialog = false + } + }, + ) + } + // -- Restore confirm -- if (state.showRestoreConfirm) { val isPlaylistMirror = state.pendingRestoreKind == PendingRestoreKind.PLAYLIST_MIRROR AlertDialog( diff --git a/app/src/main/java/com/lastwave/app/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/lastwave/app/ui/settings/SettingsViewModel.kt index 0751679cf..5ccc34a94 100644 --- a/app/src/main/java/com/lastwave/app/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/lastwave/app/ui/settings/SettingsViewModel.kt @@ -999,6 +999,41 @@ class SettingsViewModel @Inject constructor( } } + fun connectWithCookies(rawText: String, onComplete: ((Boolean, String?) -> Unit)? = null) { + viewModelScope.launch { + val normalized = try { + com.lastwave.app.data.ytmusic.CookieNormalizer.normalize(rawText) + } catch (e: Exception) { + onComplete?.invoke(false, e.message ?: "Invalid cookies. Make sure SAPISID is present.") + return@launch + } + try { + ytAuthManager.connect(normalized, "", null, null) + kotlinx.coroutines.withTimeoutOrNull(4_000L) { ytAuthManager.awaitLoadedConnection() } + runCatching { sessionPreferences.exitGuestMode() } + var info: com.lastwave.app.data.music.YtAccountInfo? = null + for ((attempt, waitMs) in longArrayOf(0L, 1_200L, 2_500L, 4_000L).withIndex()) { + if (waitMs > 0L) kotlinx.coroutines.delay(waitMs) + info = runCatching { innerTube.fetchAccountInfo() }.getOrNull() + if (info != null) break + } + val displayName = info?.accountName?.takeIf { it.isNotBlank() } ?: "Google account" + if (info != null) { + ytAuthManager.updateAccountIdentity(info.accountName, info.channelHandle, info.photoUrl) + } + viewModelScope.launch { + runCatching { ytMusicSyncManager.syncNow("connected") } + } + _uiState.update { it.copy(toastMessage = "Connected as $displayName") } + onComplete?.invoke(true, null) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (e: Exception) { + onComplete?.invoke(false, e.localizedMessage ?: e.message ?: "Connection failed") + } + } + } + fun disconnectYouTube() { launchSettingsAction("disconnect YouTube Music") { ytMusicPreferences.setSyncEnabled(false) diff --git a/app/src/main/java/com/lastwave/app/ui/settings/YouTubeLoginScreen.kt b/app/src/main/java/com/lastwave/app/ui/settings/YouTubeLoginScreen.kt index dd371bb69..797144b46 100644 --- a/app/src/main/java/com/lastwave/app/ui/settings/YouTubeLoginScreen.kt +++ b/app/src/main/java/com/lastwave/app/ui/settings/YouTubeLoginScreen.kt @@ -24,16 +24,22 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CloudSync +import androidx.compose.material.icons.filled.Key import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.lastwave.app.R import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -64,6 +70,7 @@ fun YouTubeLoginScreen( val state by viewModel.uiState.collectAsStateWithLifecycle() var loadProgressVisible by remember { mutableStateOf(true) } var webViewError by remember { mutableStateOf(null) } + var showCookieDialog by remember { mutableStateOf(false) } LaunchedEffect(state.connectedName, state.verifying) { if (state.connectedName != null || state.verifying) return@LaunchedEffect @@ -93,6 +100,19 @@ fun YouTubeLoginScreen( title = "Connect YouTube Music", subtitle = "Sign in with your Google account", onBack = onBack, + actions = { + FilledTonalIconButton( + onClick = { showCookieDialog = true }, + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ), + ) { + Icon( + Icons.Filled.Key, + contentDescription = stringResource(R.string.settings_cookie_login), + ) + } + }, ) Box(Modifier.weight(1f)) { @@ -259,29 +279,51 @@ fun YouTubeLoginScreen( } } Spacer(Modifier.height(12.dp)) - Button( - onClick = { - viewModel.attemptConnect(readYouTubeCookies()) - }, - enabled = webViewError == null && !state.verifying && state.connectedName == null, - shape = CircleShape, - modifier = Modifier.fillMaxWidth().height(46.dp), + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - if (state.verifying) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, + OutlinedButton( + onClick = { showCookieDialog = true }, + enabled = !state.verifying && state.connectedName == null, + shape = CircleShape, + modifier = Modifier.weight(1f).height(46.dp), + ) { + Icon( + Icons.Filled.Key, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(6.dp)) + Text( + "Cookie Login", + maxLines = 1, ) - Spacer(Modifier.width(8.dp)) - Text("Verifying account...") - } else { - Text("I'm signed in") + } + Button( + onClick = { + viewModel.attemptConnect(readYouTubeCookies()) + }, + enabled = webViewError == null && !state.verifying && state.connectedName == null, + shape = CircleShape, + modifier = Modifier.weight(1.2f).height(46.dp), + ) { + if (state.verifying) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text("Verifying...") + } else { + Text("I'm signed in") + } } } Spacer(Modifier.height(6.dp)) Text( - "After signing in you'll be returned here automatically.", + "After signing in or connecting cookies you'll be returned here automatically.", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), ) @@ -289,6 +331,17 @@ fun YouTubeLoginScreen( } } } + + if (showCookieDialog) { + CookieLoginDialog( + onDismiss = { showCookieDialog = false }, + onConnect = { cookies -> + showCookieDialog = false + viewModel.attemptCookieConnect(cookies) + }, + isConnecting = state.verifying, + ) + } } private const val LOGIN_URL = diff --git a/app/src/main/java/com/lastwave/app/ui/settings/YouTubeLoginViewModel.kt b/app/src/main/java/com/lastwave/app/ui/settings/YouTubeLoginViewModel.kt index 1c6bef5f6..c355c17a5 100644 --- a/app/src/main/java/com/lastwave/app/ui/settings/YouTubeLoginViewModel.kt +++ b/app/src/main/java/com/lastwave/app/ui/settings/YouTubeLoginViewModel.kt @@ -33,7 +33,24 @@ class YouTubeLoginViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() /** - * Persists the cookies captured from the sign-in WebView and reports + * Connects with pasted cookies (raw header, Netscape cookies.txt, or JSON array). + * Normalizes and validates via [com.lastwave.app.data.ytmusic.CookieNormalizer], then connects. + */ + fun attemptCookieConnect(rawText: String) { + if (_uiState.value.verifying) return + val normalized = try { + com.lastwave.app.data.ytmusic.CookieNormalizer.normalize(rawText) + } catch (e: Exception) { + _uiState.update { + it.copy(errorMessage = e.message ?: "Invalid cookies. Make sure SAPISID is present.") + } + return + } + attemptConnect(normalized) + } + + /** + * Persists the cookies captured from the sign-in WebView or cookie login and reports * success as soon as they carry a real YouTube session (4.1.0 behavior). * Resolving the account name via InnerTube is best-effort enrichment — * a slow or unavailable account_menu must never reject a sign-in whose @@ -42,7 +59,10 @@ class YouTubeLoginViewModel @Inject constructor( */ fun attemptConnect(rawCookieHeader: String?) { if (_uiState.value.verifying) return - val cookies = rawCookieHeader.orEmpty() + val raw = rawCookieHeader.orEmpty() + val cookies = runCatching { + com.lastwave.app.data.ytmusic.CookieNormalizer.normalize(raw) + }.getOrDefault(raw) val hasSapisid = listOf("__Secure-3PAPISID=", "SAPISID=", "APISID=").any { it in cookies } if (!hasSapisid) { _uiState.update { @@ -55,7 +75,7 @@ class YouTubeLoginViewModel @Inject constructor( viewModelScope.launch { val previous = ytAuthManager.connection.value try { - ytAuthManager.connect(rawCookieHeader ?: return@launch, "", null, null) + ytAuthManager.connect(cookies, "", null, null) // Slow devices: the DataStore collector can re-emit the old // (disconnected) value after the optimistic update, making // fetchAccountInfo() bail instantly. Wait until memory matches diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bba6de7ca..30c885c80 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -30,6 +30,13 @@ YouTube Music Connect YouTube Music Sign in to see and sync every playlist + YouTube Music Cookie Login + Sign in with exported session cookies + Paste your session cookies from music.youtube.com (raw Cookie header, Netscape cookies.txt, or JSON array). Must contain SAPISID or __Secure-3PAPISID. + SID=...; __Secure-3PAPISID=...; SAPISID=... + Paste from Clipboard + Connect + Connecting… Two-way Playlist Sync Select Playlists to Sync YouTube Playlists Shown diff --git a/app/src/test/java/com/lastwave/app/data/ytmusic/CookieNormalizerTest.kt b/app/src/test/java/com/lastwave/app/data/ytmusic/CookieNormalizerTest.kt new file mode 100644 index 000000000..8b8c39f54 --- /dev/null +++ b/app/src/test/java/com/lastwave/app/data/ytmusic/CookieNormalizerTest.kt @@ -0,0 +1,75 @@ +package com.lastwave.app.data.ytmusic + +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.assertThrows +import org.junit.Test + +class CookieNormalizerTest { + + @Test + fun `normalizes raw cookie header with SAPISID`() { + val raw = "SID=abc123xyz; __Secure-3PAPISID=secret_sapisid; HSID=some_hsid" + val normalized = CookieNormalizer.normalize(raw) + assertThat(normalized).contains("__Secure-3PAPISID=secret_sapisid") + assertThat(normalized).contains("HSID=some_hsid") + assertThat(normalized).contains("SID=abc123xyz") + } + + @Test + fun `strips cookie prefix and surrounding quotes`() { + val raw = "Cookie: SID=\"abc123xyz\"; SAPISID='my_sapisid'" + val normalized = CookieNormalizer.normalize(raw) + assertThat(normalized).contains("SAPISID=my_sapisid") + assertThat(normalized).contains("SID=abc123xyz") + assertThat(normalized).doesNotContain("\"") + assertThat(normalized).doesNotContain("'") + } + + @Test + fun `parses JSON array from browser extension`() { + val json = """ + [ + {"name": "SID", "value": "12345"}, + {"name": "__Secure-3PAPISID", "value": "secure_token_123"}, + {"name": "SSID", "value": "67890"} + ] + """.trimIndent() + val normalized = CookieNormalizer.normalize(json) + assertThat(normalized).contains("__Secure-3PAPISID=secure_token_123") + assertThat(normalized).contains("SID=12345") + assertThat(normalized).contains("SSID=67890") + } + + @Test + fun `parses Netscape cookies text format with HttpOnly`() { + val netscape = """ + # Netscape HTTP Cookie File + # http://curl.haxx.se/rfc/cookie_spec.html + # This is a generated file! Do not edit. + + .youtube.com TRUE / TRUE 1799999999 SID netscape_sid + #HttpOnly_.youtube.com TRUE / TRUE 1799999999 SAPISID netscape_sapisid + """.trimIndent() + val normalized = CookieNormalizer.normalize(netscape) + assertThat(normalized).contains("SAPISID=netscape_sapisid") + assertThat(normalized).contains("SID=netscape_sid") + } + + @Test + fun `throws when empty or blank`() { + assertThrows(IllegalArgumentException::class.java) { + CookieNormalizer.normalize("") + } + assertThrows(IllegalArgumentException::class.java) { + CookieNormalizer.normalize(" ") + } + } + + @Test + fun `throws when missing SAPISID family`() { + val err = assertThrows(IllegalArgumentException::class.java) { + CookieNormalizer.normalize("SID=12345; HSID=67890") + } + assertThat(err.message).contains("Missing SAPISID") + } +} From 1f15c1aa6ab60d6ebc4a9b7c630087eca128eb8c Mon Sep 17 00:00:00 2001 From: ajisth69 Date: Sun, 18 Oct 2026 19:53:45 +0530 Subject: [PATCH 08/12] fix(ui): restore missing closing brace in SettingsScreen YouTube group --- .../main/java/com/lastwave/app/ui/settings/SettingsScreen.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/lastwave/app/ui/settings/SettingsScreen.kt b/app/src/main/java/com/lastwave/app/ui/settings/SettingsScreen.kt index 3b1ffbd68..d004c79f5 100644 --- a/app/src/main/java/com/lastwave/app/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/lastwave/app/ui/settings/SettingsScreen.kt @@ -1622,7 +1622,8 @@ fun SettingsScreen( } } } - } + } + } SettingsTab.LAST_FM -> { item { From 9664e19abc1e26aa887d41e7d1339aac960bf7f9 Mon Sep 17 00:00:00 2001 From: ajisth69 Date: Sun, 18 Oct 2026 20:52:32 +0530 Subject: [PATCH 09/12] fix(player): align header back and options buttons up using LiquidGlassSurface --- .../com/lastwave/app/ui/player/PlayerHost.kt | 60 ++++++++++--------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/lastwave/app/ui/player/PlayerHost.kt b/app/src/main/java/com/lastwave/app/ui/player/PlayerHost.kt index a0ae01f1e..d678c80b3 100644 --- a/app/src/main/java/com/lastwave/app/ui/player/PlayerHost.kt +++ b/app/src/main/java/com/lastwave/app/ui/player/PlayerHost.kt @@ -2284,13 +2284,13 @@ private fun FullPlayer( Modifier .fillMaxWidth() .height(52.dp) - .padding(horizontal = 20.dp) + .padding(horizontal = if (isTabletLandscape) 32.dp else 20.dp) .playerVerticalSwipe(enabled = currentTab != FullPlayerTab.NOW_PLAYING), ) { // FullPlayer sits on a dark blurred-artwork scrim in both light and // dark mode, so foreground must use the white overlay palette — // never MaterialTheme onSurface (near-black in light mode). - IconButton( + LiquidGlassSurface( onClick = { if (currentTab != FullPlayerTab.NOW_PLAYING) { onTabChange(FullPlayerTab.NOW_PLAYING) @@ -2298,20 +2298,22 @@ private fun FullPlayer( onCollapse() } }, + shape = CircleShape, + color = liquidGlassContainerColor(Color.White.copy(alpha = 0.14f)), + contentColor = Color.White.copy(alpha = 0.94f), + glassModifier = Modifier.liquidGlassChrome( + CircleShape, + LocalLiquidGlass.current, + LiquidGlassPreset.FloatingControls, + ), modifier = Modifier .align(Alignment.CenterStart) - .size(44.dp) - .clip(CircleShape) - .liquidGlassChrome(CircleShape, LocalLiquidGlass.current, LiquidGlassPreset.FloatingControls) - .background( - liquidGlassContainerColor(Color.White.copy(alpha = 0.14f)), - ), + .size(44.dp), ) { Icon( if (currentTab != FullPlayerTab.NOW_PLAYING) Icons.Filled.ArrowBack else Icons.Filled.ExpandMore, if (currentTab != FullPlayerTab.NOW_PLAYING) "Back to player" else "Minimize player", modifier = Modifier.size(26.dp), - tint = Color.White.copy(alpha = 0.94f), ) } Column( @@ -2347,40 +2349,42 @@ private fun FullPlayer( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { if (currentTab == FullPlayerTab.LYRICS && onSetLyricsOffsetMs != null) { - IconButton( + LiquidGlassSurface( onClick = { showLyricsOffsetDialog = true }, - modifier = Modifier - .size(44.dp) - .clip(CircleShape) - .liquidGlassChrome(CircleShape, LocalLiquidGlass.current, LiquidGlassPreset.FloatingControls) - .background( - if (lyricsOffsetMs != 0L) MaterialTheme.colorScheme.primary.copy(alpha = 0.35f) - else liquidGlassContainerColor(Color.White.copy(alpha = 0.14f)), - ), + shape = CircleShape, + color = if (lyricsOffsetMs != 0L) MaterialTheme.colorScheme.primary.copy(alpha = 0.35f) + else liquidGlassContainerColor(Color.White.copy(alpha = 0.14f)), + contentColor = if (lyricsOffsetMs != 0L) MaterialTheme.colorScheme.primary else Color.White.copy(alpha = 0.94f), + glassModifier = Modifier.liquidGlassChrome( + CircleShape, + LocalLiquidGlass.current, + LiquidGlassPreset.FloatingControls, + ), + modifier = Modifier.size(44.dp), ) { Icon( Icons.Filled.Timer, contentDescription = stringResource(com.lastwave.app.R.string.player_lyrics_offset_cd), modifier = Modifier.size(22.dp), - tint = if (lyricsOffsetMs != 0L) MaterialTheme.colorScheme.primary else Color.White.copy(alpha = 0.94f), ) } } - IconButton( + LiquidGlassSurface( onClick = { showTrackMenu = true }, - modifier = Modifier - .size(44.dp) - .clip(CircleShape) - .liquidGlassChrome(CircleShape, LocalLiquidGlass.current, LiquidGlassPreset.FloatingControls) - .background( - liquidGlassContainerColor(Color.White.copy(alpha = 0.14f)), - ), + shape = CircleShape, + color = liquidGlassContainerColor(Color.White.copy(alpha = 0.14f)), + contentColor = Color.White.copy(alpha = 0.94f), + glassModifier = Modifier.liquidGlassChrome( + CircleShape, + LocalLiquidGlass.current, + LiquidGlassPreset.FloatingControls, + ), + modifier = Modifier.size(44.dp), ) { Icon( Icons.Filled.MoreVert, "Song options", modifier = Modifier.size(22.dp), - tint = Color.White.copy(alpha = 0.94f), ) } } From 7f95ed255e02e4a72143f77f4f9b988449b9acf7 Mon Sep 17 00:00:00 2001 From: ajisth69 Date: Sun, 18 Oct 2026 21:02:22 +0530 Subject: [PATCH 10/12] fix(ytmusic): parse cookie JSON with pure Kotlin regex to prevent JVM unit test stub failure --- .../app/data/ytmusic/CookieNormalizer.kt | 50 ++++++++----------- 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/lastwave/app/data/ytmusic/CookieNormalizer.kt b/app/src/main/java/com/lastwave/app/data/ytmusic/CookieNormalizer.kt index 6ac9f754d..9aeea604b 100644 --- a/app/src/main/java/com/lastwave/app/data/ytmusic/CookieNormalizer.kt +++ b/app/src/main/java/com/lastwave/app/data/ytmusic/CookieNormalizer.kt @@ -1,8 +1,5 @@ package com.lastwave.app.data.ytmusic -import org.json.JSONArray -import org.json.JSONObject - /** * Normalizes user-pasted YouTube Music session cookies into a clean `Cookie` header string. * @@ -47,36 +44,29 @@ object CookieNormalizer { } // 1. JSON export? e.g. [{"name":"SID","value":"..."}] or {"cookies": [...]} or {"SID":"..."} + // Uses pure Kotlin regex parsing so unit tests on the JVM don't fail due to Android SDK org.json stubs. if (input.startsWith("[") || input.startsWith("{")) { - try { - if (input.startsWith("[")) { - val arr = JSONArray(input) - for (i in 0 until arr.length()) { - val obj = arr.optJSONObject(i) ?: continue - val n = obj.optString("name") - val v = obj.optString("value") - addPair(n, v) - } - } else { - val obj = JSONObject(input) - val cookiesArr = obj.optJSONArray("cookies") - if (cookiesArr != null) { - for (i in 0 until cookiesArr.length()) { - val item = cookiesArr.optJSONObject(i) ?: continue - addPair(item.optString("name"), item.optString("value")) - } - } else { - val keys = obj.keys() - while (keys.hasNext()) { - val k = keys.next() - if (k != "cookies") { - addPair(k, obj.optString(k)) - } - } + val objPattern = Regex("""\{[^{}]*\}""") + val namePattern = Regex(""""name"\s*:\s*"([^"]+)"""") + val valuePattern = Regex(""""value"\s*:\s*"([^"]*)"""") + for (match in objPattern.findAll(input)) { + val block = match.value + val n = namePattern.find(block)?.groupValues?.getOrNull(1) + val v = valuePattern.find(block)?.groupValues?.getOrNull(1) + if (n != null && v != null) { + addPair(n, v) + } + } + if (pairs.isEmpty()) { + val kvPattern = Regex(""""([^"]+)"\s*:\s*"([^"]*)"""") + val ignoredKeys = setOf("name", "value", "cookies", "domain", "path", "expirationDate", "hostOnly", "httpOnly", "sameSite", "secure", "session", "storeId") + for (m in kvPattern.findAll(input)) { + val k = m.groupValues[1] + val v = m.groupValues[2] + if (k !in ignoredKeys) { + addPair(k, v) } } - } catch (_: Exception) { - // Not valid JSON — fall through to line-oriented parsing } } From 9ffd89e1222b440f3894ddfe2c86e99486167519 Mon Sep 17 00:00:00 2001 From: ajisth69 Date: Sun, 18 Oct 2026 21:29:53 +0530 Subject: [PATCH 11/12] refactor(player): remove verification checklist and clarify USB hardware direct pipeline --- .../app/ui/player/SignalPathDialog.kt | 178 ++++++++---------- app/src/main/res/values/strings.xml | 5 + 2 files changed, 86 insertions(+), 97 deletions(-) diff --git a/app/src/main/java/com/lastwave/app/ui/player/SignalPathDialog.kt b/app/src/main/java/com/lastwave/app/ui/player/SignalPathDialog.kt index 95a67d4cb..7b540cd3c 100644 --- a/app/src/main/java/com/lastwave/app/ui/player/SignalPathDialog.kt +++ b/app/src/main/java/com/lastwave/app/ui/player/SignalPathDialog.kt @@ -332,53 +332,6 @@ fun SignalPathDialog( } } - // Collapsible Verification Checklist - var showChecks by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } - TextButton( - onClick = { showChecks = !showChecks }, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - if (showChecks) "Hide verification checks" else "Show verification checks (${report.checks.count { it.passed }}/${report.checks.size} passed)", - style = MaterialTheme.typography.labelMedium, - ) - } - - if (showChecks) { - report.checks.forEach { check -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - verticalAlignment = Alignment.Top, - ) { - Box( - modifier = Modifier - .padding(top = 5.dp) - .size(8.dp) - .background( - if (check.passed) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.error, - CircleShape, - ), - ) - Spacer(Modifier.width(10.dp)) - Column(Modifier.weight(1f)) { - Text( - stringResource(check.labelRes), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - ) - Text( - stringResource(check.detailRes, *check.detailArgs.toTypedArray()), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - Spacer(Modifier.height(8.dp)) - } Spacer(Modifier.height(8.dp)) Text( @@ -419,57 +372,88 @@ fun SignalPathDialog( ) HealthRow(stringResource(R.string.signal_glitches), report.glitchCount.toString()) - // Fork: what the phone's own output allows, and what is in use. + // Route/pipeline details: what the output allows and what is in use. Spacer(Modifier.height(8.dp)) - Text( - stringResource(R.string.signal_internal_section), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.height(4.dp)) - val session = report.internalSession - val route = session?.route ?: internalAvailability?.route ?: InternalRoute.UNKNOWN - val routeName = session?.routeName ?: internalAvailability?.routeName.orEmpty() - HealthRow( - stringResource(R.string.signal_internal_route), - routeLabel(route) + if (routeName.isNotBlank()) " • $routeName" else "", - ) - HealthRow( - stringResource(R.string.signal_internal_status), - when { - report.internalDirectActive -> stringResource(R.string.internal_reason_active) - !internalEnabled -> stringResource(R.string.signal_internal_off) - internalAvailability != null -> stringResource(internalFailureDetail(internalAvailability.reason)) - else -> "—" - }, - ) - val probe = internalAvailability?.probe - HealthRow( - stringResource(R.string.signal_internal_endpoint), - when { - session != null -> stringResource( - R.string.signal_internal_endpoint_value, - session.deviceRateHz, - InternalDirectStream.labelOf(session.deviceEncoding), - ) + when { - session.backend == com.lastwave.app.playback.InternalBackend.DIRECT_TRACK -> - " • " + stringResource(R.string.signal_internal_backend_direct) - session.mmap -> " • MMAP" - else -> "" - } - probe != null && probe.usable -> stringResource( - R.string.signal_internal_endpoint_value, - probe.sampleRateHz, - InternalDirectStream.labelOf(probe.encoding), - ) + if (probe.mmap) " • MMAP" else "" - probe != null && internalAvailability != null -> - stringResource(internalFailureDetail(internalAvailability.reason)) - else -> stringResource(R.string.signal_internal_not_checked) - }, - ) - if (internalUnderruns != null) { - HealthRow(stringResource(R.string.signal_internal_underruns), internalUnderruns.toString()) + if (report.usbExclusiveActive) { + Text( + stringResource(R.string.signal_usb_hardware_section), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(4.dp)) + HealthRow( + stringResource(R.string.signal_usb_interface), + stringResource(R.string.signal_usb_interface_value), + ) + HealthRow( + stringResource(R.string.signal_android_mixer), + stringResource(R.string.signal_android_mixer_bypassed), + ) + val epRate = if (report.outputRateHz > 0) report.outputRateHz else report.appRateHz + HealthRow( + stringResource(R.string.signal_internal_endpoint), + if (epRate > 0) { + stringResource( + R.string.signal_internal_endpoint_value, + epRate, + "${report.outputBits}-bit PCM", + ) + } else { + "—" + }, + ) + } else { + Text( + stringResource(R.string.signal_internal_section), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(4.dp)) + val session = report.internalSession + val route = session?.route ?: internalAvailability?.route ?: InternalRoute.UNKNOWN + val routeName = session?.routeName ?: internalAvailability?.routeName.orEmpty() + HealthRow( + stringResource(R.string.signal_internal_route), + routeLabel(route) + if (routeName.isNotBlank()) " • $routeName" else "", + ) + HealthRow( + stringResource(R.string.signal_internal_status), + when { + report.internalDirectActive -> stringResource(R.string.internal_reason_active) + !internalEnabled -> stringResource(R.string.signal_internal_off) + internalAvailability != null -> stringResource(internalFailureDetail(internalAvailability.reason)) + else -> "—" + }, + ) + val probe = internalAvailability?.probe + HealthRow( + stringResource(R.string.signal_internal_endpoint), + when { + session != null -> stringResource( + R.string.signal_internal_endpoint_value, + session.deviceRateHz, + InternalDirectStream.labelOf(session.deviceEncoding), + ) + when { + session.backend == com.lastwave.app.playback.InternalBackend.DIRECT_TRACK -> + " • " + stringResource(R.string.signal_internal_backend_direct) + session.mmap -> " • MMAP" + else -> "" + } + probe != null && probe.usable -> stringResource( + R.string.signal_internal_endpoint_value, + probe.sampleRateHz, + InternalDirectStream.labelOf(probe.encoding), + ) + if (probe.mmap) " • MMAP" else "" + probe != null && internalAvailability != null -> + stringResource(internalFailureDetail(internalAvailability.reason)) + else -> stringResource(R.string.signal_internal_not_checked) + }, + ) + if (internalUnderruns != null) { + HealthRow(stringResource(R.string.signal_internal_underruns), internalUnderruns.toString()) + } } if (usbDiagnostic != null) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 30c885c80..e5e079feb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -387,6 +387,11 @@ DIRECT → HAL Android mixer bypassed — samples enter the HAL unchanged. A vendor DSP after the HAL cannot be verified from Android. Exclusive AAudio (internal) + Hardware direct pipeline + Interface + Direct USB (usbdevfs) + Android mixer + Bypassed (Hardware exclusive) Internal output Route Direct output From 32e51ac4e4cd13c5a4b8e10b1694b49603bdaad1 Mon Sep 17 00:00:00 2001 From: ajisth69 Date: Sun, 18 Oct 2026 21:50:15 +0530 Subject: [PATCH 12/12] fix(feed,discover): filter listened tracks from discovery, enable radio queues on feed, and optimize feed load --- .../app/data/discover/DiscoverRepository.kt | 56 +++++++++++----- .../lastwave/app/data/feed/FeedRepository.kt | 65 +++++++++---------- .../com/lastwave/app/playback/MusicPlayer.kt | 23 +++++-- .../app/ui/discover/DiscoverScreen.kt | 2 +- .../app/ui/discover/DiscoverViewModel.kt | 12 +++- .../com/lastwave/app/ui/feed/FeedScreen.kt | 27 +++++--- .../com/lastwave/app/ui/feed/FeedViewModel.kt | 26 ++++++-- 7 files changed, 140 insertions(+), 71 deletions(-) diff --git a/app/src/main/java/com/lastwave/app/data/discover/DiscoverRepository.kt b/app/src/main/java/com/lastwave/app/data/discover/DiscoverRepository.kt index e5c662354..c4927cd00 100644 --- a/app/src/main/java/com/lastwave/app/data/discover/DiscoverRepository.kt +++ b/app/src/main/java/com/lastwave/app/data/discover/DiscoverRepository.kt @@ -25,7 +25,7 @@ private const val TAG = "DiscoverRepository" private const val MAX_REFILL_ATTEMPTS = 2 private const val MAX_EXPLORATION_SEEDS = 120 private const val MAX_TRACK_SEEDS = 6 -private const val DISCOVER_ARTIST_CAP = 3 +private const val DISCOVER_ARTIST_CAP = 1 private const val MAX_SHOWN_KEYS = 3_000 private const val SAVED_TRACK_SCORE_PENALTY = 22.0 @@ -186,16 +186,43 @@ class DiscoverRepository @Inject constructor( } } + val savedPlaylistKeys = generateRepository.savedPlaylistTrackKeys() + val listenedKeys = buildSet { + profile?.recentTracksRaw?.forEach { add(it.key) } + profile?.topTracksRaw?.forEach { add(it.key) } + profile?.ytMusicRecentRaw?.forEach { add(it.key) } + profile?.ytMusicLikedRaw?.forEach { add(it.key) } + profile?.ytMusicFeedRaw?.forEach { add(it.key) } + addAll(savedPlaylistKeys) + } + val listenedTitlesByArtist = buildMap> { + fun record(artist: String, title: String) { + val a = artist.trim().lowercase() + val t = title.trim().lowercase() + if (a.isNotBlank() && t.isNotBlank()) { + getOrPut(a) { mutableSetOf() }.add(t) + } + } + profile?.recentTracksRaw?.forEach { record(it.artist, it.name) } + profile?.topTracksRaw?.forEach { record(it.artist, it.name) } + profile?.ytMusicRecentRaw?.forEach { record(it.artist, it.name) } + profile?.ytMusicLikedRaw?.forEach { record(it.artist, it.name) } + profile?.ytMusicFeedRaw?.forEach { record(it.artist, it.name) } + } + + fun isAlreadyListened(track: GeneratedTrack): Boolean { + if (track.key in listenedKeys) return true + val a = track.artist.trim().lowercase() + val t = track.name.trim().lowercase() + return listenedTitlesByArtist[a]?.contains(t) == true + } + val queuedKeys = queue.mapTo(mutableSetOf()) { it.key } val candidates = LinkedHashMap() val sourceBatches = jobs.awaitAll().toMutableList() - profile?.ytMusicFeedRaw - ?.take(40) - ?.takeIf { it.isNotEmpty() } - ?.let { sourceBatches += SourceBatch(it, 92, "yt-feed") } for (batch in sourceBatches) { for (track in batch.tracks) { - if (track.name.isBlank() || track.artist.isBlank() || track.key in queuedKeys) continue + if (track.name.isBlank() || track.artist.isBlank() || track.key in queuedKeys || isAlreadyListened(track)) continue val current = candidates[track.key] if (current == null) { candidates[track.key] = RankedCandidate( @@ -216,12 +243,11 @@ class DiscoverRepository @Inject constructor( val allowedKeys = generateRepository.filterRecommendationExclusions( candidates.values.map(RankedCandidate::track), ).mapTo(mutableSetOf(), GeneratedTrack::key) - val savedPlaylistKeys = generateRepository.savedPlaylistTrackKeys() class ScoredCandidate(val candidate: RankedCandidate, val score: Double) val ranked = candidates.values - .filterNot { it.track.key in shownKeys } + .filterNot { it.track.key in shownKeys || isAlreadyListened(it.track) } .map { candidate -> val affinity = profile?.artistAffinity?.get(candidate.track.artist.trim().lowercase()) ?: 0.0 val consensus = minOf(24, (candidate.sources.size - 1).coerceAtLeast(0) * 8) @@ -236,16 +262,16 @@ class DiscoverRepository @Inject constructor( .map { it.candidate } val preferred = ranked.filter { it.track.key in allowedKeys } - val ytFeedCandidates = preferred.filter { "yt-feed" in it.sources || "yt-new" in it.sources } - val otherCandidates = preferred.filterNot { "yt-feed" in it.sources || "yt-new" in it.sources } + val trendingCandidates = preferred.filter { "yt-new" in it.sources } + val similarCandidates = preferred.filterNot { "yt-new" in it.sources } val balanced = buildList { - var otherIndex = 0 - var ytIndex = 0 - while (otherIndex < otherCandidates.size || ytIndex < ytFeedCandidates.size) { + var simIndex = 0 + var trendIndex = 0 + while (simIndex < similarCandidates.size || trendIndex < trendingCandidates.size) { repeat(2) { - if (otherIndex < otherCandidates.size) add(otherCandidates[otherIndex++]) + if (simIndex < similarCandidates.size) add(similarCandidates[simIndex++]) } - if (ytIndex < ytFeedCandidates.size) add(ytFeedCandidates[ytIndex++]) + if (trendIndex < trendingCandidates.size) add(trendingCandidates[trendIndex++]) } } val artistCounts = mutableMapOf() diff --git a/app/src/main/java/com/lastwave/app/data/feed/FeedRepository.kt b/app/src/main/java/com/lastwave/app/data/feed/FeedRepository.kt index 124804c0a..d0bf80fdc 100644 --- a/app/src/main/java/com/lastwave/app/data/feed/FeedRepository.kt +++ b/app/src/main/java/com/lastwave/app/data/feed/FeedRepository.kt @@ -19,12 +19,14 @@ import com.lastwave.app.data.playlist.PlaylistRepository import com.lastwave.app.data.repository.HomeRepository import com.lastwave.app.data.repository.RelatedGraphRepository import com.lastwave.app.data.ytmusic.YtMusicAuthManager +import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withTimeoutOrNull import javax.inject.Inject import javax.inject.Singleton @@ -113,6 +115,7 @@ class FeedRepository @Inject constructor( ) { private var cachedFeed: FeedData? = null private var cachedKey: String? = null + private val artistEntityCache = ConcurrentHashMap() fun getCachedFeed(): FeedData? = cachedFeed @@ -280,15 +283,13 @@ class FeedRepository @Inject constructor( } val familiarCandidates = buildList { - ytQuickPicks.forEachIndexed { i, t -> add(t to trackScore(t, i, 3.0)) } - ytLikedSongs.forEachIndexed { i, t -> add(t to trackScore(t, i, 2.2)) } - ytRecentSongs.forEachIndexed { i, t -> add(t to trackScore(t, i, 1.6)) } - regularPicks.forEachIndexed { i, t -> add(t to trackScore(t, i, 2.6)) } - // Local-first: liked + saved Room tracks score as personal picks - // for guest/disconnected so Quick Picks never looks generic. - localQuickPicks.forEachIndexed { i, t -> add(t to trackScore(t, i, 2.4)) } - homeSongs.forEachIndexed { i, t -> add(t to trackScore(t, i, 1.2)) } - charts.forEachIndexed { i, t -> add(t to trackScore(t, i, 1.0)) } + ytQuickPicks.forEachIndexed { i, t -> add(t to trackScore(t, i, 2.8)) } + homeSongs.forEachIndexed { i, t -> add(t to trackScore(t, i, 2.2)) } + charts.forEachIndexed { i, t -> add(t to trackScore(t, i, 2.0)) } + ytLikedSongs.forEachIndexed { i, t -> add(t to trackScore(t, i, 1.8)) } + regularPicks.forEachIndexed { i, t -> add(t to trackScore(t, i, 1.6)) } + localQuickPicks.forEachIndexed { i, t -> add(t to trackScore(t, i, 1.5)) } + ytRecentSongs.forEachIndexed { i, t -> add(t to trackScore(t, i, 1.2)) } } .distinctBy { (t, _) -> t.artist.trim().lowercase() to t.title.trim().lowercase() } .sortedByDescending { it.second } @@ -308,7 +309,7 @@ class FeedRepository @Inject constructor( // First paint: familiar-only picks. These need no network beyond the // parallel batch above, so a cold feed shows shelves immediately. // Blending discovery into Quick Picks must never gate this - // emission — the related-songs round-trips (3 x up to 30 tracks) are + // emission — the related-songs round-trips (3 x up to 30 tracks) are // the slowest calls in the load, and waiting on them left the whole // feed blank for seconds on a cold start. val familiarQuickPicks = diversify(familiarCandidates, YouTubeMusicTrack::artist, maxPerArtist = 2) @@ -321,8 +322,11 @@ class FeedRepository @Inject constructor( // Discovery: blends fresh picks over the familiar ones once the // round-trips land, and feeds Fresh Finds / Mixes below. It overlaps // with the artist fetches that follow instead of blocking paint. - val discoveryResults = discoverySeeds.zip(discoveryDef.await()) - val discoveryTracks = discoveryResults.flatMap { it.second }.distinctBy { it.videoId } + val discoveryBatches = withTimeoutOrNull(3000L) { discoveryDef.await() } ?: emptyList() + val discoveryResults = discoverySeeds.zip( + if (discoveryBatches.size == discoverySeeds.size) discoveryBatches else List(discoverySeeds.size) { emptyList() } + ) + val discoveryTracks = discoveryBatches.flatten().distinctBy { it.videoId } val familiarVideoIds = (ytLikedSongs + ytRecentSongs + regularPicks + localQuickPicks + homeSongs).mapTo(mutableSetOf()) { it.videoId } val familiarKeys = (ytLikedSongs + ytRecentSongs + regularPicks + localQuickPicks + homeSongs).mapTo(mutableSetOf()) { @@ -341,9 +345,11 @@ class FeedRepository @Inject constructor( .sortedByDescending { it.second } .map { it.first } + val freshOrChart = (newCandidates + charts.filter { it.videoId !in familiarVideoIds }) + .distinctBy { it.videoId } val quickCandidates = blend( - first = diversify(familiarCandidates, YouTubeMusicTrack::artist, maxPerArtist = 2), - second = diversify(newCandidates, YouTubeMusicTrack::artist, maxPerArtist = 1), + first = diversify(familiarCandidates, YouTubeMusicTrack::artist, maxPerArtist = 1), + second = diversify(freshOrChart, YouTubeMusicTrack::artist, maxPerArtist = 1), ) val quickPicks = diversify(quickCandidates, YouTubeMusicTrack::artist, maxPerArtist = 2) @@ -395,42 +401,33 @@ class FeedRepository @Inject constructor( val topArtists = topArtistNames.map { name -> async(Dispatchers.IO) { + val cached = artistEntityCache[name.lowercase()] + if (cached != null) return@async cached val trackArtwork = artistSignalTracks .firstOrNull { ArtistHelper.splitArtists(it.artist).any { artist -> artist.equals(name, ignoreCase = true) } } ?.artworkUrl previous?.topArtists?.firstOrNull { it.name.equals(name, ignoreCase = true) && it.browseId != null } - ?.let { return@async it } + ?.let { + artistEntityCache[name.lowercase()] = it + return@async it + } val entity = runCatching { innerTube.searchArtists(name, limit = 3) .firstOrNull { it.name.trim().equals(name, ignoreCase = true) } }.getOrNull() - FeedArtist( + val feedArtist = FeedArtist( name = name, browseId = entity?.browseId, artworkUrl = entity?.artworkUrl?.takeIf(ArtworkNormalizer::isRealImage) ?: trackArtwork, ) + artistEntityCache[name.lowercase()] = feedArtist + feedArtist } }.awaitAll() - val releaseYear = java.time.Year.now().value.toString() - val artistReleases = if (artistRanks.isEmpty()) emptyList() else topArtists - .filter { it.name.lowercase() in artistRanks } - .take(4).map { artist -> - async(Dispatchers.IO) { - val page = artist.browseId?.let { id -> - runCatching { innerTube.fetchArtistPage(id, artist.name) }.getOrNull() - } - (page?.albums.orEmpty() + page?.singles.orEmpty()) - .filter { it.year == releaseYear && it.browseId.isNotBlank() } - .take(3).map { release -> - YouTubePlaylistSummary(id = release.browseId, title = release.title, - author = artist.name, artworkUrl = release.artworkUrl) - } - } - }.awaitAll().flatten() - val newReleases = (matchedReleases + artistReleases) + val newReleases = (matchedReleases.ifEmpty { releaseCandidates }) .ifEmpty { previous?.newReleases.orEmpty() } - .distinctBy { it.id }.shuffled(random).take(15) + .distinctBy { it.id }.take(15) val familiarIds = (ytLikedSongs + ytRecentSongs + regularPicks).mapTo(mutableSetOf()) { it.videoId } val freshPool = (discoveryTracks + charts + homeSongs + ytQuickPicks) diff --git a/app/src/main/java/com/lastwave/app/playback/MusicPlayer.kt b/app/src/main/java/com/lastwave/app/playback/MusicPlayer.kt index e12796ab9..dad30b5e9 100644 --- a/app/src/main/java/com/lastwave/app/playback/MusicPlayer.kt +++ b/app/src/main/java/com/lastwave/app/playback/MusicPlayer.kt @@ -1935,9 +1935,17 @@ class MusicPlayer @Inject constructor( startIndex: Int = 0, sourceLabel: String = "LastWave", startShuffled: Boolean = false, + enableRadioContinuation: Boolean = false, ) { - disableRadioQueue() - playQueueInternal(tracks, startIndex, endlessDiscover = false, sourceLabel = sourceLabel, startShuffled = startShuffled) + if (!enableRadioContinuation) disableRadioQueue() + playQueueInternal( + tracks = tracks, + startIndex = startIndex, + endlessDiscover = false, + sourceLabel = sourceLabel, + startShuffled = startShuffled, + enableRadioContinuation = enableRadioContinuation, + ) } fun playDiscoverQueue(tracks: List, startIndex: Int = 0) { @@ -1951,12 +1959,19 @@ class MusicPlayer @Inject constructor( endlessDiscover: Boolean, sourceLabel: String = if (endlessDiscover) "Discover" else "LastWave", startShuffled: Boolean = false, + enableRadioContinuation: Boolean = false, ) { if (tracks.isEmpty()) return pendingRestoredSession = null discoverQueueLoadJob?.cancel() discoverQueueActive = endlessDiscover - disableRadioQueue() + if (!enableRadioContinuation) { + disableRadioQueue() + } else { + radioQueueActive = true + radioUsedSeeds.clear() + tracks.getOrNull(startIndex)?.videoId?.takeIf(String::isNotBlank)?.let { radioUsedSeeds.add(it) } + } val selectedIndex = startIndex.coerceIn(tracks.indices) playRequest?.cancel() queueEnrichmentJob?.cancel() @@ -1973,7 +1988,7 @@ class MusicPlayer @Inject constructor( selectedIndex = selectedIndex, startPositionMs = 0L, sourceLabel = sourceLabel, - endlessDiscover = endlessDiscover, + endlessDiscover = endlessDiscover || enableRadioContinuation, startShuffled = startShuffled, ) } diff --git a/app/src/main/java/com/lastwave/app/ui/discover/DiscoverScreen.kt b/app/src/main/java/com/lastwave/app/ui/discover/DiscoverScreen.kt index 69818333a..f4f88f57c 100644 --- a/app/src/main/java/com/lastwave/app/ui/discover/DiscoverScreen.kt +++ b/app/src/main/java/com/lastwave/app/ui/discover/DiscoverScreen.kt @@ -144,7 +144,7 @@ fun DiscoverScreen(onBack: () -> Unit = {}, viewModel: DiscoverViewModel = hiltV ) { ExpressiveHeader( title = "Discover", - subtitle = if (state.isYtConnected) "Discover frash tracks" else "Discover frash tracks", + subtitle = "Discover fresh tracks", onBack = onBack, actions = { HeaderActionIcon(Icons.Filled.BookmarkAdd, "Save as playlist", viewModel::saveAsPlaylist) diff --git a/app/src/main/java/com/lastwave/app/ui/discover/DiscoverViewModel.kt b/app/src/main/java/com/lastwave/app/ui/discover/DiscoverViewModel.kt index be8464385..71e425056 100644 --- a/app/src/main/java/com/lastwave/app/ui/discover/DiscoverViewModel.kt +++ b/app/src/main/java/com/lastwave/app/ui/discover/DiscoverViewModel.kt @@ -66,16 +66,22 @@ class DiscoverViewModel @Inject constructor( fun loadInitial() { viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null, endReached = false) } + val cached = repository.allowedCachedFeed() + if (cached.isNotEmpty()) { + _uiState.update { it.copy(isLoading = false, error = null, tracks = cached) } + } else { + _uiState.update { it.copy(isLoading = true, error = null, endReached = false) } + } try { - val cached = repository.allowedCachedFeed() val needed = (INITIAL_BATCH_SIZE - cached.size).coerceAtLeast(0) val batch = if (needed > 0) repository.nextBatch(needed) else emptyList() val feed = repository.getCachedFeed() _uiState.update { it.copy(isLoading = false, tracks = feed) } artworkRepository.enrichBatch((cached + batch).take(INITIAL_BATCH_SIZE).map { it.name to it.artist }) } catch (e: Exception) { - _uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn't load recommendations") } + if (_uiState.value.tracks.isEmpty()) { + _uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn't load recommendations") } + } } } } diff --git a/app/src/main/java/com/lastwave/app/ui/feed/FeedScreen.kt b/app/src/main/java/com/lastwave/app/ui/feed/FeedScreen.kt index 00dde5d09..8184a3b63 100644 --- a/app/src/main/java/com/lastwave/app/ui/feed/FeedScreen.kt +++ b/app/src/main/java/com/lastwave/app/ui/feed/FeedScreen.kt @@ -410,7 +410,14 @@ fun FeedScreen( tracks = state.feedData.quickPicks, currentPlayingVideoId = playbackState.current?.videoId, isPlaying = playbackState.isPlaying, - onTrackClick = { index -> viewModel.playTracksQueue(state.feedData.quickPicks, index, "Quick Picks") }, + onTrackClick = { index -> + val track = state.feedData.quickPicks.getOrNull(index) + if (track != null) { + viewModel.playTrack(track, "Quick Picks") + } else { + viewModel.playTracksQueue(state.feedData.quickPicks, index, "Quick Picks") + } + }, onMenuClick = { menuTrack = it }, onLongClick = { track, index -> miniTrayTrack = TrackMiniTrayData( @@ -420,7 +427,7 @@ fun FeedScreen( artworkUrl = track.artworkUrl, videoId = track.videoId.takeIf(String::isNotBlank), sourceLabel = "Home", - onPlay = { viewModel.playTracksQueue(state.feedData.quickPicks, index, "Quick Picks") }, + onPlay = { viewModel.playTrack(track, "Quick Picks") }, ) }, ) @@ -497,7 +504,7 @@ fun FeedScreen( subtitle = ArtistHelper.primaryArtist(track.artist), artworkUrl = track.artworkUrl, fallbackIcon = Icons.Filled.MusicNote, - onClick = { viewModel.playTracksQueue(section.items, index, section.title) }, + onClick = { viewModel.playTrack(track, section.title) }, onLongClick = { miniTrayTrack = TrackMiniTrayData( title = track.title, @@ -506,10 +513,10 @@ fun FeedScreen( artworkUrl = track.artworkUrl, videoId = track.videoId.takeIf(String::isNotBlank), sourceLabel = "Home", - onPlay = { viewModel.playTracksQueue(section.items, index, section.title) }, + onPlay = { viewModel.playTrack(track, section.title) }, ) }, - onPlayClick = { viewModel.playTracksQueue(section.items, index, section.title) }, + onPlayClick = { viewModel.playTrack(track, section.title) }, ) } }, @@ -554,11 +561,11 @@ fun FeedScreen( artworkUrl = track.artworkUrl, videoId = track.videoId.takeIf(String::isNotBlank), sourceLabel = "Home", - onPlay = { viewModel.playTracksQueue(state.feedData.freshFinds, index, "Fresh Finds") }, + onPlay = { viewModel.playTrack(track, "Fresh Finds") }, ) }, - onClick = { viewModel.playTracksQueue(state.feedData.freshFinds, index, "Fresh Finds") }, - onPlayClick = { viewModel.playTracksQueue(state.feedData.freshFinds, index, "Fresh Finds") }, + onClick = { viewModel.playTrack(track, "Fresh Finds") }, + onPlayClick = { viewModel.playTrack(track, "Fresh Finds") }, ) } }, @@ -802,7 +809,7 @@ fun FeedScreen( ChartTrackCard( rank = index + 1, track = track, - onClick = { viewModel.playTracksQueue(state.feedData.charts, index, "Top Charts") }, + onClick = { viewModel.playTrack(track, "Top Charts") }, onLongClick = { miniTrayTrack = TrackMiniTrayData( title = track.title, @@ -811,7 +818,7 @@ fun FeedScreen( artworkUrl = track.artworkUrl, videoId = track.videoId.takeIf(String::isNotBlank), sourceLabel = "Home", - onPlay = { viewModel.playTracksQueue(state.feedData.charts, index, "Top Charts") }, + onPlay = { viewModel.playTrack(track, "Top Charts") }, ) }, ) diff --git a/app/src/main/java/com/lastwave/app/ui/feed/FeedViewModel.kt b/app/src/main/java/com/lastwave/app/ui/feed/FeedViewModel.kt index f4272d46f..ea1565b56 100644 --- a/app/src/main/java/com/lastwave/app/ui/feed/FeedViewModel.kt +++ b/app/src/main/java/com/lastwave/app/ui/feed/FeedViewModel.kt @@ -83,8 +83,10 @@ class FeedViewModel @Inject constructor( connection to session.username }.distinctUntilChanged().collect { (connection, _) -> feedJob?.cancel() + val cached = repository.getCachedFeed() _uiState.value = FeedUiState( - feedData = FeedData( + isLoading = cached == null, + feedData = cached ?: FeedData( isYtConnected = connection.isConnected, ytAccountName = connection.accountName.takeIf { connection.isConnected }, ), @@ -205,13 +207,19 @@ class FeedViewModel @Inject constructor( shuffled.map { it.toPlayableTrack() }, startIndex = 0, sourceLabel = "$sourceLabel Shuffle", + enableRadioContinuation = true, ) } fun playTracksQueue(tracks: List, startIndex: Int = 0, sourceLabel: String = "Feed") { if (tracks.isEmpty()) return val playable = tracks.map { it.toPlayableTrack() } - musicPlayer.playQueue(playable, startIndex = startIndex.coerceIn(0, playable.lastIndex.coerceAtLeast(0)), sourceLabel = sourceLabel) + musicPlayer.playQueue( + playable, + startIndex = startIndex.coerceIn(0, playable.lastIndex.coerceAtLeast(0)), + sourceLabel = sourceLabel, + enableRadioContinuation = true, + ) } fun playRecentQueue(tracks: List, startIndex: Int = 0) { @@ -225,7 +233,12 @@ class FeedViewModel @Inject constructor( videoId = GeneratedTrack(it.name, it.artist.displayName, it.artworkUrl, it.url).youtubeVideoIdOrNull(), ) } - musicPlayer.playQueue(playable, startIndex = startIndex.coerceIn(0, playable.lastIndex.coerceAtLeast(0)), sourceLabel = "Jump Back In") + musicPlayer.playQueue( + playable, + startIndex = startIndex.coerceIn(0, playable.lastIndex.coerceAtLeast(0)), + sourceLabel = "Jump Back In", + enableRadioContinuation = true, + ) } fun playGeneratedQueue(tracks: List, startIndex: Int = 0, sourceLabel: String = "Heavy Rotation") { @@ -239,7 +252,12 @@ class FeedViewModel @Inject constructor( videoId = it.youtubeVideoIdOrNull(), ) } - musicPlayer.playQueue(playable, startIndex = startIndex.coerceIn(0, playable.lastIndex.coerceAtLeast(0)), sourceLabel = sourceLabel) + musicPlayer.playQueue( + playable, + startIndex = startIndex.coerceIn(0, playable.lastIndex.coerceAtLeast(0)), + sourceLabel = sourceLabel, + enableRadioContinuation = true, + ) } fun playArtistRadio(artist: FeedArtist) {