mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
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:
@@ -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>
|
||||
|
||||
+110
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -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 Cooley–Tukey 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()
|
||||
}
|
||||
}
|
||||
+80
@@ -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)
|
||||
}
|
||||
}
|
||||
+67
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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
|
||||
+41
@@ -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
|
||||
}
|
||||
+37
@@ -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,
|
||||
)
|
||||
}
|
||||
+38
@@ -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
|
||||
}
|
||||
}
|
||||
+128
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -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)
|
||||
}
|
||||
}
|
||||
+93
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -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)
|
||||
}
|
||||
}
|
||||
+115
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -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)
|
||||
}
|
||||
+55
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user