WIP visualizer

This commit is contained in:
Damontecres 2026-03-03 14:31:46 -05:00
parent 11587c5fcf
commit 74f935e1f6
No known key found for this signature in database
4 changed files with 123 additions and 6 deletions

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import android.media.audiofx.Visualizer
import androidx.annotation.OptIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
@ -43,6 +44,8 @@ import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.abs
import kotlin.math.min
@OptIn(UnstableApi::class)
@Singleton
@ -54,10 +57,12 @@ class MusicService
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val imageUrlService: ImageUrlService,
) {
) : Visualizer.OnDataCaptureListener {
private val _state = MutableStateFlow(MusicServiceState.EMPTY)
val state: StateFlow<MusicServiceState> = _state
val viz = MutableStateFlow<IntArray>(IntArray(0))
val player: Player by lazy {
ExoPlayer
.Builder(context)
@ -71,6 +76,12 @@ class MusicService
}
}
private val visualizer by lazy {
Visualizer(player.audioSessionId).apply {
captureSize = Visualizer.getCaptureSizeRange()[1]
}
}
private val mutex = Mutex()
var mediaSession: MediaSession? = null
private set
@ -81,6 +92,15 @@ class MusicService
if (mediaSession == null) {
Timber.i("Starting music MediaSession")
mediaSession = MediaSession.Builder(context, player).build()
onMain {
visualizer.setDataCaptureListener(
this@MusicService,
Visualizer.getMaxCaptureRate() / 2,
true,
false,
)
visualizer.enabled = true
}
}
}
}
@ -102,6 +122,7 @@ class MusicService
player.stop()
player.setMediaItems(emptyList())
}
visualizer.enabled = false
_state.update {
MusicServiceState.EMPTY
}
@ -287,6 +308,37 @@ class MusicService
_state.update { it.copy(loadingState = LoadingState.Success) }
return result
}
override fun onFftDataCapture(
visualizer: Visualizer,
fft: ByteArray,
samplingRate: Int,
) {
}
override fun onWaveFormDataCapture(
visualizer: Visualizer,
waveform: ByteArray,
samplingRate: Int,
) {
val resolution = 64
val processed = IntArray(resolution)
val captureSize =
Visualizer.getCaptureSizeRange()[1]
val groupSize = captureSize / resolution
// val groupSize = captureSize / resolution.toFloat()
for (i in 0 until resolution) {
processed[i] =
waveform
.map { abs(it.toInt()) }
// .map { it.toInt() }
.subList(i * groupSize, min((i + 1) * groupSize, waveform.size))
.average()
.toInt()
// processed[i] = abs(waveform[ceil(i * finder).toInt()].toInt())
}
viz.update { processed }
}
}
@Stable

View file

@ -0,0 +1,56 @@
package com.github.damontecres.wholphin.ui.detail.music
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
@Composable
fun BoxScope.BarVisualizer(
data: IntArray,
modifier: Modifier,
) {
var size by remember { mutableStateOf(IntSize.Zero) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.onSizeChanged { size = it },
) {
val size = with(LocalDensity.current) { DpSize(size.width.toDp(), size.height.toDp()) }
val padding = 1.dp
val width = size.width / data.size
data.forEachIndexed { index, data ->
val height by animateDpAsState(
targetValue = size.height * data / 128f / 1.5f,
animationSpec = tween(easing = LinearEasing),
)
Box(
Modifier
.height(height)
.width(width)
.padding(start = if (index == 0) 0.dp else padding)
.background(MaterialTheme.colorScheme.border),
// .align(Alignment.Bottom),
)
}
}
}

View file

@ -74,6 +74,7 @@ fun NowPlayingPage(
state.musicServiceState.queueSize,
)
val current = queue.getOrNull(state.musicServiceState.currentIndex)
val viz by viewModel.viz.collectAsState()
val controllerViewState = viewModel.controllerViewState
val preferences =
@ -165,11 +166,17 @@ fun NowPlayingPage(
.fillMaxSize()
.weight(1f),
) {
AsyncImage(
contentDescription = null,
model = current?.imageUrl,
modifier = Modifier.fillMaxSize(.7f),
)
Box(modifier = Modifier.fillMaxSize(.7f)) {
AsyncImage(
contentDescription = null,
model = current?.imageUrl,
modifier = Modifier.fillMaxSize(),
)
BarVisualizer(
data = viz,
modifier = Modifier.fillMaxSize(),
)
}
current?.title?.let {
Text(
text = it,

View file

@ -66,6 +66,8 @@ class NowPlayingViewModel
val state = MutableStateFlow(NowPlayingState(musicService.state.value))
val player get() = musicService.player
val viz = musicService.viz
private val lyricCache =
InMemoryKache<UUID, LyricDto>(20) {
creationScope = CoroutineScope(Dispatchers.IO)