Merge remote-tracking branch 'origin/main' into claude/relay-message-pagination-LMqSQ

# Conflicts:
#	commons/src/commonMain/composeResources/values/strings.xml
This commit is contained in:
Claude
2026-06-08 18:15:56 +00:00
90 changed files with 6017 additions and 244 deletions
@@ -55,7 +55,7 @@
<string name="accessibility_user_avatar">User avatar</string>
<string name="accessibility_navigate">Navigate</string>
<!-- Relay history paging (shared feed markers + status card) -->
<!-- Relay history paging (shared feed markers + status card) -->
<string name="chats_history_relay_sync">Relay sync:</string>
<string name="chats_history_older">Older %1$s messages</string>
<string name="chats_history_all_caught_up">All caught up</string>
@@ -72,4 +72,7 @@
<item quantity="one">%1$d relay</item>
<item quantity="other">%1$d relays</item>
</plurals>
<!-- Notes & Replies -->
<string name="replying_to">replying to </string>
</resources>
@@ -0,0 +1,110 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.audio
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlin.math.exp
import kotlin.math.ln
import kotlin.math.log10
/** One frame of frequency-domain magnitudes, ordered low→high Hz and normalized 0f..1f. */
class Spectrum(
val bins: FloatArray,
)
/**
* Groups a linear-frequency magnitude array (index 0 = DC, last index = Nyquist)
* into [binCount] log-spaced buckets so bass/mid get more bars than the treble
* tail. Output is normalized 0f..1f using [floorDb] as the silence floor.
* Bin 0 (DC) is always excluded; the Nyquist bin (last) is included.
* Low bins are guaranteed to map to distinct FFT bins (contiguous, non-aliased).
*/
fun FloatArray.toLogBins(
binCount: Int,
floorDb: Float = -60f,
): FloatArray {
if (isEmpty() || binCount <= 0) return FloatArray(0)
val out = FloatArray(binCount)
val logSize = ln(size.toDouble())
var lo = 1 // skip DC (index 0)
for (b in 0 until binCount) {
var hi = exp(logSize * (b + 1) / binCount).toInt()
if (hi <= lo) hi = lo + 1 // contiguous & distinct buckets: low bins map to distinct FFT bins, not all to bin 1
if (hi > size) hi = size
var peak = 0f
var i = lo
while (i < hi) {
if (this[i] > peak) peak = this[i]
i++
}
val db = if (peak > 0f) 20f * log10(peak) else floorDb
out[b] = ((db - floorDb) / -floorDb).coerceIn(0f, 1f)
lo = hi
}
return out
}
/**
* Returns a NEW array scaled so the largest value becomes 1f. An all-zero input is returned as a zero-filled copy.
* Reference/allocating variant — production uses the *Into / in-place form on the audio thread.
*/
fun FloatArray.normalizedToPeak(): FloatArray {
var peak = 0f
for (v in this) if (v > peak) peak = v
if (peak <= 0f) return copyOf()
val inv = 1f / peak
return FloatArray(size) { this[it] * inv }
}
/**
* In-place version of [normalizedToPeak]: scales this array so its largest value becomes 1f.
* [fromIndex] excludes leading entries from both the peak search and the scaling (e.g. pass 1 to
* ignore the DC bin of an FFT magnitude array, which downstream log-binning also skips).
*/
fun FloatArray.normalizeToPeakInPlace(fromIndex: Int = 0) {
var peak = 0f
for (i in fromIndex until size) if (this[i] > peak) peak = this[i]
if (peak <= 0f) return
val inv = 1f / peak
for (i in fromIndex until size) this[i] *= inv
}
/**
* Holds back the latest [frames] spectra and emits the one from [frames] hops ago. The PCM tap
* computes each [Spectrum] inside the audio processor chain, upstream of the AudioTrack output
* buffer, so it runs ahead of what's audible by roughly that buffer's depth. Delaying the visual by
* the same number of decoded FFT hops (one [Spectrum] == one hop) realigns it with the speaker, in
* the same decoded-sample units as the lead, so it stays correct across startup and rate jitter.
* [frames] <= 0 is identity (e.g. previews, which carry no output latency).
*/
fun Flow<Spectrum>.delayedByFrames(frames: Int): Flow<Spectrum> =
if (frames <= 0) {
this
} else {
flow {
val queue = ArrayDeque<Spectrum>(frames + 1)
collect { frame ->
queue.addLast(frame)
if (queue.size > frames) emit(queue.removeFirst())
}
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.audio
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import kotlinx.coroutines.flow.Flow
/**
* Renders [spectrum] using whichever [style] the user selected.
* CLASSIC is not a spectrum renderer; passing it renders nothing (OffRenderer fallback). Callers handle CLASSIC separately.
*/
@Composable
fun AudioVisualizer(
style: VisualizerStyle,
spectrum: Flow<Spectrum>,
modifier: Modifier = Modifier,
palette: VisualizerPalette = VisualizerPalette.DEFAULT,
) {
VisualizerRegistry.forStyle(style).Render(spectrum, palette, modifier)
}
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.audio
import kotlin.math.PI
import kotlin.math.cos
object AudioWindow {
/** Hann window of length [n]. */
fun hann(n: Int): FloatArray {
if (n <= 1) return FloatArray(n) { 1f }
return FloatArray(n) { (0.5 - 0.5 * cos(2.0 * PI * it / (n - 1))).toFloat() }
}
/**
* Converts 16-bit PCM [samples] to normalized floats in [-1, 1], multiplies by
* [window], and pads/truncates to `window.size`.
*/
fun shortsToWindowed(
samples: ShortArray,
window: FloatArray,
): FloatArray =
FloatArray(window.size) { i ->
if (i < samples.size) (samples[i] / 32768f) * window[i] else 0f
}
/** Like [shortsToWindowed] but writes into [out] (size must equal [window].size); no allocation. */
fun shortsToWindowedInto(
samples: ShortArray,
window: FloatArray,
out: FloatArray,
) {
require(out.size == window.size) { "out buffer must match window size" }
for (i in window.indices) {
out[i] = if (i < samples.size) (samples[i] / 32768f) * window[i] else 0f
}
}
}
@@ -0,0 +1,121 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.audio
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Minimal in-place iterative radix-2 CooleyTukey FFT (pure Kotlin, no JNI).
* Adapted from Nayuki's public-domain "Free small FFT". Only power-of-2 sizes
* are supported, which is all an audio visualiser needs (256/512/1024).
*/
object Fft {
/** In-place forward transform. [re]/[im] must be the same power-of-2 length. */
fun transform(
re: DoubleArray,
im: DoubleArray,
) {
val n = re.size
require(n != 0 && (n and (n - 1)) == 0) { "FFT size must be a power of 2, was $n" }
var j = 0
for (i in 1 until n) {
var bit = n shr 1
while (j and bit != 0) {
j = j xor bit
bit = bit shr 1
}
j = j or bit
if (i < j) {
val tr = re[i]
re[i] = re[j]
re[j] = tr
val ti = im[i]
im[i] = im[j]
im[j] = ti
}
}
var len = 2
while (len <= n) {
val ang = -2.0 * PI / len
val wLenRe = cos(ang)
val wLenIm = sin(ang)
var i = 0
while (i < n) {
var wRe = 1.0
var wIm = 0.0
val half = len / 2
for (k in 0 until half) {
val aRe = re[i + k]
val aIm = im[i + k]
val bRe = re[i + k + half] * wRe - im[i + k + half] * wIm
val bIm = re[i + k + half] * wIm + im[i + k + half] * wRe
re[i + k] = aRe + bRe
im[i + k] = aIm + bIm
re[i + k + half] = aRe - bRe
im[i + k + half] = aIm - bIm
val nextWRe = wRe * wLenRe - wIm * wLenIm
wIm = wRe * wLenIm + wIm * wLenRe
wRe = nextWRe
}
i += len
}
len = len shl 1
}
}
/**
* Forward-transforms real [signal] and returns magnitudes for bins 0..N/2 (DC..Nyquist).
* Reference/allocating variant — production uses the *Into / in-place form on the audio thread.
*/
fun magnitudes(signal: FloatArray): FloatArray {
val out = FloatArray(signal.size / 2 + 1)
magnitudesInto(signal, DoubleArray(signal.size), DoubleArray(signal.size), out)
return out
}
/**
* Like [magnitudes] but writes into caller-owned buffers (no allocation). [re] and [im] must be
* the same length as [signal] (a power of 2); [out] must be `signal.size / 2 + 1`. Used on the
* audio thread to avoid per-frame garbage.
*/
fun magnitudesInto(
signal: FloatArray,
re: DoubleArray,
im: DoubleArray,
out: FloatArray,
) {
val n = signal.size
require(n != 0 && (n and (n - 1)) == 0) { "FFT size must be a power of 2, was $n" }
require(re.size == n && im.size == n) { "re/im buffers must be size $n" }
require(out.size == n / 2 + 1) { "out buffer must be size ${n / 2 + 1}" }
for (i in 0 until n) {
re[i] = signal[i].toDouble()
im[i] = 0.0
}
transform(re, im)
for (i in out.indices) out[i] = sqrt(re[i] * re[i] + im[i] * im[i]).toFloat()
}
}
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.audio
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameMillis
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.drawscope.DrawScope
import kotlinx.coroutines.flow.Flow
/**
* Collects [spectrum] into a decayed [FloatArray] and optionally runs a monotonic time clock,
* then calls [draw] inside the Canvas draw lambda. The fast-changing state is read
* ONLY in the draw lambda, so new frames trigger the draw phase, never recomposition.
* Pass [animated] = false for non-time-varying styles (bars, radial) to avoid 60fps redraws.
*/
@Composable
fun SpectrumCanvas(
spectrum: Flow<Spectrum>,
palette: VisualizerPalette,
modifier: Modifier,
decay: Float = 0.85f,
animated: Boolean = true,
draw: DrawScope.(bins: FloatArray, timeSec: Float, palette: VisualizerPalette) -> Unit,
) {
val smoothed = remember { mutableStateOf(FloatArray(0)) }
LaunchedEffect(spectrum, decay) {
var prev = FloatArray(0)
spectrum.collect { frame ->
// A fresh array each frame is intentional: mutableStateOf compares by reference, so a new
// instance is what signals Compose to redraw. Do NOT switch to in-place mutation.
val next =
FloatArray(frame.bins.size) { i ->
val prior = if (i < prev.size) prev[i] * decay else 0f
if (frame.bins[i] > prior) frame.bins[i] else prior
}
smoothed.value = next
prev = next
}
}
val timeSec = remember { mutableStateOf(0f) }
if (animated) {
LaunchedEffect(Unit) {
var last = 0L
while (true) {
withFrameMillis { ms ->
if (last != 0L) timeSec.value += (ms - last) / 1000f
last = ms
}
}
}
}
Canvas(modifier) {
draw(smoothed.value, if (animated) timeSec.value else 0f, palette)
}
}
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.audio
import androidx.compose.runtime.withFrameMillis
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlin.math.PI
import kotlin.math.exp
import kotlin.math.max
import kotlin.math.pow
import kotlin.math.sin
/** Deterministic decorative spectrum for previews and demos (no real audio). */
object SyntheticSpectrum {
fun frame(
timeSec: Float,
binCount: Int,
): Spectrum {
val t = timeSec
val bins =
FloatArray(binCount) { i ->
val f = i / binCount.toFloat()
val beat = (0.5 + 0.5 * sin(t * 2.0 * PI * 1.9)).pow(6.0)
val bass = exp((-f * 9.0)) * (0.55 + 0.9 * beat)
val mid = exp(-((f - 0.35) * 4.0).pow(2.0)) * (0.35 + 0.25 * sin(t * 5.0 + i))
val treble = exp(-((f - 0.8) * 3.5).pow(2.0)) * max(0.0, sin(t * 14.0 + i * 2.3)) * 0.4
val v = bass + mid + treble + 0.04 * sin(t * 3.0 + i * 0.7)
v.coerceIn(0.0, 1.0).toFloat()
}
return Spectrum(bins)
}
/**
* Compose-friendly stream that emits one [frame] per display frame.
*
* Must be collected inside a Compose frame-clock scope (e.g. a `LaunchedEffect`); it uses
* `withFrameMillis`, which throws if collected from a plain coroutine with no frame clock.
*/
fun flow(binCount: Int = 48): Flow<Spectrum> =
flow {
var startMs = -1L
while (true) {
val ms = withFrameMillis { it }
if (startMs < 0) startMs = ms
emit(frame((ms - startMs) / 1000f, binCount))
}
}
}
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.audio
import androidx.compose.runtime.Immutable
/**
* Hue anchors (degrees, 0..360) the renderers use to colour the spectrum. Defaults
* give the cyan→magenta / bass-purple / treble-pink look from the design mockups.
*/
@Immutable
data class VisualizerPalette(
val lowHue: Float = 280f,
val midHue: Float = 200f,
val highHue: Float = 330f,
val saturation: Float = 0.92f,
val lightness: Float = 0.6f,
) {
init {
require(saturation in 0f..1f) { "saturation must be in 0f..1f, was $saturation" }
require(lightness in 0f..1f) { "lightness must be in 0f..1f, was $lightness" }
}
companion object {
val DEFAULT = VisualizerPalette()
}
}
/** Wraps a hue into 0f..360f (handles negatives), for Color.hsl. */
fun Float.wrapHue(): Float = ((this % 360f) + 360f) % 360f
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.audio
import com.vitorpamplona.amethyst.commons.audio.renderers.AuroraRenderer
import com.vitorpamplona.amethyst.commons.audio.renderers.BarsRenderer
import com.vitorpamplona.amethyst.commons.audio.renderers.OffRenderer
import com.vitorpamplona.amethyst.commons.audio.renderers.RadialRenderer
import com.vitorpamplona.amethyst.commons.audio.renderers.StaticRenderer
import com.vitorpamplona.amethyst.commons.audio.renderers.WavesRenderer
/**
* CLASSIC is intentionally absent — it requires the live player and is handled by the caller;
* forStyle(CLASSIC) falls back to OffRenderer.
*/
object VisualizerRegistry {
val all: List<VisualizerRenderer> =
listOf(OffRenderer, BarsRenderer, WavesRenderer, RadialRenderer, AuroraRenderer, StaticRenderer)
private val byStyle = all.associateBy { it.style }
fun forStyle(style: VisualizerStyle): VisualizerRenderer = byStyle[style] ?: OffRenderer
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.audio
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import kotlinx.coroutines.flow.Flow
/** A single visualiser style. Implementations draw [spectrum] frames onto a Canvas. */
interface VisualizerRenderer {
val style: VisualizerStyle
@Composable
fun Render(
spectrum: Flow<Spectrum>,
palette: VisualizerPalette,
modifier: Modifier,
)
}
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.audio
enum class VisualizerStyle {
CLASSIC,
BARS,
WAVES,
RADIAL,
AURORA,
STATIC,
OFF,
;
companion object {
val DEFAULT = CLASSIC
fun fromName(name: String?): VisualizerStyle = entries.firstOrNull { it.name == name } ?: DEFAULT
}
}
@@ -0,0 +1,128 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.audio.renderers
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import com.vitorpamplona.amethyst.commons.audio.Spectrum
import com.vitorpamplona.amethyst.commons.audio.SpectrumCanvas
import com.vitorpamplona.amethyst.commons.audio.VisualizerPalette
import com.vitorpamplona.amethyst.commons.audio.VisualizerRenderer
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.audio.wrapHue
import kotlinx.coroutines.flow.Flow
import kotlin.math.sin
/**
* Three overlapping glowing ribbons. Each ribbon is a stroked line whose vertical displacement from
* its centre is the live spectrum of its band (interpolated across the width), so the ribbon's shape
* tracks the audio; the glow thickness swells with the overall energy. A small time shimmer (±8%)
* adds life without driving the motion. The line is clamped inside the canvas (accounting for the
* stroke width) so the tops/bottoms never get cut.
*/
object AuroraRenderer : VisualizerRenderer {
override val style = VisualizerStyle.AURORA
private class Ribbon(
val hue: (VisualizerPalette) -> Float,
val yo: Float,
val lo: Float,
val hi: Float,
val dir: Float,
)
private val ribbons =
listOf(
Ribbon({ it.midHue - 40f }, 0.55f, 0f, 0.5f, -1f),
Ribbon({ it.lowHue }, 0.5f, 0.15f, 0.85f, 1f),
Ribbon({ it.highHue }, 0.45f, 0.5f, 1f, -1f),
)
@Composable
override fun Render(
spectrum: Flow<Spectrum>,
palette: VisualizerPalette,
modifier: Modifier,
) {
val paths = remember { List(ribbons.size) { Path() } }
// Each ribbon's gradient depends only on the palette (the animation lives in the Path
// geometry), so build the brushes once instead of allocating three Colors + a Brush per
// ribbon on every frame.
val brushes =
remember(palette) {
ribbons.map { ribbon ->
val hue = ribbon.hue(palette).wrapHue()
Brush.horizontalGradient(
0f to Color.hsl(hue, palette.saturation, palette.lightness, 0f),
0.5f to Color.hsl(hue, palette.saturation, palette.lightness, 0.6f),
1f to Color.hsl(hue, palette.saturation, palette.lightness, 0f),
)
}
}
SpectrumCanvas(spectrum, palette, modifier) { bins, t, _ ->
if (bins.isEmpty()) return@SpectrumCanvas
val n = bins.size
val w = size.width
val h = size.height
// overall energy → glow thickness swells with loudness (bounded)
var energy = 0f
for (b in bins) energy += b
energy /= bins.size
val strokeW = 12f + energy * 30f
val half = strokeW / 2f
ribbons.forEachIndexed { index, r ->
val path = paths[index]
path.reset()
var x = 0f
while (x <= w) {
val f = x / w
// interpolate this ribbon's band → the displacement IS the spectrum
val fb = (r.lo + (r.hi - r.lo) * f) * (n - 1)
val i0 = fb.toInt().coerceIn(0, n - 1)
val i1 = (i0 + 1).coerceAtMost(n - 1)
val v = bins[i0] + (bins[i1] - bins[i0]) * (fb - i0)
val shimmer = 1f + 0.08f * sin(f * 9f + t)
val disp = (v * shimmer).coerceIn(0f, 1f) * h * 0.40f
// maxOf guards the reversed-range crash when the canvas is shorter than the
// loudness-swelled stroke (h < strokeW makes half > h - half).
val y = (h * r.yo + r.dir * disp).coerceIn(half, maxOf(half, h - half))
if (x == 0f) path.moveTo(x, y) else path.lineTo(x, y)
x += 5f
}
drawPath(
path = path,
brush = brushes[index],
style = Stroke(width = strokeW, cap = StrokeCap.Round),
blendMode = BlendMode.Plus,
)
}
}
}
}
@@ -0,0 +1,69 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.audio.renderers
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.commons.audio.Spectrum
import com.vitorpamplona.amethyst.commons.audio.SpectrumCanvas
import com.vitorpamplona.amethyst.commons.audio.VisualizerPalette
import com.vitorpamplona.amethyst.commons.audio.VisualizerRenderer
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.audio.wrapHue
import kotlinx.coroutines.flow.Flow
import kotlin.math.min
object BarsRenderer : VisualizerRenderer {
override val style = VisualizerStyle.BARS
@Composable
override fun Render(
spectrum: Flow<Spectrum>,
palette: VisualizerPalette,
modifier: Modifier,
) {
SpectrumCanvas(spectrum, palette, modifier, animated = false) { bins, _, pal ->
if (bins.isEmpty()) return@SpectrumCanvas
val n = bins.size
val gap = 2f
val barW = (size.width - gap * (n - 1)) / n
if (barW <= 0f) return@SpectrumCanvas
val mid = size.height / 2f
val radius = CornerRadius(min(barW / 2f, 3f), min(barW / 2f, 3f))
for (i in 0 until n) {
val v = bins[i].coerceIn(0f, 1f)
val half = v * (size.height / 2f - 2f)
if (half <= 0f) continue
val hue = pal.midHue + (pal.highHue - pal.midHue) * (i / n.toFloat())
drawRoundRect(
color = Color.hsl(hue.wrapHue(), pal.saturation, pal.lightness),
topLeft = Offset(i * (barW + gap), mid - half),
size = Size(barW, half * 2f),
cornerRadius = radius,
)
}
}
}
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.audio.renderers
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.commons.audio.Spectrum
import com.vitorpamplona.amethyst.commons.audio.VisualizerPalette
import com.vitorpamplona.amethyst.commons.audio.VisualizerRenderer
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import kotlinx.coroutines.flow.Flow
object OffRenderer : VisualizerRenderer {
override val style = VisualizerStyle.OFF
@Composable
override fun Render(
spectrum: Flow<Spectrum>,
palette: VisualizerPalette,
modifier: Modifier,
) {
// Draws nothing, but honors the modifier so the reserved layout region doesn't collapse.
Box(modifier)
}
}
@@ -0,0 +1,93 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.audio.renderers
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import com.vitorpamplona.amethyst.commons.audio.Spectrum
import com.vitorpamplona.amethyst.commons.audio.SpectrumCanvas
import com.vitorpamplona.amethyst.commons.audio.VisualizerPalette
import com.vitorpamplona.amethyst.commons.audio.VisualizerRenderer
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.audio.wrapHue
import kotlinx.coroutines.flow.Flow
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
object RadialRenderer : VisualizerRenderer {
override val style = VisualizerStyle.RADIAL
@Composable
override fun Render(
spectrum: Flow<Spectrum>,
palette: VisualizerPalette,
modifier: Modifier,
) {
SpectrumCanvas(spectrum, palette, modifier, animated = false) { bins, _, pal ->
if (bins.isEmpty()) return@SpectrumCanvas
val n = bins.size
val cx = size.width / 2f
val cy = size.height / 2f
val minDim = min(size.width, size.height)
// A zero/near-zero canvas (transient layout pass while audio plays) would make coreR == 0,
// and Brush.radialGradient(radius = 0f) throws. Nothing meaningful to draw at that size.
if (minDim <= 0f) return@SpectrumCanvas
val r0 = minDim * 0.16f
var energy = 0f
val lowCount = min(8, n)
for (i in 0 until lowCount) energy += bins[i]
energy /= lowCount
val coreR = r0 * (1.4f + energy)
drawCircle(
brush =
Brush.radialGradient(
0f to Color.hsl(pal.highHue, pal.saturation, 0.7f, 0.9f),
1f to Color.hsl(pal.highHue, pal.saturation, 0.6f, 0f),
center = Offset(cx, cy),
radius = coreR,
),
radius = coreR,
center = Offset(cx, cy),
)
for (i in 0 until n) {
val a = i / n.toFloat() * (2f * PI.toFloat()) - PI.toFloat() / 2f
val len = r0 + bins[i].coerceIn(0f, 1f) * minDim * 0.32f
val hue = (pal.midHue + (pal.highHue - pal.midHue) * (i / n.toFloat())).wrapHue()
drawLine(
color = Color.hsl(hue, pal.saturation, pal.lightness),
start = Offset(cx + cos(a) * r0, cy + sin(a) * r0),
end = Offset(cx + cos(a) * len, cy + sin(a) * len),
strokeWidth = 2.4f,
cap = StrokeCap.Round,
)
}
}
}
}
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.audio.renderers
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.commons.audio.Spectrum
import com.vitorpamplona.amethyst.commons.audio.SyntheticSpectrum
import com.vitorpamplona.amethyst.commons.audio.VisualizerPalette
import com.vitorpamplona.amethyst.commons.audio.VisualizerRenderer
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
/**
* A still, non-animated bar graphic for users who dislike motion. Ignores the live [spectrum]
* and renders a single frozen frame via [BarsRenderer] (which runs its canvas with animated=false,
* so there is no per-frame redraw once settled).
*/
object StaticRenderer : VisualizerRenderer {
override val style = VisualizerStyle.STATIC
@Composable
override fun Render(
spectrum: Flow<Spectrum>,
palette: VisualizerPalette,
modifier: Modifier,
) {
val frozen = remember { flowOf(SyntheticSpectrum.frame(0f, 48)) }
BarsRenderer.Render(frozen, palette, modifier)
}
}
@@ -0,0 +1,115 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.audio.renderers
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import com.vitorpamplona.amethyst.commons.audio.Spectrum
import com.vitorpamplona.amethyst.commons.audio.SpectrumCanvas
import com.vitorpamplona.amethyst.commons.audio.VisualizerPalette
import com.vitorpamplona.amethyst.commons.audio.VisualizerRenderer
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.audio.wrapHue
import kotlinx.coroutines.flow.Flow
import kotlin.math.sin
/**
* Three translucent, overlapping filled waves. Each wave's OUTLINE is the live spectrum of its
* frequency band (low / mid / high), interpolated across the width, so the shape tracks the audio
* directly. A small time shimmer (±6%) adds life without driving the motion, and the height is
* clamped to 0.92·h so the fill never reaches or leaves the top edge.
*/
object WavesRenderer : VisualizerRenderer {
override val style = VisualizerStyle.WAVES
private class Layer(
val lo: Float,
val hi: Float,
val hue: (VisualizerPalette) -> Float,
val amp: Float,
val phase: Float,
)
private val layers =
listOf(
Layer(0f, 0.4f, { it.lowHue }, 1.0f, 0f),
Layer(0.2f, 0.75f, { it.midHue }, 0.9f, 1.5f),
Layer(0.5f, 1f, { it.highHue }, 0.8f, 3f),
)
@Composable
override fun Render(
spectrum: Flow<Spectrum>,
palette: VisualizerPalette,
modifier: Modifier,
) {
val paths = remember { List(layers.size) { Path() } }
// Each layer's gradient depends only on the palette (the animation lives in the Path
// geometry), so build the brushes once instead of allocating two Colors + a Brush per layer
// on every frame.
val brushes =
remember(palette) {
layers.map { layer ->
val hue = layer.hue(palette).wrapHue()
Brush.verticalGradient(
0f to Color.hsl(hue, palette.saturation, palette.lightness, 0.85f),
1f to Color.hsl(hue, palette.saturation, palette.lightness * 0.8f, 0.05f),
)
}
}
SpectrumCanvas(spectrum, palette, modifier) { bins, t, _ ->
if (bins.isEmpty()) return@SpectrumCanvas
val n = bins.size
val w = size.width
val h = size.height
layers.forEachIndexed { index, layer ->
val path = paths[index]
path.reset()
path.moveTo(0f, h)
var x = 0f
while (x <= w) {
val f = x / w
// interpolate the spectrum within this layer's band → smooth, audio-driven outline
val fb = (layer.lo + (layer.hi - layer.lo) * f) * (n - 1)
val i0 = fb.toInt().coerceIn(0, n - 1)
val i1 = (i0 + 1).coerceAtMost(n - 1)
val v = (bins[i0] + (bins[i1] - bins[i0]) * (fb - i0)) * layer.amp
val shimmer = 1f + 0.06f * sin(f * 12f + layer.phase + t * 1.5f)
val height = (v * shimmer).coerceIn(0f, 1f) * h * 0.92f
path.lineTo(x, h - height)
x += 4f
}
path.lineTo(w, h)
path.close()
drawPath(
path = path,
brush = brushes[index],
blendMode = BlendMode.Plus,
)
}
}
}
}
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.feeds
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip18Reposts.BaseRepostEvent
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
/**
* A repost (kind 6 / kind 16) wraps another event. When that inner kind has no
* typed Quartz class — e.g. a Ditto kind-16767 profile theme wrapped in a
* generic repost — Amethyst can neither parse nor render it, so the repost would
* show as a permanently blank card. Feeds use this predicate in their acceptance
* allow-list to drop such reposts, mirroring how regular unknown-kind events are
* never displayed (no UI component renders a bare [Event]).
*
* Returns true only for reposts whose boosted content is displayable, so it can
* replace the `is RepostEvent || is GenericRepostEvent` clause in a feed's
* acceptance allow-list. Non-reposts return false (they are admitted by the
* other clauses).
*
* Conservative: a repost that declares no boosted `k` kind is assumed renderable
* — we only hide when we can positively prove the inner kind is unknown.
*
* The `returns(true) implies non-null` contract lets it stand in for the two
* `is` checks in an allow-list without losing the chain's non-null smart-cast
* (the `&& filterParams.match(noteEvent, …)` tail relies on it).
*/
@OptIn(ExperimentalContracts::class)
fun Event?.isRenderableRepost(): Boolean {
contract { returns(true) implies (this@isRenderableRepost != null) }
if (this !is BaseRepostEvent) return false
val boostedKind = boostedKind()
return boostedKind == null || EventFactory.isKnownKind(boostedKind)
}
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.note
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
data class ReplyContext(
val parentNoteId: String?,
val parentAuthorPubKey: String,
val parentAuthorDisplay: String,
) {
companion object {
fun from(
event: BaseThreadedEvent,
cache: ICacheProvider?,
): ReplyContext? {
val raw = event.replyingToAddressOrEvent() ?: return null
val isAddressable = raw.contains(":")
val parentNoteId = if (isAddressable) null else raw
val parentAuthorPubKey =
when (event) {
is CommentEvent -> event.replyAuthor()?.pubKey
else -> null
} ?: parentNoteId?.let { cache?.getNoteIfExists(it)?.author?.pubkeyHex }
?: return null
val parentAuthorDisplay =
cache?.getUserIfExists(parentAuthorPubKey)?.toBestDisplayName()
?: (parentAuthorPubKey.take(8) + "")
return ReplyContext(parentNoteId, parentAuthorPubKey, parentAuthorDisplay)
}
}
}
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.replying_to
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ReplyToLabel(
parentAuthorDisplay: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
FlowRow(modifier = modifier) {
Text(
text = stringResource(Res.string.replying_to),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "@$parentAuthorDisplay",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable(onClick = onClick),
)
}
}