diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayer.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayer.kt new file mode 100644 index 00000000..47afcf99 --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayer.kt @@ -0,0 +1,289 @@ +package com.team.prezel.core.designsystem.component.player + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.R +import com.team.prezel.core.designsystem.component.actions.button.PrezelIconButton +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonHierarchy +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonSize +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonType +import com.team.prezel.core.designsystem.component.actions.button.config.PrezelButtonDefaults +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.preview.PreviewSection +import com.team.prezel.core.designsystem.theme.PrezelTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.delay + +@Composable +fun PrezelPlayer( + state: PrezelPlayerState, + trackContentDescription: String, + modifier: Modifier = Modifier, +) { + PrezelPlayerContent( + playing = state.playing, + durationMillis = state.durationMillis, + currentMillis = state.currentMillis, + items = state.items, + trackContentDescription = trackContentDescription, + onPlayPauseClick = state::togglePlaying, + onPreviousClick = state::moveToPreviousItem, + onNextClick = state::moveToNextItem, + onSeek = state::seekToProgress, + modifier = modifier, + idle = state.idle, + showHandle = state.showHandle, + onDragStarted = state::startDrag, + onDragStopped = state::stopDrag, + previousEnabled = state.previousEnabled, + nextEnabled = state.nextEnabled, + ) +} + +@Composable +private fun PrezelPlayerContent( + playing: Boolean, + durationMillis: Long, + currentMillis: Long, + items: ImmutableList, + trackContentDescription: String, + onPlayPauseClick: () -> Unit, + onPreviousClick: () -> Unit, + onNextClick: () -> Unit, + onSeek: (Float) -> Unit, + modifier: Modifier = Modifier, + idle: Boolean = false, + showHandle: Boolean = false, + onDragStarted: () -> Unit = {}, + onDragStopped: () -> Unit = {}, + previousEnabled: Boolean = true, + nextEnabled: Boolean = true, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = PrezelTheme.spacing.V20), + ) { + PrezelPlayerTrackSection( + durationMillis = durationMillis, + currentMillis = currentMillis, + items = items, + trackContentDescription = trackContentDescription, + idle = idle, + showHandle = showHandle, + onSeek = onSeek, + onDragStarted = onDragStarted, + onDragStopped = onDragStopped, + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + + PrezelPlayerControls( + playing = playing, + onPlayPauseClick = onPlayPauseClick, + onPreviousClick = onPreviousClick, + onNextClick = onNextClick, + previousEnabled = previousEnabled, + nextEnabled = nextEnabled, + ) + } +} + +@Composable +private fun PrezelPlayerTrackSection( + durationMillis: Long, + currentMillis: Long, + items: ImmutableList, + trackContentDescription: String, + idle: Boolean, + showHandle: Boolean, + onSeek: (Float) -> Unit, + onDragStarted: () -> Unit, + onDragStopped: () -> Unit, +) { + PrezelPlayerResourceTrack( + durationMillis = durationMillis, + currentMillis = currentMillis, + items = items, + contentDescription = trackContentDescription, + idle = idle, + showHandle = showHandle, + onSeek = onSeek, + onDragStarted = onDragStarted, + onDragStopped = onDragStopped, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PrezelTheme.spacing.V20), + ) +} + +@Composable +private fun PrezelPlayerControls( + playing: Boolean, + onPlayPauseClick: () -> Unit, + onPreviousClick: () -> Unit, + onNextClick: () -> Unit, + previousEnabled: Boolean, + nextEnabled: Boolean, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + space = PrezelTheme.spacing.V24, + alignment = Alignment.CenterHorizontally, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + PrezelPlayerSeekButton( + iconResId = PrezelIcons.SkipBackward, + contentDescription = stringResource(R.string.core_designsystem_player_previous_desc), + enabled = previousEnabled, + onClick = onPreviousClick, + ) + PrezelPlayerPlayPauseButton( + playing = playing, + onClick = onPlayPauseClick, + modifier = Modifier.weight(1f), + ) + PrezelPlayerSeekButton( + iconResId = PrezelIcons.SkipForward, + contentDescription = stringResource(R.string.core_designsystem_player_next_desc), + enabled = nextEnabled, + onClick = onNextClick, + ) + } +} + +@Composable +private fun PrezelPlayerSeekButton( + iconResId: Int, + contentDescription: String, + enabled: Boolean, + onClick: () -> Unit, +) { + PrezelIconButton( + iconResId = iconResId, + type = ButtonType.GHOST, + hierarchy = ButtonHierarchy.SECONDARY, + enabled = enabled, + onClick = onClick, + modifier = Modifier + .widthIn(min = 80.dp) + .semantics { this.contentDescription = contentDescription }, + ) +} + +@Composable +private fun PrezelPlayerPlayPauseButton( + playing: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val playContentDescription = stringResource(R.string.core_designsystem_player_play_desc) + val pauseContentDescription = stringResource(R.string.core_designsystem_player_pause_desc) + + PrezelIconButton( + iconResId = if (playing) PrezelIcons.Pause else PrezelIcons.Play, + onClick = onClick, + modifier = modifier.semantics { + contentDescription = if (playing) { + pauseContentDescription + } else { + playContentDescription + } + }, + buttonDefault = PrezelButtonDefaults.getDefault( + isIconOnly = true, + type = ButtonType.FILLED, + size = ButtonSize.REGULAR, + hierarchy = ButtonHierarchy.PRIMARY, + isRounded = true, + contentColor = if (playing) PrezelTheme.colors.iconRegular else PrezelTheme.colors.solidWhite, + backgroundColor = if (playing) PrezelTheme.colors.bgLarge else PrezelTheme.colors.interactiveRegular, + ), + ) +} + +@BasicPreview +@Composable +private fun PrezelPlayerPlaybackPreview() { + PrezelTheme { + val durationMillis = 690_000L + val playerState = rememberPrezelPlayerState( + durationMillis = durationMillis, + initialItems = previewPlayerItems, + ) + + LaunchedEffect(playerState.playing) { + while (playerState.playing) { + delay(1_000L) + playerState.updateCurrentMillis( + currentMillis = (playerState.currentMillis + 1_000L).coerceAtMost(playerState.durationMillis), + ) + } + } + + PreviewSection(title = "Player Playback") { + PlayerPreviewItem(name = if (playerState.playing) "playing" else "paused") { + PrezelPlayer( + state = playerState, + trackContentDescription = stringResource(R.string.core_designsystem_player_speech_track_desc), + modifier = Modifier.width(360.dp), + ) + } + } + } +} + +@Composable +private fun PlayerPreviewItem( + name: String, + content: @Composable () -> Unit, +) { + Column( + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), + ) { + Text( + text = name, + style = PrezelTheme.typography.body3Medium, + color = PrezelTheme.colors.textMedium, + ) + content() + } +} + +private val previewPlayerItems = persistentListOf( + PrezelPlayerItem.Segment(timeMillis = 0L), + PrezelPlayerItem.Segment(timeMillis = 90_000L), + PrezelPlayerItem.Marker( + timeMillis = 151_000L, + markerType = PrezelPlayerMarkerType.WARNING, + ), + PrezelPlayerItem.Segment(timeMillis = 234_000L), + PrezelPlayerItem.Marker( + timeMillis = 317_000L, + markerType = PrezelPlayerMarkerType.GOOD, + ), + PrezelPlayerItem.Marker( + timeMillis = 443_000L, + markerType = PrezelPlayerMarkerType.WARNING, + ), +) diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerModels.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerModels.kt new file mode 100644 index 00000000..f36736a5 --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerModels.kt @@ -0,0 +1,25 @@ +package com.team.prezel.core.designsystem.component.player + +import androidx.compose.runtime.Immutable + +enum class PrezelPlayerMarkerType { + GOOD, + WARNING, + NEUTRAL, +} + +@Immutable +sealed interface PrezelPlayerItem { + val timeMillis: Long + + @Immutable + data class Segment( + override val timeMillis: Long, + ) : PrezelPlayerItem + + @Immutable + data class Marker( + override val timeMillis: Long, + val markerType: PrezelPlayerMarkerType, + ) : PrezelPlayerItem +} diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerResourceMarker.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerResourceMarker.kt new file mode 100644 index 00000000..149d6641 --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerResourceMarker.kt @@ -0,0 +1,50 @@ +package com.team.prezel.core.designsystem.component.player + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.preview.PreviewSection +import com.team.prezel.core.designsystem.theme.PrezelTheme + +@Composable +internal fun PrezelPlayerResourceMarker( + type: PrezelPlayerMarkerType, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .size(8.dp) + .clip(PrezelTheme.shapes.V1000) + .background(type.color), + ) +} + +private val PrezelPlayerMarkerType.color: Color + @Composable + get() = when (this) { + PrezelPlayerMarkerType.GOOD -> PrezelTheme.colors.feedbackGoodRegular + PrezelPlayerMarkerType.WARNING -> PrezelTheme.colors.feedbackWarningRegular + PrezelPlayerMarkerType.NEUTRAL -> PrezelTheme.colors.iconRegular + } + +@BasicPreview +@Composable +private fun PrezelPlayerResourceMarkerPreview() { + PrezelTheme { + PreviewSection(title = "Player Resource Marker") { + Row(horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16)) { + PrezelPlayerResourceMarker(type = PrezelPlayerMarkerType.GOOD) + PrezelPlayerResourceMarker(type = PrezelPlayerMarkerType.WARNING) + PrezelPlayerResourceMarker(type = PrezelPlayerMarkerType.NEUTRAL) + } + } + } +} diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerResourceTrack.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerResourceTrack.kt new file mode 100644 index 00000000..fe9d3fb6 --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerResourceTrack.kt @@ -0,0 +1,235 @@ +package com.team.prezel.core.designsystem.component.player + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.R +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.preview.PreviewColumn +import com.team.prezel.core.designsystem.preview.PreviewSection +import com.team.prezel.core.designsystem.theme.PrezelTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun PrezelPlayerResourceTrack( + durationMillis: Long, + currentMillis: Long, + items: ImmutableList, + contentDescription: String, + onSeek: (Float) -> Unit, + modifier: Modifier = Modifier, + idle: Boolean = false, + showHandle: Boolean = false, + onDragStarted: () -> Unit = {}, + onDragStopped: () -> Unit = {}, +) { + val displayedCurrentMillis = if (idle) 0L else currentMillis + val displayedProgress = if (idle) 0f else displayedCurrentMillis.toPlayerProgress(durationMillis) + + Column( + modifier = modifier.heightIn(min = 40.dp), + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), + ) { + PrezelPlayerTimeline( + progress = displayedProgress, + durationMillis = durationMillis, + items = items, + contentDescription = contentDescription, + showHandle = showHandle, + onSeek = onSeek, + onDragStarted = onDragStarted, + onDragStopped = onDragStopped, + modifier = Modifier.fillMaxWidth(), + ) + + PlayerTrackTimeLabels( + currentMillis = displayedCurrentMillis, + durationMillis = durationMillis, + ) + } +} + +@Composable +private fun PlayerTrackTimeLabels( + currentMillis: Long, + durationMillis: Long, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = currentMillis.formatPlayerTime(), + style = PrezelTheme.typography.caption2Medium, + color = PrezelTheme.colors.textRegular, + ) + Text( + text = durationMillis.formatPlayerTime(), + style = PrezelTheme.typography.caption2Medium, + color = PrezelTheme.colors.textRegular, + ) + } +} + +private fun Long.toPlayerProgress(durationMillis: Long): Float = + if (durationMillis <= 0L) { + 0f + } else { + (toFloat() / durationMillis.toFloat()).coerceIn(0f, 1f) + } + +private fun Long.formatPlayerTime(): String { + val totalSeconds = this / 1_000L + val minutes = totalSeconds / 60L + val seconds = totalSeconds % 60L + return "${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}" +} + +@BasicPreview +@Composable +private fun PrezelPlayerResourceTrackPreview() { + PrezelTheme { + PreviewSection(title = "Player Resource Track") { + PreviewColumn { + PlayerResourceTrackPreviewItem(name = "speech / idle=off / showHandle=off") { + PrezelPlayerResourceTrack( + durationMillis = 690_000, + currentMillis = 443_000, + items = previewSpeechItems, + contentDescription = stringResource(R.string.core_designsystem_player_speech_track_desc), + onSeek = {}, + modifier = Modifier.width(320.dp), + idle = false, + showHandle = false, + ) + } + PlayerResourceTrackPreviewItem(name = "script match / idle=off / showHandle=off") { + PrezelPlayerResourceTrack( + durationMillis = 690_000, + currentMillis = 443_000, + items = previewScriptMatchItems, + contentDescription = stringResource(R.string.core_designsystem_player_script_match_track_desc), + onSeek = {}, + modifier = Modifier.width(320.dp), + idle = false, + showHandle = false, + ) + } + PlayerResourceTrackPreviewItem(name = "idle=on") { + PrezelPlayerResourceTrack( + durationMillis = 690_000, + currentMillis = 443_000, + items = previewSpeechItems, + contentDescription = stringResource(R.string.core_designsystem_player_speech_track_desc), + onSeek = {}, + modifier = Modifier.width(320.dp), + idle = true, + ) + } + PlayerResourceTrackPreviewItem(name = "showHandle=on") { + PrezelPlayerResourceTrack( + durationMillis = 690_000, + currentMillis = 443_000, + items = previewSpeechItems, + contentDescription = stringResource(R.string.core_designsystem_player_speech_track_desc), + onSeek = {}, + modifier = Modifier.width(320.dp), + showHandle = true, + ) + } + PlayerResourceTrackPreviewItem(name = "overlapping markers") { + PrezelPlayerResourceTrack( + durationMillis = 690_000, + currentMillis = 443_000, + items = previewOverlappingItems, + contentDescription = stringResource(R.string.core_designsystem_player_speech_track_desc), + onSeek = {}, + modifier = Modifier.width(320.dp), + ) + } + } + } + } +} + +@Composable +private fun PlayerResourceTrackPreviewItem( + name: String, + content: @Composable () -> Unit, +) { + Column( + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), + ) { + Text( + text = name, + style = PrezelTheme.typography.body3Medium, + color = PrezelTheme.colors.textMedium, + ) + content() + } +} + +private val previewSpeechItems = persistentListOf( + PrezelPlayerItem.Segment(timeMillis = 0L), + PrezelPlayerItem.Segment(timeMillis = 90_000L), + PrezelPlayerItem.Marker( + timeMillis = 151_000L, + markerType = PrezelPlayerMarkerType.WARNING, + ), + PrezelPlayerItem.Segment(timeMillis = 234_000L), + PrezelPlayerItem.Marker( + timeMillis = 317_000L, + markerType = PrezelPlayerMarkerType.GOOD, + ), + PrezelPlayerItem.Marker( + timeMillis = 443_000L, + markerType = PrezelPlayerMarkerType.WARNING, + ), +) + +private val previewScriptMatchItems = persistentListOf( + PrezelPlayerItem.Segment(timeMillis = 0L), + PrezelPlayerItem.Segment(timeMillis = 151_000L), + PrezelPlayerItem.Segment(timeMillis = 234_000L), + PrezelPlayerItem.Marker( + timeMillis = 317_000L, + markerType = PrezelPlayerMarkerType.GOOD, + ), + PrezelPlayerItem.Marker( + timeMillis = 443_000L, + markerType = PrezelPlayerMarkerType.NEUTRAL, + ), +) + +private val previewOverlappingItems = persistentListOf( + PrezelPlayerItem.Segment(timeMillis = 0L), + PrezelPlayerItem.Segment(timeMillis = 151_000L), + PrezelPlayerItem.Marker( + timeMillis = 317_000L, + markerType = PrezelPlayerMarkerType.GOOD, + ), + PrezelPlayerItem.Segment(timeMillis = 360_000L), + PrezelPlayerItem.Marker( + timeMillis = 438_000L, + markerType = PrezelPlayerMarkerType.GOOD, + ), + PrezelPlayerItem.Marker( + timeMillis = 151_000L, + markerType = PrezelPlayerMarkerType.WARNING, + ), + PrezelPlayerItem.Marker( + timeMillis = 443_000L, + markerType = PrezelPlayerMarkerType.WARNING, + ), +) diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerState.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerState.kt new file mode 100644 index 00000000..df108db4 --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerState.kt @@ -0,0 +1,192 @@ +package com.team.prezel.core.designsystem.component.player + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun rememberPrezelPlayerState( + durationMillis: Long, + initialItems: ImmutableList, + playing: Boolean = false, + currentMillis: Long = 0L, +): PrezelPlayerState = + rememberSaveable( + durationMillis, + initialItems, + saver = PrezelPlayerState.Saver, + ) { + PrezelPlayerState( + playing = playing, + durationMillis = durationMillis, + currentMillis = currentMillis, + items = initialItems, + ) + } + +@Stable +class PrezelPlayerState internal constructor( + playing: Boolean, + durationMillis: Long, + currentMillis: Long, + items: ImmutableList, +) { + var playing by mutableStateOf(playing) + private set + + var durationMillis by mutableLongStateOf(durationMillis) + private set + + var currentMillis by mutableLongStateOf(currentMillis) + private set + + var items by mutableStateOf(items) + private set + + var dragging by mutableStateOf(false) + private set + + val idle: Boolean + get() = !playing && currentMillis == 0L + + val showHandle: Boolean + get() = dragging + + private val playbackEnded: Boolean + get() = currentMillis >= durationMillis + + private val currentItemIndex: Int + get() = if (items.isEmpty()) { + -1 + } else { + items.indexOfLast { item -> currentMillis >= item.timeMillis }.coerceAtLeast(0) + } + + val previousEnabled: Boolean + get() { + val currentItem = items.getOrNull(currentItemIndex) ?: return false + return currentItemIndex > 0 || currentMillis > currentItem.timeMillis + } + + val nextEnabled: Boolean + get() = currentItemIndex >= 0 && currentItemIndex < items.lastIndex + + fun seekToProgress(progress: Float) { + seekToMillis((durationMillis * progress.coerceIn(0f, 1f)).toLong()) + } + + fun moveToPreviousItem() { + val currentItem = items.getOrNull(currentItemIndex) ?: return + val targetIndex = if (currentMillis > currentItem.timeMillis) { + currentItemIndex + } else { + currentItemIndex - 1 + } + + if (targetIndex in items.indices) seekToMillis(items[targetIndex].timeMillis) + } + + fun moveToNextItem() { + if (nextEnabled) seekToMillis(items[currentItemIndex + 1].timeMillis) + } + + fun play() { + if (playbackEnded) seekToMillis(0L) + playing = true + } + + fun pause() { + playing = false + } + + fun togglePlaying() { + if (playing) { + pause() + } else { + play() + } + } + + fun updateCurrentMillis(currentMillis: Long) { + this.currentMillis = currentMillis.coerceIn(0L, durationMillis) + if (playbackEnded) pause() + } + + fun startDrag() { + dragging = true + } + + fun stopDrag() { + dragging = false + } + + private fun seekToMillis(targetMillis: Long) { + currentMillis = targetMillis.coerceIn(0L, durationMillis) + } + + companion object { + val Saver: Saver = Saver( + save = { state -> + listOf( + state.playing, + state.durationMillis, + state.currentMillis, + state.items.map { item -> item.toSaveable() }, + ) + }, + restore = { restored -> + val values = restored as List<*> + PrezelPlayerState( + playing = values[0] as Boolean, + durationMillis = values[1] as Long, + currentMillis = values[2] as Long, + items = (values[3] as List<*>) + .map { savedItem -> (savedItem as List<*>).toPrezelPlayerItem() } + .toImmutableList(), + ) + }, + ) + + private fun PrezelPlayerItem.toSaveable(): List = + when (this) { + is PrezelPlayerItem.Segment -> listOf( + PLAYER_ITEM_TYPE_SEGMENT, + timeMillis, + ) + + is PrezelPlayerItem.Marker -> listOf( + PLAYER_ITEM_TYPE_MARKER, + timeMillis, + markerType.name, + ) + } + + private fun List<*>.toPrezelPlayerItem(): PrezelPlayerItem { + val type = this[0] as String + val timeMillis = this[1] as Long + + return when (type) { + PLAYER_ITEM_TYPE_SEGMENT -> PrezelPlayerItem.Segment( + timeMillis = timeMillis, + ) + + PLAYER_ITEM_TYPE_MARKER -> PrezelPlayerItem.Marker( + timeMillis = timeMillis, + markerType = PrezelPlayerMarkerType.valueOf(this[2] as String), + ) + + else -> error("지원하지 않는 PrezelPlayerItem 타입입니다: $type") + } + } + + private const val PLAYER_ITEM_TYPE_SEGMENT = "segment" + private const val PLAYER_ITEM_TYPE_MARKER = "marker" + } +} diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerTimeline.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerTimeline.kt new file mode 100644 index 00000000..2d6267b2 --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerTimeline.kt @@ -0,0 +1,309 @@ +package com.team.prezel.core.designsystem.component.player + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.BoxWithConstraintsScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.setProgress +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.preview.PreviewColumn +import com.team.prezel.core.designsystem.preview.PreviewSection +import com.team.prezel.core.designsystem.theme.PrezelTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlin.math.roundToInt + +@Composable +internal fun PrezelPlayerTimeline( + progress: Float, + durationMillis: Long, + items: ImmutableList, + contentDescription: String, + showHandle: Boolean, + onSeek: (Float) -> Unit, + onDragStarted: () -> Unit, + onDragStopped: () -> Unit, + modifier: Modifier = Modifier, +) { + var widthPx by remember { mutableIntStateOf(0) } + + fun seekTo(offsetX: Float) { + if (widthPx > 0) onSeek((offsetX / widthPx).coerceIn(0f, 1f)) + } + + BoxWithConstraints( + modifier = modifier.playerTimelineModifier( + progress = progress, + contentDescription = contentDescription, + onWidthChanged = { widthPx = it }, + onSeekTo = ::seekTo, + onSetProgress = onSeek, + onDragStarted = { offsetX -> + onDragStarted() + seekTo(offsetX) + }, + onDragStopped = onDragStopped, + ), + contentAlignment = Alignment.CenterStart, + ) { + PlayerTimelineBar( + progress = progress, + playedBarVisible = !showHandle, + ) + PlayerTimelineMarkers( + items = items, + durationMillis = durationMillis, + ) + + if (showHandle) PlayerTimelineHandle(progress = progress, zIndex = items.size + 1f) + } +} + +@Composable +private fun Modifier.playerTimelineModifier( + progress: Float, + contentDescription: String, + onWidthChanged: (Int) -> Unit, + onSeekTo: (Float) -> Unit, + onSetProgress: (Float) -> Unit, + onDragStarted: (Float) -> Unit, + onDragStopped: () -> Unit, +): Modifier { + val currentOnSeekTo by rememberUpdatedState(onSeekTo) + val currentOnDragStarted by rememberUpdatedState(onDragStarted) + val currentOnDragStopped by rememberUpdatedState(onDragStopped) + + return fillMaxWidth() + .height(16.dp) + .onSizeChanged { onWidthChanged(it.width) } + .pointerInput(Unit) { + detectTapGestures { offset -> currentOnSeekTo(offset.x) } + }.pointerInput(Unit) { + detectHorizontalDragGestures( + onDragStart = { offset -> currentOnDragStarted(offset.x) }, + onDragEnd = currentOnDragStopped, + onDragCancel = currentOnDragStopped, + onHorizontalDrag = { change, _ -> + currentOnSeekTo(change.position.x) + change.consume() + }, + ) + }.semantics { + progressBarRangeInfo = ProgressBarRangeInfo(current = progress, range = 0f..1f) + this.contentDescription = contentDescription + setProgress { targetProgress -> + onSetProgress(targetProgress.coerceIn(0f, 1f)) + true + } + } +} + +@Composable +private fun PlayerTimelineBar( + progress: Float, + playedBarVisible: Boolean, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(PrezelTheme.shapes.V1000) + .background(PrezelTheme.colors.bgMedium), + ) + + if (playedBarVisible) PlayerTimelinePlayedBar(progress = progress) +} + +@Composable +private fun PlayerTimelinePlayedBar(progress: Float) { + Box( + modifier = Modifier + .fillMaxWidth(progress) + .height(8.dp) + .clip(PrezelTheme.shapes.V1000) + .background(PrezelTheme.colors.bgLarge), + ) +} + +@Composable +private fun BoxWithConstraintsScope.PlayerTimelineMarkers( + items: ImmutableList, + durationMillis: Long, +) { + var visibleMarkerIndex = 0 + items.forEach { item -> + if (item !is PrezelPlayerItem.Marker) return@forEach + + PrezelPlayerResourceMarker( + type = item.markerType, + modifier = Modifier + .align(Alignment.CenterStart) + .offset { + val markerX = ((maxWidth.toPx() - 8.dp.toPx()) * item.progressIn(durationMillis)).roundToInt() + IntOffset(x = markerX, y = 0) + }.zIndex(visibleMarkerIndex.toFloat()), + ) + visibleMarkerIndex += 1 + } +} + +private fun PrezelPlayerItem.progressIn(durationMillis: Long): Float = + if (durationMillis <= 0L) { + 0f + } else { + (timeMillis.toFloat() / durationMillis).coerceIn(0f, 1f) + } + +@Composable +private fun BoxWithConstraintsScope.PlayerTimelineHandle( + progress: Float, + zIndex: Float, +) { + Box( + modifier = Modifier + .align(Alignment.CenterStart) + .offset { + val handleX = ((maxWidth.toPx() - 16.dp.toPx()) * progress.coerceIn(0f, 1f)).roundToInt() + IntOffset(x = handleX, y = 0) + }.size(16.dp) + .clip(PrezelTheme.shapes.V1000) + .background(PrezelTheme.colors.iconLarge) + .zIndex(zIndex), + ) +} + +@BasicPreview +@Composable +private fun PrezelPlayerTimelinePreview() { + PrezelTheme { + PreviewSection(title = "Player Timeline") { + PreviewColumn { + TimelinePreviewItem(name = "played bar") { + PrezelPlayerTimeline( + progress = 0.64f, + durationMillis = 690_000L, + items = previewTimelineItems, + contentDescription = "발화 트랙", + showHandle = false, + onSeek = {}, + onDragStarted = {}, + onDragStopped = {}, + modifier = Modifier.width(320.dp), + ) + } + TimelinePreviewItem(name = "drag handle") { + PrezelPlayerTimeline( + progress = 0.64f, + durationMillis = 690_000L, + items = previewTimelineItems, + contentDescription = "발화 트랙", + showHandle = true, + onSeek = {}, + onDragStarted = {}, + onDragStopped = {}, + modifier = Modifier.width(320.dp), + ) + } + TimelinePreviewItem(name = "overlapping markers") { + PrezelPlayerTimeline( + progress = 0.46f, + durationMillis = 690_000L, + items = previewOverlappingTimelineItems, + contentDescription = "발화 트랙", + showHandle = false, + onSeek = {}, + onDragStarted = {}, + onDragStopped = {}, + modifier = Modifier.width(320.dp), + ) + } + } + } + } +} + +@Composable +private fun TimelinePreviewItem( + name: String, + content: @Composable () -> Unit, +) { + Column( + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), + ) { + Text( + text = name, + style = PrezelTheme.typography.body3Medium, + color = PrezelTheme.colors.textMedium, + ) + content() + } +} + +private val previewTimelineItems = persistentListOf( + PrezelPlayerItem.Segment(timeMillis = 0L), + PrezelPlayerItem.Segment(timeMillis = 90_000L), + PrezelPlayerItem.Marker( + timeMillis = 151_000L, + markerType = PrezelPlayerMarkerType.WARNING, + ), + PrezelPlayerItem.Segment(timeMillis = 234_000L), + PrezelPlayerItem.Marker( + timeMillis = 317_000L, + markerType = PrezelPlayerMarkerType.GOOD, + ), + PrezelPlayerItem.Marker( + timeMillis = 443_000L, + markerType = PrezelPlayerMarkerType.WARNING, + ), +) + +private val previewOverlappingTimelineItems = persistentListOf( + PrezelPlayerItem.Segment(timeMillis = 0L), + PrezelPlayerItem.Segment(timeMillis = 90_000L), + PrezelPlayerItem.Marker( + timeMillis = 151_000L, + markerType = PrezelPlayerMarkerType.WARNING, + ), + PrezelPlayerItem.Segment(timeMillis = 234_000L), + PrezelPlayerItem.Marker( + timeMillis = 317_000L, + markerType = PrezelPlayerMarkerType.GOOD, + ), + PrezelPlayerItem.Segment(timeMillis = 360_000L), + PrezelPlayerItem.Marker( + timeMillis = 438_000L, + markerType = PrezelPlayerMarkerType.GOOD, + ), + PrezelPlayerItem.Marker( + timeMillis = 443_000L, + markerType = PrezelPlayerMarkerType.WARNING, + ), +) diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/icon/PrezelIcons.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/icon/PrezelIcons.kt index 63919d44..6c7ca0e8 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/icon/PrezelIcons.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/icon/PrezelIcons.kt @@ -44,6 +44,8 @@ object PrezelIcons { val Rotate = R.drawable.core_designsystem_ic_rotate val Search = R.drawable.core_designsystem_ic_search val Setting = R.drawable.core_designsystem_ic_setting + val SkipBackward = R.drawable.core_designsystem_ic_backward + val SkipForward = R.drawable.core_designsystem_ic_forward val Stop = R.drawable.core_designsystem_ic_stop val Storage = R.drawable.core_designsystem_ic_storage val Trophy = R.drawable.core_designsystem_ic_trophy diff --git a/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_backward.xml b/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_backward.xml new file mode 100644 index 00000000..c1f8769e --- /dev/null +++ b/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_backward.xml @@ -0,0 +1,9 @@ + + + diff --git a/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_forward.xml b/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_forward.xml new file mode 100644 index 00000000..0d660ef5 --- /dev/null +++ b/Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_forward.xml @@ -0,0 +1,9 @@ + + + diff --git a/Prezel/core/designsystem/src/main/res/values/strings.xml b/Prezel/core/designsystem/src/main/res/values/strings.xml index 9e7e3bda..70523987 100644 --- a/Prezel/core/designsystem/src/main/res/values/strings.xml +++ b/Prezel/core/designsystem/src/main/res/values/strings.xml @@ -6,6 +6,12 @@ 체크박스 선택하기 %1$d년 %2$d월 + 이전 아이템으로 이동 + 다음 아이템으로 이동 + 일시정지 + 재생 + 스크립트 일치 트랙 + 발화 트랙 툴팁 닫기