Rendering a fake waveform when playing music without any video frames.

This commit is contained in:
Vitor Pamplona
2026-03-09 16:32:50 -04:00
parent 328fd5221c
commit e47299cd6b
4 changed files with 234 additions and 10 deletions
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.service.playback.composable
import androidx.annotation.OptIn
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
@@ -44,7 +46,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCen
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderTopButtons
import com.vitorpamplona.amethyst.service.playback.composable.controls.TopGradientOverlay
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.AudioPlayingAnimation
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -118,7 +120,11 @@ fun RenderVideoPlayer(
contentScale = contentScale,
)
mediaItem.src.waveformData?.let { Waveform(it, controllerState, Modifier.align(Alignment.Center)) }
AudioPlayingAnimation(
controllerState,
mediaItem.src.waveformData,
Modifier.fillMaxSize().align(Alignment.Center),
)
if (showControls) {
TopGradientOverlay(
@@ -0,0 +1,68 @@
/*
* 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.service.playback.composable.wavefront
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.Modifier
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.Tracks
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
fun Tracks.isAudio() = groups.none { it.type == C.TRACK_TYPE_VIDEO }
@Composable
fun AudioPlayingAnimation(
controllerState: MediaControllerState,
waveform: WaveformData?,
modifier: Modifier = Modifier,
) {
var isAudio by remember { mutableStateOf(controllerState.controller.currentTracks.isAudio()) }
DisposableEffect(controllerState.controller) {
val listener =
object : Player.Listener {
override fun onTracksChanged(tracks: Tracks) {
super.onTracksChanged(tracks)
isAudio = tracks.isAudio()
}
}
controllerState.controller.addListener(listener)
onDispose { controllerState.controller.removeListener(listener) }
}
if (isAudio) {
if (waveform != null) {
Waveform(waveform, controllerState, modifier)
} else {
FakeWaveformAnimation(
mediaControllerState = controllerState,
modifier = modifier,
)
}
}
}
@@ -0,0 +1,153 @@
/*
* 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.service.playback.composable.wavefront
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableFloatState
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onStart
import kotlin.math.sin
@Composable
fun FakeWaveformAnimation(
mediaControllerState: MediaControllerState,
modifier: Modifier,
) {
val waveformProgress = remember { mutableFloatStateOf(0F) }
FakeWaveformAnimation(waveformProgress, 50, modifier)
val restartFlow = remember { mutableIntStateOf(0) }
// Keeps the screen on while playing and viewing videos.
DisposableEffect(key1 = mediaControllerState.controller) {
val listener =
object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
// doesn't consider the mutex because the screen can turn off if the video
// being played in the mutex is not visible.
if (isPlaying) {
restartFlow.intValue += 1
}
}
}
mediaControllerState.controller.addListener(listener)
onDispose { mediaControllerState.controller.removeListener(listener) }
}
LaunchedEffect(key1 = restartFlow.intValue) {
pollCurrentPosition(mediaControllerState.controller).collect { value ->
waveformProgress.floatValue = (value % 5000.0f) / 5000.0f
}
}
}
fun pollCurrentPosition(controller: Player) =
flow {
while (controller.currentPosition <= controller.duration) {
emit(controller.currentPosition)
delay(100)
}
}.onStart {
emit(controller.currentPosition)
}.conflate()
@Preview
@Composable
fun FakeWaveformAnimationPreview() {
val state =
remember {
mutableFloatStateOf((500 % 1000.0f) / 1000.0f)
}
ThemeComparisonColumn {
FakeWaveformAnimation(state, 50, Modifier.width(200.dp))
}
}
@Composable
fun FakeWaveformAnimation(
waveformProgress: MutableFloatState,
barCount: Int = 50,
modifier: Modifier = Modifier,
) {
val barBrush =
Brush.linearGradient(
colors = listOf(Color(0xff2598cf), Color(0xff652d80)),
start = Offset(0.2f * 128f, 0f),
end = Offset(0.2f * 128f + 128f, 0f),
)
Canvas(
modifier =
modifier
.fillMaxWidth()
.requiredHeight(48.dp)
.padding(horizontal = 10.dp),
) {
val barWidth = 3.dp.toPx()
val barPadding = 2.dp.toPx()
val barRadius = 2.dp.toPx()
val totalBarWidth = barWidth + barPadding
val startX = (size.width - (barCount * totalBarWidth - barPadding)) / 2f
for (i in 0 until barCount) {
val phase = (i.toFloat() / barCount) * 2f * Math.PI.toFloat()
val wave = sin(waveformProgress.floatValue * 2f * Math.PI.toFloat() + phase)
val barFraction = ((wave + 1f) / 2f)
val barHeight = size.height * barFraction
val x = startX + i * totalBarWidth
val y = (size.height - barHeight) / 2f
drawRoundRect(
brush = barBrush,
topLeft = Offset(x, y),
size = Size(barWidth, barHeight),
cornerRadius = CornerRadius(barRadius, barRadius),
style = Fill,
)
}
}
}
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.components.AudioWaveformReadOnly
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onStart
@Composable
fun Waveform(
@@ -73,22 +74,18 @@ fun Waveform(
}
LaunchedEffect(key1 = restartFlow.intValue) {
pollCurrentDuration(mediaControllerState.controller).collect { value -> waveformProgress.floatValue = value }
}
LaunchedEffect(Unit) {
delay(500)
val position = mediaControllerState.controller?.let { it.currentPosition / it.duration.toFloat() } ?: 0f
waveformProgress.floatValue = position
pollCurrentRelativePosition(mediaControllerState.controller).collect { value -> waveformProgress.floatValue = value }
}
}
private fun pollCurrentDuration(controller: Player) =
fun pollCurrentRelativePosition(controller: Player) =
flow {
while (controller.currentPosition <= controller.duration) {
emit(controller.currentPosition / controller.duration.toFloat())
delay(100)
}
}.onStart {
emit(controller.currentPosition / controller.duration.toFloat())
}.conflate()
@Composable