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 package com.github.damontecres.wholphin.services
import android.content.Context import android.content.Context
import android.media.audiofx.Visualizer
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
@ -43,6 +44,8 @@ import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.math.abs
import kotlin.math.min
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@Singleton @Singleton
@ -54,10 +57,12 @@ class MusicService
private val api: ApiClient, private val api: ApiClient,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val imageUrlService: ImageUrlService, private val imageUrlService: ImageUrlService,
) { ) : Visualizer.OnDataCaptureListener {
private val _state = MutableStateFlow(MusicServiceState.EMPTY) private val _state = MutableStateFlow(MusicServiceState.EMPTY)
val state: StateFlow<MusicServiceState> = _state val state: StateFlow<MusicServiceState> = _state
val viz = MutableStateFlow<IntArray>(IntArray(0))
val player: Player by lazy { val player: Player by lazy {
ExoPlayer ExoPlayer
.Builder(context) .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() private val mutex = Mutex()
var mediaSession: MediaSession? = null var mediaSession: MediaSession? = null
private set private set
@ -81,6 +92,15 @@ class MusicService
if (mediaSession == null) { if (mediaSession == null) {
Timber.i("Starting music MediaSession") Timber.i("Starting music MediaSession")
mediaSession = MediaSession.Builder(context, player).build() 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.stop()
player.setMediaItems(emptyList()) player.setMediaItems(emptyList())
} }
visualizer.enabled = false
_state.update { _state.update {
MusicServiceState.EMPTY MusicServiceState.EMPTY
} }
@ -287,6 +308,37 @@ class MusicService
_state.update { it.copy(loadingState = LoadingState.Success) } _state.update { it.copy(loadingState = LoadingState.Success) }
return result 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 @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, state.musicServiceState.queueSize,
) )
val current = queue.getOrNull(state.musicServiceState.currentIndex) val current = queue.getOrNull(state.musicServiceState.currentIndex)
val viz by viewModel.viz.collectAsState()
val controllerViewState = viewModel.controllerViewState val controllerViewState = viewModel.controllerViewState
val preferences = val preferences =
@ -165,11 +166,17 @@ fun NowPlayingPage(
.fillMaxSize() .fillMaxSize()
.weight(1f), .weight(1f),
) { ) {
AsyncImage( Box(modifier = Modifier.fillMaxSize(.7f)) {
contentDescription = null, AsyncImage(
model = current?.imageUrl, contentDescription = null,
modifier = Modifier.fillMaxSize(.7f), model = current?.imageUrl,
) modifier = Modifier.fillMaxSize(),
)
BarVisualizer(
data = viz,
modifier = Modifier.fillMaxSize(),
)
}
current?.title?.let { current?.title?.let {
Text( Text(
text = it, text = it,

View file

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