diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt index dfeca94dd..6b4cb92d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/AudioPlayingAnimation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/AudioPlayingAnimation.kt new file mode 100644 index 000000000..0336e5519 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/AudioPlayingAnimation.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/FakeWaveformAnimation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/FakeWaveformAnimation.kt new file mode 100644 index 000000000..2e05aa6ad --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/FakeWaveformAnimation.kt @@ -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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt index 71d9d3a16..68fd893c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt @@ -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