From 74f935e1f61ea7ccb5939c267feb9d06d9958c93 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 3 Mar 2026 14:31:46 -0500 Subject: [PATCH] WIP visualizer --- .../wholphin/services/MusicService.kt | 54 +++++++++++++++++- .../wholphin/ui/detail/music/BarVisualizer.kt | 56 +++++++++++++++++++ .../ui/detail/music/NowPlayingPage.kt | 17 ++++-- .../ui/detail/music/NowPlayingViewModel.kt | 2 + 4 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/BarVisualizer.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt index d8a43a7a..c8371613 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -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 = _state + val viz = MutableStateFlow(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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/BarVisualizer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/BarVisualizer.kt new file mode 100644 index 00000000..5197146b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/BarVisualizer.kt @@ -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), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt index 8299e662..af77b169 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt index 3febe097..160b8230 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt @@ -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(20) { creationScope = CoroutineScope(Dispatchers.IO)