Improvements

This commit is contained in:
Adrian Kuta (DZCQIWG)
2025-10-03 08:51:58 +02:00
parent 6410477f54
commit 4bc3a0a096
14 changed files with 434 additions and 336 deletions

View File

@@ -1,160 +0,0 @@
package dev.adriankuta.visualizer
import android.media.audiofx.Visualizer
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.State
import androidx.compose.ui.graphics.Color
import dev.adriankuta.visualizer.domain.SoundToColorMapper
import dev.adriankuta.visualizer.domain.SoundToColorMapperFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import timber.log.Timber
/**
* Interface for audio visualization functionality.
* Follows Interface Segregation Principle.
*/
interface AudioVisualizerService {
val currentColor: State<Color>
val isActive: State<Boolean>
fun start()
fun stop()
fun release()
}
/**
* Implementation of AudioVisualizerService using Android's Visualizer API.
*
* Follows Single Responsibility Principle by focusing on audio visualization.
* Uses Dependency Injection for the sound-to-color mapper.
*/
class AndroidAudioVisualizer(
private val soundToColorMapper: SoundToColorMapper = SoundToColorMapperFactory.createDefault(),
private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Main)
) : AudioVisualizerService {
companion object {
private const val SAMPLING_RATE = 16000 // Hz
private const val CAPTURE_SIZE = 1024 // Window size for audio capture
}
private var visualizer: Visualizer? = null
private var processingJob: Job? = null
private val _currentColor = mutableStateOf(Color.Black)
override val currentColor: State<Color> = _currentColor
private val _isActive = mutableStateOf(false)
override val isActive: State<Boolean> = _isActive
override fun start() {
try {
// Initialize visualizer with audio session ID 0 (output mix)
visualizer = Visualizer(0).apply {
captureSize = CAPTURE_SIZE
// Set up waveform data listener
setDataCaptureListener(
object : Visualizer.OnDataCaptureListener {
override fun onWaveFormDataCapture(
visualizer: Visualizer?,
waveform: ByteArray?,
samplingRate: Int
) {
waveform?.let { processWaveform(it) }
}
override fun onFftDataCapture(
visualizer: Visualizer?,
fft: ByteArray?,
samplingRate: Int
) {
// Not used in this implementation
}
},
Visualizer.getMaxCaptureRate() / 2, // Capture rate
true, // Waveform
false // FFT
)
enabled = true
}
_isActive.value = true
Timber.d("AudioVisualizer started successfully")
} catch (e: Exception) {
Timber.e(e, "Failed to start AudioVisualizer")
_isActive.value = false
}
}
override fun stop() {
try {
visualizer?.enabled = false
processingJob?.cancel()
_isActive.value = false
Timber.d("AudioVisualizer stopped")
} catch (e: Exception) {
Timber.e(e, "Error stopping AudioVisualizer")
}
}
override fun release() {
stop()
try {
visualizer?.release()
visualizer = null
soundToColorMapper.reset()
Timber.d("AudioVisualizer released")
} catch (e: Exception) {
Timber.e(e, "Error releasing AudioVisualizer")
}
}
private fun processWaveform(waveform: ByteArray) {
processingJob?.cancel()
processingJob = coroutineScope.launch(Dispatchers.Default) {
try {
val color = soundToColorMapper.mapSoundToColor(waveform)
// Update UI on main thread
launch(Dispatchers.Main) {
_currentColor.value = color
}
} catch (e: Exception) {
Timber.e(e, "Error processing waveform")
}
}
}
}
/**
* Factory for creating AudioVisualizerService instances.
* Follows Factory Pattern and makes testing easier.
*/
object AudioVisualizerFactory {
/**
* Creates a default Android audio visualizer.
*/
fun createDefault(coroutineScope: CoroutineScope): AudioVisualizerService {
return AndroidAudioVisualizer(
soundToColorMapper = SoundToColorMapperFactory.createDefault(),
coroutineScope = coroutineScope
)
}
/**
* Creates an Android audio visualizer with custom mapper.
*/
fun create(
soundToColorMapper: SoundToColorMapper,
coroutineScope: CoroutineScope
): AudioVisualizerService {
return AndroidAudioVisualizer(soundToColorMapper, coroutineScope)
}
}

View File

@@ -3,7 +3,6 @@ package dev.adriankuta.visualizer.data
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface AudioVisualizer: AudioVisualizerController { interface AudioVisualizer: AudioVisualizerController {
fun waveform(): Flow<WaveformFrame>
fun fft(): Flow<FftFrame> fun visualizerFlow(): Flow<VisualizerFrame>
} }

View File

@@ -1,11 +1,12 @@
package dev.adriankuta.visualizer.data package dev.adriankuta.visualizer.data
import android.media.audiofx.Visualizer import android.media.audiofx.Visualizer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flowOn
import javax.inject.Inject import javax.inject.Inject
class AudioVisualizerImpl @Inject constructor( class AudioVisualizerImpl @Inject constructor(
@@ -13,22 +14,14 @@ class AudioVisualizerImpl @Inject constructor(
) : AudioVisualizer { ) : AudioVisualizer {
override fun start() { override fun start() {
visualizer.enabled = true // visualizer.enabled = true
} }
override fun stop() { override fun stop() {
visualizer.enabled = false visualizer.enabled = false
} }
override fun waveform(): Flow<WaveformFrame> = override fun visualizerFlow(): Flow<VisualizerFrame> = callbackFlow {
visualizerFlow()
.filterIsInstance(WaveformFrame::class)
override fun fft(): Flow<FftFrame> =
visualizerFlow()
.filterIsInstance(FftFrame::class)
private fun visualizerFlow(): Flow<VisualizerFrame> = callbackFlow {
val listener = object : Visualizer.OnDataCaptureListener { val listener = object : Visualizer.OnDataCaptureListener {
override fun onFftDataCapture( override fun onFftDataCapture(
visualizer: Visualizer?, visualizer: Visualizer?,
@@ -47,13 +40,15 @@ class AudioVisualizerImpl @Inject constructor(
} }
} }
visualizer.setDataCaptureListener( val result = visualizer.setDataCaptureListener(
listener, listener,
Visualizer.getMaxCaptureRate(), Visualizer.getMaxCaptureRate(),
true, true,
true true
) )
visualizer.enabled = true
awaitClose { awaitClose {
runCatching { runCatching {
visualizer.enabled = false visualizer.enabled = false
@@ -63,13 +58,17 @@ class AudioVisualizerImpl @Inject constructor(
} }
} }
.conflate() .conflate()
.flowOn(Dispatchers.Default)
}
sealed interface VisualizerFrame {
val data: ByteArray
} }
sealed interface VisualizerFrame
data class WaveformFrame( data class WaveformFrame(
val data: ByteArray, override val data: ByteArray,
) : VisualizerFrame ) : VisualizerFrame
data class FftFrame( data class FftFrame(
val data: ByteArray, override val data: ByteArray,
) : VisualizerFrame ) : VisualizerFrame

View File

@@ -9,6 +9,9 @@ import dagger.hilt.components.SingletonComponent
import dev.adriankuta.visualizer.data.AudioVisualizer import dev.adriankuta.visualizer.data.AudioVisualizer
import dev.adriankuta.visualizer.data.AudioVisualizerController import dev.adriankuta.visualizer.data.AudioVisualizerController
import dev.adriankuta.visualizer.data.AudioVisualizerImpl import dev.adriankuta.visualizer.data.AudioVisualizerImpl
import dev.adriankuta.visualizer.domain.processors.FftToColor
import dev.adriankuta.visualizer.domain.processors.HSLSoundToColorMapper
import dev.adriankuta.visualizer.domain.processors.VisualizerProcessor
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@@ -25,6 +28,11 @@ abstract class VisualizerModule {
audioVisualizerImpl: AudioVisualizerImpl audioVisualizerImpl: AudioVisualizerImpl
): AudioVisualizerController ): AudioVisualizerController
/*@Binds
abstract fun bindFftProcessor(
fftToColor: WaveformToColorProcessor
): VisualizerProcessor*/
companion object { companion object {
@Provides @Provides
@Singleton @Singleton
@@ -33,5 +41,11 @@ abstract class VisualizerModule {
captureSize = 1024 captureSize = 1024
} }
} }
@Provides
@Singleton
fun provideProcessor(): VisualizerProcessor {
return HSLSoundToColorMapper()
}
} }
} }

View File

@@ -2,22 +2,28 @@ package dev.adriankuta.visualizer.domain
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import dev.adriankuta.visualizer.data.AudioVisualizer import dev.adriankuta.visualizer.data.AudioVisualizer
import dev.adriankuta.visualizer.data.FftFrame
import dev.adriankuta.visualizer.data.VisualizerFrame
import dev.adriankuta.visualizer.data.WaveformFrame import dev.adriankuta.visualizer.data.WaveformFrame
import dev.adriankuta.visualizer.domain.processors.VisualizerProcessor
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import javax.inject.Inject import javax.inject.Inject
class ObserveVisualizerColorUseCase @Inject constructor( class ObserveVisualizerColorUseCase @Inject constructor(
private val audioVisualizer: AudioVisualizer private val audioVisualizer: AudioVisualizer,
private val processor: VisualizerProcessor,
) { ) {
private val colorMapper = HSLSoundToColorMapper()
operator fun invoke(): Flow<Color> = operator fun invoke(): Flow<Color> =
audioVisualizer.waveform() audioVisualizer.visualizerFlow()
.map { processColor(it) } .process(processor)
.onStart { emit(Color.Black) }
private fun processColor(waveformFrame: WaveformFrame): Color {
return colorMapper.mapSoundToColor(waveformFrame.data)
}
} }
fun Flow<VisualizerFrame>.process(processor: VisualizerProcessor) =
this.filterIsInstance(WaveformFrame::class)
.map(processor::invoke)

View File

@@ -1,97 +0,0 @@
package dev.adriankuta.visualizer.domain
import androidx.compose.ui.graphics.Color
import dev.adriankuta.visualizer.HSL
import dev.adriankuta.visualizer.MovingAverage
import dev.adriankuta.visualizer.calculateRms
/**
* Interface for mapping sound data to colors.
* Follows the Interface Segregation Principle by defining a focused contract.
*/
interface SoundToColorMapper {
/**
* Maps audio waveform data to a color.
*
* @param waveform Audio waveform data as byte array
* @return Compose Color representing the mapped sound
*/
fun mapSoundToColor(waveform: ByteArray): Color
/**
* Resets the internal state of the mapper.
*/
fun reset()
}
/**
* Default implementation of SoundToColorMapper that uses HSL color space
* and moving average for smooth color transitions.
*
* Follows Single Responsibility Principle by focusing on sound-to-color mapping.
* Follows Dependency Inversion Principle by depending on abstractions.
*/
class HSLSoundToColorMapper(
private val baseHue: Float = 0f, // Blue hue as default
private val saturation: Float = 1f,
private val movingAverageWindowSize: Int = 1
) : SoundToColorMapper {
private val movingAverage = MovingAverage(movingAverageWindowSize)
private var rmsMax = 0.0
override fun mapSoundToColor(waveform: ByteArray): Color {
// Calculate RMS value for current frame
val currentRms = calculateRms(waveform)
// Update maximum RMS value for normalization
if (currentRms > rmsMax) {
rmsMax = currentRms
}
// Calculate energy ratio (0-1)
val energyRatio = if (rmsMax > 0) currentRms / rmsMax else 0.0
// Apply moving average for smooth transitions
val adjustedLightness = movingAverage.process(energyRatio)
// Create HSL color and convert to Compose Color
val hslColor = HSL(
hue = baseHue,
saturation = saturation,
lightness = adjustedLightness.toFloat().coerceIn(0f, 1f)
)
return hslColor.toComposeColor()
}
override fun reset() {
movingAverage.reset()
rmsMax = 0.0
}
}
/**
* Factory for creating SoundToColorMapper instances.
* Follows the Factory Pattern and Open/Closed Principle.
*/
object SoundToColorMapperFactory {
/**
* Creates a default HSL-based sound to color mapper.
*/
fun createDefault(): SoundToColorMapper = HSLSoundToColorMapper()
/**
* Creates an HSL-based sound to color mapper with custom parameters.
*
* @param baseHue The base hue for the color (0-360)
* @param saturation The saturation level (0-1)
* @param movingAverageWindowSize Window size for moving average smoothing
*/
fun createHSLMapper(
baseHue: Float = 240f,
saturation: Float = 1f,
movingAverageWindowSize: Int = 7
): SoundToColorMapper = HSLSoundToColorMapper(baseHue, saturation, movingAverageWindowSize)
}

View File

@@ -0,0 +1,142 @@
package dev.adriankuta.visualizer.domain.processors
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.colorspace.ColorSpaces
import dev.adriankuta.visualizer.data.FftFrame
import dev.adriankuta.visualizer.data.VisualizerFrame
import dev.adriankuta.visualizer.data.WaveformFrame
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.math.ln
import kotlin.math.max
import kotlin.math.sqrt
import kotlin.reflect.KClass
/**
* Shared utilities for brightness mapping.
*/
class BrightnessMapper(
private val hue: Float = 210f, // blue-ish
private val saturation: Float = 0.9f, // vivid color
private val gain: Float = 8f, // log mapping strength (higher -> more sensitive)
private val attack: Float = 0.35f, // 0..1, higher = faster rise
private val release: Float = 0.08f, // 0..1, lower = slower fall
private val peakDecay: Float = 0.995f // 0..1, how fast auto-gain reference decays
) {
private var smoothed = 0f
private var peak = 0.1f
/**
* @param rawEnergy input in [0, +inf) (e.g., RMS or avg magnitude)
* @return Color with brightness (V) in [0,1]
*/
fun toColor(rawEnergy: Float): Color {
// 1) Log loudness mapping: map [0, +inf) → [0, 1]
// normalizedLog = ln(1 + gain * x) / ln(1 + gain)
val loud = if (rawEnergy <= 0f) 0f
else (ln(1f + gain * rawEnergy) / ln(1f + gain))
// 2) Peak/auto-gain tracking
peak = max(loud, peak * peakDecay)
val norm = if (peak > 1e-6f) loud / peak else loud
val clamped = norm.coerceIn(0f, 1f)
// 3) Attack/Release smoothing
val a = if (clamped > smoothed) attack else release
smoothed = smoothed + a * (clamped - smoothed)
// 4) HSV → Color (fixed hue & saturation; dynamic value)
return Color.hsv(
hue = hue.coerceIn(0f, 360f),
saturation = saturation.coerceIn(0f, 1f),
value = smoothed.coerceIn(0f, 1f),
colorSpace = ColorSpaces.Srgb
)
}
}
/**
* Visualizer waveform → brightness.
* Visualizer's waveform bytes are unsigned 8-bit: 0..255 with 128 ≈ 0.
*/
class WaveformToColorProcessor @Inject constructor(
) : VisualizerProcessor {
private val brightness: BrightnessMapper = BrightnessMapper(
hue = 210f,
saturation = 0.9f,
gain = 8f,
attack = 0.35f,
release = 0.08f,
peakDecay = 0.995f
)
override suspend fun invoke(frame: VisualizerFrame): Color = withContext(Dispatchers.Default) {
val bytes = frame.data
if (bytes.isEmpty()) {
return@withContext Color.Black
}
// Convert to [-1, 1] floats, compute RMS.
var sumSq = 0.0
val inv = 1f / 128f
for (b in bytes) {
// unsigned -> center at 128 -> normalize
val s = ((b.toInt() and 0xFF) - 128) * inv
sumSq += (s * s)
}
val rms = sqrt(sumSq / bytes.size).toFloat() // 0..~1
brightness.toColor(rms)
}
}
/**
* Visualizer FFT → brightness.
* Visualizer's FFT bytes are 8-bit signed fixed-point: interleaved (re, im).
* We compute mean magnitude over bins. You can bias ranges by adding weights.
*/
class FftToColorProcessor @Inject constructor(
) : VisualizerProcessor {
private val brightness: BrightnessMapper = BrightnessMapper(
hue = 210f,
saturation = 0.9f,
gain = 10f,
attack = 0.30f,
release = 0.10f,
peakDecay = 0.996f
)
private val binWeighting: ((binIndex: Int, totalBins: Int) -> Float)? = null
override suspend fun invoke(frame: VisualizerFrame): Color = withContext(Dispatchers.Default) {
val bytes = frame.data
if (bytes.size < 2) {
return@withContext Color.Black
}
// Each bin: (re, im) as signed bytes. Skip DC/nyquist if you like; here we include all valid pairs.
val pairCount = bytes.size / 2
var sumMag = 0.0
var weightSum = 0.0
var bin = 0
var i = 0
while (i + 1 < bytes.size) {
val re = bytes[i].toInt().toByte().toInt() // ensure sign
val im = bytes[i + 1].toInt().toByte().toInt()
val mag = sqrt((re * re + im * im).toDouble()) // magnitude in byte units
val w = binWeighting?.invoke(bin, pairCount)?.coerceAtLeast(0f)?.toDouble() ?: 1.0
sumMag += mag * w
weightSum += w
bin += 1
i += 2
}
// Average magnitude → normalize to something close to [0,1].
// 8-bit range ≈ [-128,127], so typical magnitude can be up to ~181; average is far lower.
val avgMag = if (weightSum > 0.0) sumMag / weightSum else 0.0
val norm = (avgMag / 128.0).toFloat().coerceAtLeast(0f) // rough normalization
brightness.toColor(norm)
}
}

View File

@@ -0,0 +1,58 @@
package dev.adriankuta.visualizer.domain.processors
import androidx.compose.ui.graphics.Color
import dev.adriankuta.visualizer.data.FftFrame
import dev.adriankuta.visualizer.data.VisualizerFrame
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.math.sqrt
import kotlin.reflect.KClass
class FftToColor @Inject constructor() : VisualizerProcessor {
override suspend fun invoke(frame: VisualizerFrame): Color = withContext(Dispatchers.Default) {
// --- Extract raw FFT bytes from your frame ---
// Adjust the property name if your FftFrame differs (e.g., `frame.data`, `frame.bytes`, etc.)
val fft: ByteArray = frame.data
if (fft.isEmpty()) return@withContext Color.Black
// Visualizer FFT format: interleaved [re0, im0, re1, im1, ...]
// Skip DC (bin 0: re0, im0) as it often behaves like noise/offset.
var sumMagnitude = 0.0
var bins = 0
var i = 2 // start from bin 1 (skip DC)
while (i + 1 < fft.size) {
val re = fft[i].toInt()
val im = fft[i + 1].toInt()
val mag = sqrt((re * re + im * im).toDouble())
sumMagnitude += mag
bins++
i += 2
}
val avgMagnitude = if (bins > 0) sumMagnitude / bins else 0.0
// Normalize ~[0..1]. Each component is in [-128..127], so magnitude upper bound ~181.
// 128 is a practical normalization for stable visuals; then clamp.
val normalized = (avgMagnitude / 128.0).coerceIn(0.0, 1.0)
// Perceptual easing to avoid flicker and give more dynamic range at lower levels.
val eased = sqrt(normalized).toFloat()
// Simple noise gate so tiny ambient levels dont glow.
val value = if (eased < NOISE_FLOOR) 0f else eased
// Single, stable color only brightness changes with the music.
Color.hsv(BASE_HUE, BASE_SATURATION, value)
}
private companion object {
// Tune these to your taste (degrees 0..360, 0..1).
const val BASE_HUE = 0f // cool blue
const val BASE_SATURATION = 0.85f // vivid
const val NOISE_FLOOR = 0.05f // ignore very low energy
}
}

View File

@@ -0,0 +1,57 @@
package dev.adriankuta.visualizer.domain.processors
import androidx.compose.ui.graphics.Color
import dev.adriankuta.visualizer.HSL
import dev.adriankuta.visualizer.MovingAverage
import dev.adriankuta.visualizer.calculateRms
import dev.adriankuta.visualizer.data.FftFrame
import dev.adriankuta.visualizer.data.VisualizerFrame
import dev.adriankuta.visualizer.data.WaveformFrame
import timber.log.Timber
import kotlin.reflect.KClass
/**
* Default implementation of SoundToColorMapper that uses HSL color space
* and moving average for smooth color transitions.
*
* Follows Single Responsibility Principle by focusing on sound-to-color mapping.
* Follows Dependency Inversion Principle by depending on abstractions.
*/
class HSLSoundToColorMapper(
private val baseHue: Float = 0f, // Blue hue as default
private val saturation: Float = 1f,
private val movingAverageWindowSize: Int = 2
) : VisualizerProcessor {
private val movingAverage = MovingAverage(movingAverageWindowSize)
private var rmsMax = 0.0
override suspend fun invoke(frame: VisualizerFrame): Color {
val waveform = frame.data
// Calculate RMS value for current frame
val currentRms = calculateRms(waveform)
rmsMax -= 0.01
// Update maximum RMS value for normalization
if (currentRms > rmsMax) {
rmsMax = currentRms
}
Timber.d("Max RMS: %s, current %s", rmsMax, currentRms)
// Calculate energy ratio (0-1)
val energyRatio = if (rmsMax > 0) currentRms / rmsMax else 0.0
// Apply moving average for smooth transitions
val adjustedLightness = movingAverage.process(energyRatio)
// Create HSL color and convert to Compose Color
val hslColor = HSL(
hue = baseHue,
saturation = saturation,
lightness = adjustedLightness.toFloat().coerceIn(0f, 1f)
)
return hslColor.toComposeColor()
}
}

View File

@@ -0,0 +1,10 @@
package dev.adriankuta.visualizer.domain.processors
import androidx.compose.ui.graphics.Color
import dev.adriankuta.visualizer.data.VisualizerFrame
import kotlin.reflect.KClass
interface VisualizerProcessor {
suspend operator fun invoke(frame: VisualizerFrame): Color
}

View File

@@ -0,0 +1,20 @@
package dev.adriankuta.visualizer.ui.components
import android.view.WindowManager
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@Composable
fun KeepScreenOn() {
val activity = LocalActivity.current
DisposableEffect(Unit) {
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
onDispose {
// Clear the flag when the composable is disposed
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}

View File

@@ -4,14 +4,37 @@ import android.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.* import androidx.compose.material3.AlertDialog
import androidx.compose.runtime.* import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -24,35 +47,20 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.adriankuta.visualizer.AudioVisualizerFactory import dev.adriankuta.visualizer.ui.components.KeepScreenOn
import dev.adriankuta.visualizer.AudioVisualizerService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import timber.log.Timber import timber.log.Timber
@Composable
fun VisualizerScreen(
viewModel: VisualizerScreenViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
}
@Composable @Composable
fun VisualizerScreen( fun VisualizerScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
audioVisualizer: AudioVisualizerService = remember { viewModel: VisualizerScreenViewModel = hiltViewModel()
AudioVisualizerFactory.createDefault(
CoroutineScope(Dispatchers.Main + SupervisorJob())
)
}
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val currentColor by audioVisualizer.currentColor val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val isActive by audioVisualizer.isActive
KeepScreenOn()
// Permission state management // Permission state management
var hasAudioPermission by remember { var hasAudioPermission by remember {
@@ -73,7 +81,7 @@ fun VisualizerScreen(
hasAudioPermission = isGranted hasAudioPermission = isGranted
if (isGranted) { if (isGranted) {
Timber.d("Audio permission granted, starting visualizer") Timber.d("Audio permission granted, starting visualizer")
audioVisualizer.start() viewModel.start()
} else { } else {
Timber.w("Audio permission denied") Timber.w("Audio permission denied")
showPermissionRationale = true showPermissionRationale = true
@@ -84,8 +92,9 @@ fun VisualizerScreen(
fun startVisualizerWithPermissionCheck() { fun startVisualizerWithPermissionCheck() {
when { when {
hasAudioPermission -> { hasAudioPermission -> {
audioVisualizer.start() viewModel.start()
} }
else -> { else -> {
Timber.d("Requesting audio permission") Timber.d("Requesting audio permission")
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
@@ -98,7 +107,7 @@ fun VisualizerScreen(
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
when (event) { when (event) {
Lifecycle.Event.ON_START -> startVisualizerWithPermissionCheck() Lifecycle.Event.ON_START -> startVisualizerWithPermissionCheck()
Lifecycle.Event.ON_STOP -> audioVisualizer.stop() Lifecycle.Event.ON_STOP -> viewModel.stop()
else -> {} else -> {}
} }
} }
@@ -107,7 +116,7 @@ fun VisualizerScreen(
onDispose { onDispose {
lifecycleOwner.lifecycle.removeObserver(observer) lifecycleOwner.lifecycle.removeObserver(observer)
audioVisualizer.release() viewModel.stop()
} }
} }
@@ -128,26 +137,13 @@ fun VisualizerScreen(
) )
// Color display area // Color display area
Box( ColorDisplay(uiState)
modifier = Modifier
.size(300.dp)
.clip(RoundedCornerShape(16.dp))
.background(currentColor)
.padding(16.dp),
contentAlignment = Alignment.Center
) {
// Show color information
ColorInfoCard(
color = currentColor,
isActive = isActive
)
}
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
// Status indicator // Status indicator
StatusIndicator( StatusIndicator(
isActive = isActive, isActive = uiState.isActive,
hasPermission = hasAudioPermission hasPermission = hasAudioPermission
) )
@@ -157,7 +153,7 @@ fun VisualizerScreen(
Text( Text(
text = when { text = when {
!hasAudioPermission -> "Audio permission is required to visualize sound" !hasAudioPermission -> "Audio permission is required to visualize sound"
isActive -> "Play some music or make sounds to see the colors change!" uiState.isActive -> "Play some music or make sounds to see the colors change!"
else -> "Starting audio visualizer..." else -> "Starting audio visualizer..."
}, },
fontSize = 16.sp, fontSize = 16.sp,
@@ -211,13 +207,35 @@ fun VisualizerScreen(
} }
} }
@Composable
private fun ColorDisplay(uiState: VisualizerScreenUiState) {
val animatedColor by animateColorAsState(
uiState.color,
animationSpec = tween(50),
)
Box(
modifier = Modifier
.size(300.dp)
.clip(RoundedCornerShape(16.dp))
.drawBehind {
drawRect(animatedColor)
}
.padding(16.dp),
contentAlignment = Alignment.Center
) {
// Show color information
ColorInfoCard(
color = uiState.color,
)
}
}
/** /**
* Card displaying color information. * Card displaying color information.
*/ */
@Composable @Composable
private fun ColorInfoCard( private fun ColorInfoCard(
color: Color, color: Color,
isActive: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Card( Card(

View File

@@ -3,5 +3,6 @@ package dev.adriankuta.visualizer.view
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
data class VisualizerScreenUiState( data class VisualizerScreenUiState(
val color: Color val color: Color,
val isActive: Boolean = false
) )

View File

@@ -1,23 +1,54 @@
@file:OptIn(FlowPreview::class)
package dev.adriankuta.visualizer.view package dev.adriankuta.visualizer.view
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dev.adriankuta.visualizer.data.AudioVisualizerController
import dev.adriankuta.visualizer.domain.ObserveVisualizerColorUseCase import dev.adriankuta.visualizer.domain.ObserveVisualizerColorUseCase
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@HiltViewModel @HiltViewModel
class VisualizerScreenViewModel @Inject constructor( class VisualizerScreenViewModel @Inject constructor(
private val observeVisualizerColorUseCase: ObserveVisualizerColorUseCase observeVisualizerColorUseCase: ObserveVisualizerColorUseCase,
private val audioVisualizerController: AudioVisualizerController
) : ViewModel() { ) : ViewModel() {
val uiState = observeVisualizerColorUseCase() private val _isActive = MutableStateFlow(false)
.stateIn(
val uiState = combine(
observeVisualizerColorUseCase().sample(50.milliseconds),
_isActive
) { color, isActive ->
VisualizerScreenUiState(
color = color,
isActive = isActive
)
}.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
initialValue = Color.Black initialValue = VisualizerScreenUiState(
color = Color.Black,
isActive = false
) )
)
fun start() {
audioVisualizerController.start()
_isActive.value = true
}
fun stop() {
audioVisualizerController.stop()
_isActive.value = false
}
} }