From db66729b925e2f02e24befd15c8cd650c8f4fb3b Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Thu, 7 May 2026 01:00:59 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20=EC=98=A4=EB=94=94=EC=98=A4=20?= =?UTF-8?q?=ED=94=8C=EB=A0=88=EC=9D=B4=EC=96=B4(`PrezelPlayer`)=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20=ED=8A=B8=EB=9E=99=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: `PrezelPlayer` 및 관련 컴포넌트 추가** * 재생/일시정지, 전/후 이동 제어 및 프로그레스 바를 포함한 `PrezelPlayer` 컴포넌트를 구현했습니다. * `PrezelPlayerResourceTrack`: 재생 진행 상태, 시간 표시, 마커(Marker) 표시 및 탐색(Seek) 기능을 지원하는 트랙 컴포넌트를 추가했습니다. * `PrezelPlayerResourceMarker`: 트랙 위에 표시될 상태 마커(`GOOD`, `WARNING`, `NEUTRAL`) 컴포넌트를 추가했습니다. * **feat: 플레이어 제어용 아이콘 및 데이터 모델 추가** * `PrezelIcons`: `SkipBackward`, `SkipForward` 아이콘 리소스를 추가하고 등록했습니다. * `PrezelPlayerResourceMarkerItem`: 트랙의 마커 데이터를 정의하기 위한 sealed 구조의 모델과 팩토리 메서드(`speech`, `scriptMatch`)를 추가했습니다. * `PrezelPlayerResourceTrackType`: 음성(`SPEECH`) 및 대본 일치(`SCRIPT_MATCH`) 트랙 타입을 정의하는 열거형을 추가했습니다. * **refactor: 플레이어 UI 스타일 및 인터랙션 구현** * 터치 및 드래그 제스처를 통한 재생 위치 탐색(Seek) 로직을 적용했습니다. * `playing` 상태에 따라 재생/일시정지 버튼의 색상 및 아이콘이 동적으로 변경되도록 구현했습니다. * 밀리초(Long) 단위를 `mm:ss` 형식으로 변환하는 `formatPlayerTime` 확장 함수를 추가했습니다. --- .../component/player/PrezelPlayer.kt | 259 ++++++++++++ .../player/PrezelPlayerResourceMarker.kt | 60 +++ .../player/PrezelPlayerResourceTrack.kt | 394 ++++++++++++++++++ .../core/designsystem/icon/PrezelIcons.kt | 2 + .../core_designsystem_ic_backward.xml | 9 + .../drawable/core_designsystem_ic_forward.xml | 9 + 6 files changed, 733 insertions(+) create mode 100644 Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayer.kt create mode 100644 Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerResourceMarker.kt create mode 100644 Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerResourceTrack.kt create mode 100644 Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_backward.xml create mode 100644 Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_forward.xml 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..e02d1fcc --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayer.kt @@ -0,0 +1,259 @@ +package com.team.prezel.core.designsystem.component.player + +import androidx.annotation.FloatRange +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.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.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +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.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( + playing: Boolean, + @FloatRange(from = 0.0, to = 1.0) progress: Float, + durationMillis: Long, + currentMillis: Long, + markers: ImmutableList, + onPlayPauseClick: () -> Unit, + onBackwardClick: () -> Unit, + onForwardClick: () -> Unit, + onSeek: (Float) -> Unit, + modifier: Modifier = Modifier, + trackType: PrezelPlayerResourceTrackType = PrezelPlayerResourceTrackType.SPEECH, + idle: Boolean = false, + showHandle: Boolean = false, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = PrezelTheme.spacing.V20), + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), + ) { + PrezelPlayerTrackSection( + progress = progress, + durationMillis = durationMillis, + currentMillis = currentMillis, + markers = markers, + trackType = trackType, + idle = idle, + showHandle = showHandle, + onSeek = onSeek, + ) + + PrezelPlayerControls( + playing = playing, + onPlayPauseClick = onPlayPauseClick, + onBackwardClick = onBackwardClick, + onForwardClick = onForwardClick, + ) + } +} + +@Composable +private fun PrezelPlayerTrackSection( + @FloatRange(from = 0.0, to = 1.0) progress: Float, + durationMillis: Long, + currentMillis: Long, + markers: ImmutableList, + trackType: PrezelPlayerResourceTrackType, + idle: Boolean, + showHandle: Boolean, + onSeek: (Float) -> Unit, +) { + PrezelPlayerResourceTrack( + progress = progress, + durationMillis = durationMillis, + currentMillis = currentMillis, + markers = markers, + type = trackType, + idle = idle, + showHandle = showHandle, + onSeek = onSeek, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PrezelTheme.spacing.V20), + ) +} + +@Composable +private fun PrezelPlayerControls( + playing: Boolean, + onPlayPauseClick: () -> Unit, + onBackwardClick: () -> Unit, + onForwardClick: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + space = PrezelTheme.spacing.V24, + alignment = Alignment.CenterHorizontally, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + PrezelPlayerSeekButton(iconResId = PrezelIcons.SkipBackward, onClick = onBackwardClick) + PrezelPlayerPlayPauseButton( + playing = playing, + onClick = onPlayPauseClick, + modifier = Modifier.weight(1f), + ) + PrezelPlayerSeekButton(iconResId = PrezelIcons.SkipForward, onClick = onForwardClick) + } +} + +@Composable +private fun PrezelPlayerSeekButton( + iconResId: Int, + onClick: () -> Unit, +) { + PrezelIconButton( + iconResId = iconResId, + type = ButtonType.GHOST, + hierarchy = ButtonHierarchy.SECONDARY, + onClick = onClick, + modifier = Modifier.widthIn(min = 80.dp), + ) +} + +@Composable +private fun PrezelPlayerPlayPauseButton( + playing: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + PrezelIconButton( + iconResId = if (playing) PrezelIcons.Pause else PrezelIcons.Play, + onClick = onClick, + modifier = modifier, + 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, + ), + ) +} + +@Preview(showBackground = true) +@Composable +private fun PrezelPlayerPreview() { + PrezelTheme { + PreviewSection(title = "Player") { + PlayerPreviewItem(name = "playing=off") { + PrezelPlayer( + playing = false, + progress = 0.671f, + durationMillis = 690_000, + currentMillis = 443_000, + markers = previewMarkers, + onPlayPauseClick = {}, + onBackwardClick = {}, + onForwardClick = {}, + onSeek = {}, + modifier = Modifier.width(360.dp), + ) + } + PlayerPreviewItem(name = "playing=on") { + PrezelPlayer( + playing = true, + progress = 0.671f, + durationMillis = 690_000, + currentMillis = 443_000, + markers = previewMarkers, + onPlayPauseClick = {}, + onBackwardClick = {}, + onForwardClick = {}, + onSeek = {}, + modifier = Modifier.width(360.dp), + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PrezelPlayerPlaybackPreview() { + PrezelTheme { + var playing by remember { mutableStateOf(false) } + var currentMillis by remember { mutableLongStateOf(443_000L) } + val durationMillis = 690_000L + val progress = (currentMillis.toFloat() / durationMillis).coerceIn(0f, 1f) + + LaunchedEffect(playing) { + while (playing) { + delay(1_000L) + currentMillis = (currentMillis + 1_000L).coerceAtMost(durationMillis) + if (currentMillis == durationMillis) playing = false + } + } + + PreviewSection(title = "Player Playback") { + PlayerPreviewItem(name = if (playing) "playing" else "paused") { + PrezelPlayer( + playing = playing, + progress = progress, + durationMillis = durationMillis, + currentMillis = currentMillis, + markers = previewMarkers, + onPlayPauseClick = { playing = !playing }, + onBackwardClick = { currentMillis = (currentMillis - 5_000L).coerceAtLeast(0L) }, + onForwardClick = { currentMillis = (currentMillis + 5_000L).coerceAtMost(durationMillis) }, + onSeek = { seekProgress -> currentMillis = (durationMillis * seekProgress).toLong() }, + modifier = Modifier.width(360.dp), + showHandle = true, + ) + } + } + } +} + +@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 previewMarkers = persistentListOf( + PrezelPlayerResourceMarkerItem.speech(position = 0.2188f, type = PrezelSpeechMarkerType.WARNING), + PrezelPlayerResourceMarkerItem.speech(position = 0.4594f, type = PrezelSpeechMarkerType.GOOD), + PrezelPlayerResourceMarkerItem.speech(position = 0.6469f, type = PrezelSpeechMarkerType.WARNING), +) 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..07a9d101 --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerResourceMarker.kt @@ -0,0 +1,60 @@ +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.runtime.Immutable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.preview.PreviewSection +import com.team.prezel.core.designsystem.theme.PrezelTheme + +@Immutable +enum class PrezelPlayerResourceMarkerType { + GOOD, + WARNING, + NEUTRAL, +} + +internal val PlayerMarkerSize = 8.dp + +@Composable +fun PrezelPlayerResourceMarker( + type: PrezelPlayerResourceMarkerType, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .size(PlayerMarkerSize) + .clip(PrezelTheme.shapes.V1000) + .background(type.color), + ) +} + +val PrezelPlayerResourceMarkerType.color: Color + @Composable + get() = when (this) { + PrezelPlayerResourceMarkerType.GOOD -> PrezelTheme.colors.feedbackGoodRegular + PrezelPlayerResourceMarkerType.WARNING -> PrezelTheme.colors.feedbackWarningRegular + PrezelPlayerResourceMarkerType.NEUTRAL -> PrezelTheme.colors.iconRegular + } + +@Preview(showBackground = true) +@Composable +private fun PrezelPlayerResourceMarkerPreview() { + PrezelTheme { + PreviewSection(title = "Player Resource Marker") { + Row(horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16)) { + PrezelPlayerResourceMarker(type = PrezelPlayerResourceMarkerType.GOOD) + PrezelPlayerResourceMarker(type = PrezelPlayerResourceMarkerType.WARNING) + PrezelPlayerResourceMarker(type = PrezelPlayerResourceMarkerType.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..08b5a54f --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerResourceTrack.kt @@ -0,0 +1,394 @@ +package com.team.prezel.core.designsystem.component.player + +import androidx.annotation.FloatRange +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.Row +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.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +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.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +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 + +@Immutable +enum class PrezelPlayerResourceTrackType { + SPEECH, + SCRIPT_MATCH, +} + +@Immutable +enum class PrezelSpeechMarkerType { + GOOD, + WARNING, +} + +@Immutable +enum class PrezelScriptMatchMarkerType { + GOOD, + NEUTRAL, +} + +@Immutable +class PrezelPlayerResourceMarkerItem private constructor( + @param:FloatRange(from = 0.0, to = 1.0) + val position: Float, + val type: PrezelPlayerResourceMarkerType, + internal val trackType: PrezelPlayerResourceTrackType, +) { + companion object { + fun speech( + @FloatRange(from = 0.0, to = 1.0) position: Float, + type: PrezelSpeechMarkerType, + ): PrezelPlayerResourceMarkerItem = + PrezelPlayerResourceMarkerItem( + position = position, + type = type.markerType, + trackType = PrezelPlayerResourceTrackType.SPEECH, + ) + + fun scriptMatch( + @FloatRange(from = 0.0, to = 1.0) position: Float, + type: PrezelScriptMatchMarkerType, + ): PrezelPlayerResourceMarkerItem = + PrezelPlayerResourceMarkerItem( + position = position, + type = type.markerType, + trackType = PrezelPlayerResourceTrackType.SCRIPT_MATCH, + ) + } +} + +@Composable +fun PrezelPlayerResourceTrack( + @FloatRange(from = 0.0, to = 1.0) progress: Float, + durationMillis: Long, + currentMillis: Long, + markers: ImmutableList, + onSeek: (Float) -> Unit, + modifier: Modifier = Modifier, + type: PrezelPlayerResourceTrackType = PrezelPlayerResourceTrackType.SPEECH, + idle: Boolean = false, + showHandle: Boolean = false, +) { + val coercedProgress = progress.coerceIn(0f, 1f) + val displayedProgress = if (idle) 0f else coercedProgress + val displayedCurrentMillis = if (idle) 0L else currentMillis + + Column( + modifier = modifier.height(PlayerTrackHeight), + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), + ) { + PrezelPlayerTimeline( + progress = displayedProgress, + markers = markers, + type = type, + showHandle = showHandle, + onSeek = onSeek, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = displayedCurrentMillis.formatPlayerTime(), + style = PrezelTheme.typography.caption2Medium, + color = PrezelTheme.colors.textRegular, + ) + Text( + text = durationMillis.formatPlayerTime(), + style = PrezelTheme.typography.caption2Medium, + color = PrezelTheme.colors.textRegular, + ) + } + } +} + +@Composable +private fun PrezelPlayerTimeline( + progress: Float, + markers: ImmutableList, + type: PrezelPlayerResourceTrackType, + showHandle: Boolean, + onSeek: (Float) -> 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, + type = type, + onWidthChanged = { widthPx = it }, + onSeekTo = ::seekTo, + ), + contentAlignment = Alignment.CenterStart, + ) { + PlayerTimelineBar( + progress = progress, + playedBarVisible = !showHandle, + ) + PlayerTimelineMarkers(markers = markers, type = type) + + if (showHandle) PlayerTimelineHandle(progress = progress, zIndex = markers.size + 1f) + } +} + +private fun Modifier.playerTimelineModifier( + progress: Float, + type: PrezelPlayerResourceTrackType, + onWidthChanged: (Int) -> Unit, + onSeekTo: (Float) -> Unit, +): Modifier = + fillMaxWidth() + .height(PlayerTimelineHeight) + .onSizeChanged { onWidthChanged(it.width) } + .pointerInput(onSeekTo) { + detectTapGestures { offset -> onSeekTo(offset.x) } + }.pointerInput(onSeekTo) { + detectHorizontalDragGestures { change, _ -> onSeekTo(change.position.x) } + }.semantics { + progressBarRangeInfo = ProgressBarRangeInfo(current = progress, range = 0f..1f) + contentDescription = type.contentDescription + } + +@Composable +private fun PlayerTimelineBar( + progress: Float, + playedBarVisible: Boolean, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(PlayerBarHeight) + .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(PlayerBarHeight) + .clip(PrezelTheme.shapes.V1000) + .background(PrezelTheme.colors.bgLarge), + ) +} + +@Composable +private fun BoxWithConstraintsScope.PlayerTimelineMarkers( + markers: ImmutableList, + type: PrezelPlayerResourceTrackType, +) { + var visibleMarkerIndex = 0 + markers.forEach { marker -> + if (marker.trackType == type) { + PrezelPlayerResourceMarker( + type = marker.type, + modifier = Modifier + .align(Alignment.CenterStart) + .offset { + val markerX = ((maxWidth.toPx() - PlayerMarkerSize.toPx()) * marker.position.coerceIn(0f, 1f)).roundToInt() + IntOffset(x = markerX, y = 0) + }.zIndex(visibleMarkerIndex.toFloat()), + ) + visibleMarkerIndex += 1 + } + } +} + +@Composable +private fun BoxWithConstraintsScope.PlayerTimelineHandle( + progress: Float, + zIndex: Float, +) { + Box( + modifier = Modifier + .align(Alignment.CenterStart) + .offset { + val handleX = ((maxWidth.toPx() - PlayerHandleSize.toPx()) * progress.coerceIn(0f, 1f)).roundToInt() + IntOffset(x = handleX, y = 0) + }.size(PlayerHandleSize) + .clip(PrezelTheme.shapes.V1000) + .background(PrezelTheme.colors.iconLarge) + .zIndex(zIndex), + ) +} + +private val PrezelPlayerResourceTrackType.contentDescription: String + get() = when (this) { + PrezelPlayerResourceTrackType.SPEECH -> "Speech track" + PrezelPlayerResourceTrackType.SCRIPT_MATCH -> "Script match track" + } + +private val PrezelSpeechMarkerType.markerType: PrezelPlayerResourceMarkerType + get() = when (this) { + PrezelSpeechMarkerType.GOOD -> PrezelPlayerResourceMarkerType.GOOD + PrezelSpeechMarkerType.WARNING -> PrezelPlayerResourceMarkerType.WARNING + } + +private val PrezelScriptMatchMarkerType.markerType: PrezelPlayerResourceMarkerType + get() = when (this) { + PrezelScriptMatchMarkerType.GOOD -> PrezelPlayerResourceMarkerType.GOOD + PrezelScriptMatchMarkerType.NEUTRAL -> PrezelPlayerResourceMarkerType.NEUTRAL + } + +private fun Long.formatPlayerTime(): String { + val totalSeconds = coerceAtLeast(0L) / 1_000L + val minutes = totalSeconds / 60L + val seconds = totalSeconds % 60L + return "${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}" +} + +private val PlayerTrackHeight = 40.dp +private val PlayerTimelineHeight = 16.dp +private val PlayerBarHeight = 8.dp +private val PlayerHandleSize = 16.dp + +@Preview(showBackground = true) +@Composable +private fun PrezelPlayerResourceTrackPreview() { + PrezelTheme { + PreviewSection(title = "Player Resource Track") { + PreviewColumn { + PlayerResourceTrackPreviewItem(name = "type=speech / idle=off / showHandle=off") { + PrezelPlayerResourceTrack( + progress = 0.671f, + durationMillis = 690_000, + currentMillis = 443_000, + markers = previewSpeechMarkers, + onSeek = {}, + modifier = Modifier.width(320.dp), + type = PrezelPlayerResourceTrackType.SPEECH, + idle = false, + showHandle = false, + ) + } + PlayerResourceTrackPreviewItem(name = "type=scriptMatch / idle=off / showHandle=off") { + PrezelPlayerResourceTrack( + progress = 0.671f, + durationMillis = 690_000, + currentMillis = 443_000, + markers = previewScriptMatchMarkers, + onSeek = {}, + modifier = Modifier.width(320.dp), + type = PrezelPlayerResourceTrackType.SCRIPT_MATCH, + idle = false, + showHandle = false, + ) + } + PlayerResourceTrackPreviewItem(name = "idle=on") { + PrezelPlayerResourceTrack( + progress = 0.671f, + durationMillis = 690_000, + currentMillis = 443_000, + markers = previewSpeechMarkers, + onSeek = {}, + modifier = Modifier.width(320.dp), + idle = true, + ) + } + PlayerResourceTrackPreviewItem(name = "showHandle=on") { + PrezelPlayerResourceTrack( + progress = 0.671f, + durationMillis = 690_000, + currentMillis = 443_000, + markers = previewSpeechMarkers, + onSeek = {}, + modifier = Modifier.width(320.dp), + showHandle = true, + ) + } + PlayerResourceTrackPreviewItem(name = "overlapping markers") { + PrezelPlayerResourceTrack( + progress = 0.671f, + durationMillis = 690_000, + currentMillis = 443_000, + markers = previewOverlappingMarkers, + 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 previewSpeechMarkers = persistentListOf( + PrezelPlayerResourceMarkerItem.speech(position = 0.2188f, type = PrezelSpeechMarkerType.WARNING), + PrezelPlayerResourceMarkerItem.speech(position = 0.4594f, type = PrezelSpeechMarkerType.GOOD), + PrezelPlayerResourceMarkerItem.speech(position = 0.6469f, type = PrezelSpeechMarkerType.WARNING), +) + +private val previewScriptMatchMarkers = persistentListOf( + PrezelPlayerResourceMarkerItem.scriptMatch(position = 0.4594f, type = PrezelScriptMatchMarkerType.GOOD), + PrezelPlayerResourceMarkerItem.scriptMatch(position = 0.6469f, type = PrezelScriptMatchMarkerType.NEUTRAL), +) + +private val previewOverlappingMarkers = persistentListOf( + PrezelPlayerResourceMarkerItem.speech(position = 0.4594f, type = PrezelSpeechMarkerType.GOOD), + PrezelPlayerResourceMarkerItem.speech(position = 0.6344f, type = PrezelSpeechMarkerType.GOOD), + PrezelPlayerResourceMarkerItem.speech(position = 0.2188f, type = PrezelSpeechMarkerType.WARNING), + PrezelPlayerResourceMarkerItem.speech(position = 0.6469f, type = PrezelSpeechMarkerType.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 @@ + + + From acc5f3c2d34b3c1732ecf7421787ba75f67b5a6b Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Thu, 7 May 2026 01:00:59 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20=EC=98=A4=EB=94=94=EC=98=A4=20?= =?UTF-8?q?=ED=94=8C=EB=A0=88=EC=9D=B4=EC=96=B4(`PrezelPlayer`)=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20=ED=8A=B8=EB=9E=99=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: `PrezelPlayer` 및 관련 컴포넌트 추가** * 재생/일시정지, 전/후 이동 제어 및 프로그레스 바를 포함한 `PrezelPlayer` 컴포넌트를 구현했습니다. * `PrezelPlayerResourceTrack`: 재생 진행 상태, 시간 표시, 마커(Marker) 표시 및 탐색(Seek) 기능을 지원하는 트랙 컴포넌트를 추가했습니다. * `PrezelPlayerResourceMarker`: 트랙 위에 표시될 상태 마커(`GOOD`, `WARNING`, `NEUTRAL`) 컴포넌트를 추가했습니다. * **feat: 플레이어 제어용 아이콘 및 데이터 모델 추가** * `PrezelIcons`: `SkipBackward`, `SkipForward` 아이콘 리소스를 추가하고 등록했습니다. * `PrezelPlayerResourceMarkerItem`: 트랙의 마커 데이터를 정의하기 위한 sealed 구조의 모델과 팩토리 메서드(`speech`, `scriptMatch`)를 추가했습니다. * `PrezelPlayerResourceTrackType`: 음성(`SPEECH`) 및 대본 일치(`SCRIPT_MATCH`) 트랙 타입을 정의하는 열거형을 추가했습니다. * **refactor: 플레이어 UI 스타일 및 인터랙션 구현** * 터치 및 드래그 제스처를 통한 재생 위치 탐색(Seek) 로직을 적용했습니다. * `playing` 상태에 따라 재생/일시정지 버튼의 색상 및 아이콘이 동적으로 변경되도록 구현했습니다. * 밀리초(Long) 단위를 `mm:ss` 형식으로 변환하는 `formatPlayerTime` 확장 함수를 추가했습니다. --- .../component/player/PrezelPlayer.kt | 259 +++++++++++ .../player/PrezelPlayerResourceMarker.kt | 60 +++ .../player/PrezelPlayerResourceTrack.kt | 409 ++++++++++++++++++ .../core/designsystem/icon/PrezelIcons.kt | 2 + .../core_designsystem_ic_backward.xml | 9 + .../drawable/core_designsystem_ic_forward.xml | 9 + 6 files changed, 748 insertions(+) create mode 100644 Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayer.kt create mode 100644 Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerResourceMarker.kt create mode 100644 Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerResourceTrack.kt create mode 100644 Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_backward.xml create mode 100644 Prezel/core/designsystem/src/main/res/drawable/core_designsystem_ic_forward.xml 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..4fb62987 --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayer.kt @@ -0,0 +1,259 @@ +package com.team.prezel.core.designsystem.component.player + +import androidx.annotation.FloatRange +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.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.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +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.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( + playing: Boolean, + @FloatRange(from = 0.0, to = 1.0) progress: Float, + durationMillis: Long, + currentMillis: Long, + markers: ImmutableList, + onPlayPauseClick: () -> Unit, + onBackwardClick: () -> Unit, + onForwardClick: () -> Unit, + onSeek: (Float) -> Unit, + modifier: Modifier = Modifier, + trackType: PrezelPlayerResourceTrackType = PrezelPlayerResourceTrackType.SPEECH, + idle: Boolean = false, + showHandle: Boolean = false, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = PrezelTheme.spacing.V20), + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), + ) { + PrezelPlayerTrackSection( + progress = progress, + durationMillis = durationMillis, + currentMillis = currentMillis, + markers = markers, + trackType = trackType, + idle = idle, + showHandle = showHandle, + onSeek = onSeek, + ) + + PrezelPlayerControls( + playing = playing, + onPlayPauseClick = onPlayPauseClick, + onBackwardClick = onBackwardClick, + onForwardClick = onForwardClick, + ) + } +} + +@Composable +private fun PrezelPlayerTrackSection( + @FloatRange(from = 0.0, to = 1.0) progress: Float, + durationMillis: Long, + currentMillis: Long, + markers: ImmutableList, + trackType: PrezelPlayerResourceTrackType, + idle: Boolean, + showHandle: Boolean, + onSeek: (Float) -> Unit, +) { + PrezelPlayerResourceTrack( + progress = progress, + durationMillis = durationMillis, + currentMillis = currentMillis, + markers = markers, + type = trackType, + idle = idle, + showHandle = showHandle, + onSeek = onSeek, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PrezelTheme.spacing.V20), + ) +} + +@Composable +private fun PrezelPlayerControls( + playing: Boolean, + onPlayPauseClick: () -> Unit, + onBackwardClick: () -> Unit, + onForwardClick: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + space = PrezelTheme.spacing.V24, + alignment = Alignment.CenterHorizontally, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + PrezelPlayerSeekButton(iconResId = PrezelIcons.SkipBackward, onClick = onBackwardClick) + PrezelPlayerPlayPauseButton( + playing = playing, + onClick = onPlayPauseClick, + modifier = Modifier.weight(1f), + ) + PrezelPlayerSeekButton(iconResId = PrezelIcons.SkipForward, onClick = onForwardClick) + } +} + +@Composable +private fun PrezelPlayerSeekButton( + iconResId: Int, + onClick: () -> Unit, +) { + PrezelIconButton( + iconResId = iconResId, + type = ButtonType.GHOST, + hierarchy = ButtonHierarchy.SECONDARY, + onClick = onClick, + modifier = Modifier.widthIn(min = 80.dp), + ) +} + +@Composable +private fun PrezelPlayerPlayPauseButton( + playing: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + PrezelIconButton( + iconResId = if (playing) PrezelIcons.Pause else PrezelIcons.Play, + onClick = onClick, + modifier = modifier, + 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, + ), + ) +} + +@Preview(showBackground = true) +@Composable +private fun PrezelPlayerPreview() { + PrezelTheme { + PreviewSection(title = "Player") { + PlayerPreviewItem(name = "playing=off") { + PrezelPlayer( + playing = false, + progress = 0.671f, + durationMillis = 690_000, + currentMillis = 443_000, + markers = previewMarkers, + onPlayPauseClick = {}, + onBackwardClick = {}, + onForwardClick = {}, + onSeek = {}, + modifier = Modifier.width(360.dp), + ) + } + PlayerPreviewItem(name = "playing=on") { + PrezelPlayer( + playing = true, + progress = 0.671f, + durationMillis = 690_000, + currentMillis = 443_000, + markers = previewMarkers, + onPlayPauseClick = {}, + onBackwardClick = {}, + onForwardClick = {}, + onSeek = {}, + modifier = Modifier.width(360.dp), + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PrezelPlayerPlaybackPreview() { + PrezelTheme { + var playing by remember { mutableStateOf(false) } + var currentMillis by remember { mutableLongStateOf(443_000L) } + val durationMillis = 690_000L + val progress = (currentMillis.toFloat() / durationMillis).coerceIn(0f, 1f) + + LaunchedEffect(playing) { + while (playing) { + delay(1_000L) + currentMillis = (currentMillis + 1_000L).coerceAtMost(durationMillis) + if (currentMillis == durationMillis) playing = false + } + } + + PreviewSection(title = "Player Playback") { + PlayerPreviewItem(name = if (playing) "playing" else "paused") { + PrezelPlayer( + playing = playing, + progress = progress, + durationMillis = durationMillis, + currentMillis = currentMillis, + markers = previewMarkers, + onPlayPauseClick = { playing = !playing }, + onBackwardClick = { currentMillis = (currentMillis - 5_000L).coerceAtLeast(0L) }, + onForwardClick = { currentMillis = (currentMillis + 5_000L).coerceAtMost(durationMillis) }, + onSeek = { seekProgress -> currentMillis = (durationMillis * seekProgress).toLong() }, + modifier = Modifier.width(360.dp), + showHandle = false, + ) + } + } + } +} + +@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 previewMarkers = persistentListOf( + PrezelPlayerResourceMarkerItem.speech(position = 0.2188f, type = PrezelSpeechMarkerType.WARNING), + PrezelPlayerResourceMarkerItem.speech(position = 0.4594f, type = PrezelSpeechMarkerType.GOOD), + PrezelPlayerResourceMarkerItem.speech(position = 0.6469f, type = PrezelSpeechMarkerType.WARNING), +) 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..07a9d101 --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerResourceMarker.kt @@ -0,0 +1,60 @@ +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.runtime.Immutable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.preview.PreviewSection +import com.team.prezel.core.designsystem.theme.PrezelTheme + +@Immutable +enum class PrezelPlayerResourceMarkerType { + GOOD, + WARNING, + NEUTRAL, +} + +internal val PlayerMarkerSize = 8.dp + +@Composable +fun PrezelPlayerResourceMarker( + type: PrezelPlayerResourceMarkerType, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .size(PlayerMarkerSize) + .clip(PrezelTheme.shapes.V1000) + .background(type.color), + ) +} + +val PrezelPlayerResourceMarkerType.color: Color + @Composable + get() = when (this) { + PrezelPlayerResourceMarkerType.GOOD -> PrezelTheme.colors.feedbackGoodRegular + PrezelPlayerResourceMarkerType.WARNING -> PrezelTheme.colors.feedbackWarningRegular + PrezelPlayerResourceMarkerType.NEUTRAL -> PrezelTheme.colors.iconRegular + } + +@Preview(showBackground = true) +@Composable +private fun PrezelPlayerResourceMarkerPreview() { + PrezelTheme { + PreviewSection(title = "Player Resource Marker") { + Row(horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16)) { + PrezelPlayerResourceMarker(type = PrezelPlayerResourceMarkerType.GOOD) + PrezelPlayerResourceMarker(type = PrezelPlayerResourceMarkerType.WARNING) + PrezelPlayerResourceMarker(type = PrezelPlayerResourceMarkerType.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..ad631f0b --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerResourceTrack.kt @@ -0,0 +1,409 @@ +package com.team.prezel.core.designsystem.component.player + +import androidx.annotation.FloatRange +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.Row +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.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.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.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +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 + +@Immutable +enum class PrezelPlayerResourceTrackType { + SPEECH, + SCRIPT_MATCH, +} + +@Immutable +enum class PrezelSpeechMarkerType { + GOOD, + WARNING, +} + +@Immutable +enum class PrezelScriptMatchMarkerType { + GOOD, + NEUTRAL, +} + +@Immutable +class PrezelPlayerResourceMarkerItem private constructor( + @param:FloatRange(from = 0.0, to = 1.0) + val position: Float, + val type: PrezelPlayerResourceMarkerType, + internal val trackType: PrezelPlayerResourceTrackType, +) { + companion object { + fun speech( + @FloatRange(from = 0.0, to = 1.0) position: Float, + type: PrezelSpeechMarkerType, + ): PrezelPlayerResourceMarkerItem = + PrezelPlayerResourceMarkerItem( + position = position, + type = type.markerType, + trackType = PrezelPlayerResourceTrackType.SPEECH, + ) + + fun scriptMatch( + @FloatRange(from = 0.0, to = 1.0) position: Float, + type: PrezelScriptMatchMarkerType, + ): PrezelPlayerResourceMarkerItem = + PrezelPlayerResourceMarkerItem( + position = position, + type = type.markerType, + trackType = PrezelPlayerResourceTrackType.SCRIPT_MATCH, + ) + } +} + +@Composable +fun PrezelPlayerResourceTrack( + @FloatRange(from = 0.0, to = 1.0) progress: Float, + durationMillis: Long, + currentMillis: Long, + markers: ImmutableList, + onSeek: (Float) -> Unit, + modifier: Modifier = Modifier, + type: PrezelPlayerResourceTrackType = PrezelPlayerResourceTrackType.SPEECH, + idle: Boolean = false, + showHandle: Boolean = false, +) { + val coercedProgress = progress.coerceIn(0f, 1f) + val displayedProgress = if (idle) 0f else coercedProgress + val displayedCurrentMillis = if (idle) 0L else currentMillis + + Column( + modifier = modifier.height(PlayerTrackHeight), + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), + ) { + PrezelPlayerTimeline( + progress = displayedProgress, + markers = markers, + type = type, + showHandle = showHandle, + onSeek = onSeek, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = displayedCurrentMillis.formatPlayerTime(), + style = PrezelTheme.typography.caption2Medium, + color = PrezelTheme.colors.textRegular, + ) + Text( + text = durationMillis.formatPlayerTime(), + style = PrezelTheme.typography.caption2Medium, + color = PrezelTheme.colors.textRegular, + ) + } + } +} + +@Composable +private fun PrezelPlayerTimeline( + progress: Float, + markers: ImmutableList, + type: PrezelPlayerResourceTrackType, + showHandle: Boolean, + onSeek: (Float) -> Unit, + modifier: Modifier = Modifier, +) { + var widthPx by remember { mutableIntStateOf(0) } + var dragging by remember { mutableStateOf(false) } + val handleVisible = showHandle || dragging + + fun seekTo(offsetX: Float) { + if (widthPx > 0) onSeek((offsetX / widthPx).coerceIn(0f, 1f)) + } + + BoxWithConstraints( + modifier = modifier.playerTimelineModifier( + progress = progress, + type = type, + onWidthChanged = { widthPx = it }, + onSeekTo = ::seekTo, + onDragStarted = { offsetX -> + dragging = true + seekTo(offsetX) + }, + onDragStopped = { dragging = false }, + ), + contentAlignment = Alignment.CenterStart, + ) { + PlayerTimelineBar( + progress = progress, + playedBarVisible = !handleVisible, + ) + PlayerTimelineMarkers(markers = markers, type = type) + + if (handleVisible) PlayerTimelineHandle(progress = progress, zIndex = markers.size + 1f) + } +} + +private fun Modifier.playerTimelineModifier( + progress: Float, + type: PrezelPlayerResourceTrackType, + onWidthChanged: (Int) -> Unit, + onSeekTo: (Float) -> Unit, + onDragStarted: (Float) -> Unit, + onDragStopped: () -> Unit, +): Modifier = + fillMaxWidth() + .height(PlayerTimelineHeight) + .onSizeChanged { onWidthChanged(it.width) } + .pointerInput(onSeekTo) { + detectTapGestures { offset -> onSeekTo(offset.x) } + }.pointerInput(onSeekTo, onDragStarted, onDragStopped) { + detectHorizontalDragGestures( + onDragStart = { offset -> onDragStarted(offset.x) }, + onDragEnd = onDragStopped, + onDragCancel = onDragStopped, + onHorizontalDrag = { change, _ -> onSeekTo(change.position.x) }, + ) + }.semantics { + progressBarRangeInfo = ProgressBarRangeInfo(current = progress, range = 0f..1f) + contentDescription = type.contentDescription + } + +@Composable +private fun PlayerTimelineBar( + progress: Float, + playedBarVisible: Boolean, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(PlayerBarHeight) + .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(PlayerBarHeight) + .clip(PrezelTheme.shapes.V1000) + .background(PrezelTheme.colors.bgLarge), + ) +} + +@Composable +private fun BoxWithConstraintsScope.PlayerTimelineMarkers( + markers: ImmutableList, + type: PrezelPlayerResourceTrackType, +) { + var visibleMarkerIndex = 0 + markers.forEach { marker -> + if (marker.trackType == type) { + PrezelPlayerResourceMarker( + type = marker.type, + modifier = Modifier + .align(Alignment.CenterStart) + .offset { + val markerX = ((maxWidth.toPx() - PlayerMarkerSize.toPx()) * marker.position.coerceIn(0f, 1f)).roundToInt() + IntOffset(x = markerX, y = 0) + }.zIndex(visibleMarkerIndex.toFloat()), + ) + visibleMarkerIndex += 1 + } + } +} + +@Composable +private fun BoxWithConstraintsScope.PlayerTimelineHandle( + progress: Float, + zIndex: Float, +) { + Box( + modifier = Modifier + .align(Alignment.CenterStart) + .offset { + val handleX = ((maxWidth.toPx() - PlayerHandleSize.toPx()) * progress.coerceIn(0f, 1f)).roundToInt() + IntOffset(x = handleX, y = 0) + }.size(PlayerHandleSize) + .clip(PrezelTheme.shapes.V1000) + .background(PrezelTheme.colors.iconLarge) + .zIndex(zIndex), + ) +} + +private val PrezelPlayerResourceTrackType.contentDescription: String + get() = when (this) { + PrezelPlayerResourceTrackType.SPEECH -> "Speech track" + PrezelPlayerResourceTrackType.SCRIPT_MATCH -> "Script match track" + } + +private val PrezelSpeechMarkerType.markerType: PrezelPlayerResourceMarkerType + get() = when (this) { + PrezelSpeechMarkerType.GOOD -> PrezelPlayerResourceMarkerType.GOOD + PrezelSpeechMarkerType.WARNING -> PrezelPlayerResourceMarkerType.WARNING + } + +private val PrezelScriptMatchMarkerType.markerType: PrezelPlayerResourceMarkerType + get() = when (this) { + PrezelScriptMatchMarkerType.GOOD -> PrezelPlayerResourceMarkerType.GOOD + PrezelScriptMatchMarkerType.NEUTRAL -> PrezelPlayerResourceMarkerType.NEUTRAL + } + +private fun Long.formatPlayerTime(): String { + val totalSeconds = coerceAtLeast(0L) / 1_000L + val minutes = totalSeconds / 60L + val seconds = totalSeconds % 60L + return "${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}" +} + +private val PlayerTrackHeight = 40.dp +private val PlayerTimelineHeight = 16.dp +private val PlayerBarHeight = 8.dp +private val PlayerHandleSize = 16.dp + +@Preview(showBackground = true) +@Composable +private fun PrezelPlayerResourceTrackPreview() { + PrezelTheme { + PreviewSection(title = "Player Resource Track") { + PreviewColumn { + PlayerResourceTrackPreviewItem(name = "type=speech / idle=off / showHandle=off") { + PrezelPlayerResourceTrack( + progress = 0.671f, + durationMillis = 690_000, + currentMillis = 443_000, + markers = previewSpeechMarkers, + onSeek = {}, + modifier = Modifier.width(320.dp), + type = PrezelPlayerResourceTrackType.SPEECH, + idle = false, + showHandle = false, + ) + } + PlayerResourceTrackPreviewItem(name = "type=scriptMatch / idle=off / showHandle=off") { + PrezelPlayerResourceTrack( + progress = 0.671f, + durationMillis = 690_000, + currentMillis = 443_000, + markers = previewScriptMatchMarkers, + onSeek = {}, + modifier = Modifier.width(320.dp), + type = PrezelPlayerResourceTrackType.SCRIPT_MATCH, + idle = false, + showHandle = false, + ) + } + PlayerResourceTrackPreviewItem(name = "idle=on") { + PrezelPlayerResourceTrack( + progress = 0.671f, + durationMillis = 690_000, + currentMillis = 443_000, + markers = previewSpeechMarkers, + onSeek = {}, + modifier = Modifier.width(320.dp), + idle = true, + ) + } + PlayerResourceTrackPreviewItem(name = "showHandle=on") { + PrezelPlayerResourceTrack( + progress = 0.671f, + durationMillis = 690_000, + currentMillis = 443_000, + markers = previewSpeechMarkers, + onSeek = {}, + modifier = Modifier.width(320.dp), + showHandle = true, + ) + } + PlayerResourceTrackPreviewItem(name = "overlapping markers") { + PrezelPlayerResourceTrack( + progress = 0.671f, + durationMillis = 690_000, + currentMillis = 443_000, + markers = previewOverlappingMarkers, + 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 previewSpeechMarkers = persistentListOf( + PrezelPlayerResourceMarkerItem.speech(position = 0.2188f, type = PrezelSpeechMarkerType.WARNING), + PrezelPlayerResourceMarkerItem.speech(position = 0.4594f, type = PrezelSpeechMarkerType.GOOD), + PrezelPlayerResourceMarkerItem.speech(position = 0.6469f, type = PrezelSpeechMarkerType.WARNING), +) + +private val previewScriptMatchMarkers = persistentListOf( + PrezelPlayerResourceMarkerItem.scriptMatch(position = 0.4594f, type = PrezelScriptMatchMarkerType.GOOD), + PrezelPlayerResourceMarkerItem.scriptMatch(position = 0.6469f, type = PrezelScriptMatchMarkerType.NEUTRAL), +) + +private val previewOverlappingMarkers = persistentListOf( + PrezelPlayerResourceMarkerItem.speech(position = 0.4594f, type = PrezelSpeechMarkerType.GOOD), + PrezelPlayerResourceMarkerItem.speech(position = 0.6344f, type = PrezelSpeechMarkerType.GOOD), + PrezelPlayerResourceMarkerItem.speech(position = 0.2188f, type = PrezelSpeechMarkerType.WARNING), + PrezelPlayerResourceMarkerItem.speech(position = 0.6469f, type = PrezelSpeechMarkerType.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 @@ + + + From d092f7ce9d21b0dfbd64a8bf3ab106b352e986fb Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Thu, 7 May 2026 12:14:46 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20`PrezelPlayer`=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B3=A0=EB=8F=84=ED=99=94=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 플레이어 데이터 모델 및 마커 인터페이스 정의** * `PrezelPlayerResourceMarkerItem` sealed interface를 추가하여 `Speech`와 `ScriptMatch` 트랙 마커를 구분하고, `timeSeconds` 기반으로 위치를 계산하도록 개선했습니다. * 마커 타입(`GOOD`, `WARNING`, `NEUTRAL`) 및 트랙 타입(`SPEECH`, `SCRIPT_MATCH`)을 정의하는 Enum 클래스들을 추가했습니다. * **refactor: `PrezelPlayer` 및 관련 컴포넌트 구조 개선** * `PrezelPlayerResourceTrack`: 외부에서 `progress`를 주입받는 대신 `currentMillis`와 `durationMillis`를 통해 내부에서 계산하도록 로직을 변경했습니다. * `PrezelPlayerTimeline`: 타임라인 로직을 별도 컴포넌트로 분리하고, 드래그/탭 제스처를 통한 탐색(Seek) 및 마커 렌더링 로직을 통합했습니다. * 컴포넌트 내부에 흩어져 있던 Preview 코드들을 `PrezelPlayerPreview.kt`로 분리하여 관리 효율성을 높였습니다. * **feat: 접근성(Accessibility) 및 다국어 지원 추가** * 재생, 일시정지, 앞/뒤로 이동 버튼 및 각 트랙 타임라인에 대한 `contentDescription`을 추가했습니다. * `strings.xml`에 플레이어 조작 및 트랙 설명 관련 리소스를 정의했습니다. * **fix: 플레이어 로직 안정화** * 시간 표시 포맷팅 로직(`formatPlayerTime`) 및 재생 시간 제한(`coercePlayerMillis`) 유틸리티 함수를 추가했습니다. * `progress` 계산 시 0으로 나누기(Division by zero) 방지 로직을 적용했습니다. --- .../component/player/PrezelPlayer.kt | 57 ++-- .../component/player/PrezelPlayerModels.kt | 58 ++++ .../player/PrezelPlayerResourceMarker.kt | 6 +- .../player/PrezelPlayerResourceTrack.kt | 302 +++--------------- .../component/player/PrezelPlayerTimeline.kt | 214 +++++++++++++ .../src/main/res/values/strings.xml | 6 + 6 files changed, 365 insertions(+), 278 deletions(-) create mode 100644 Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerModels.kt create mode 100644 Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerTimeline.kt 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 index 4fb62987..aa1ea210 100644 --- 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 @@ -1,6 +1,5 @@ package com.team.prezel.core.designsystem.component.player -import androidx.annotation.FloatRange import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -18,8 +17,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue 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.tooling.preview.Preview 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 @@ -35,7 +38,6 @@ import kotlinx.coroutines.delay @Composable fun PrezelPlayer( playing: Boolean, - @FloatRange(from = 0.0, to = 1.0) progress: Float, durationMillis: Long, currentMillis: Long, markers: ImmutableList, @@ -55,7 +57,6 @@ fun PrezelPlayer( verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), ) { PrezelPlayerTrackSection( - progress = progress, durationMillis = durationMillis, currentMillis = currentMillis, markers = markers, @@ -76,7 +77,6 @@ fun PrezelPlayer( @Composable private fun PrezelPlayerTrackSection( - @FloatRange(from = 0.0, to = 1.0) progress: Float, durationMillis: Long, currentMillis: Long, markers: ImmutableList, @@ -86,7 +86,6 @@ private fun PrezelPlayerTrackSection( onSeek: (Float) -> Unit, ) { PrezelPlayerResourceTrack( - progress = progress, durationMillis = durationMillis, currentMillis = currentMillis, markers = markers, @@ -115,19 +114,28 @@ private fun PrezelPlayerControls( ), verticalAlignment = Alignment.CenterVertically, ) { - PrezelPlayerSeekButton(iconResId = PrezelIcons.SkipBackward, onClick = onBackwardClick) + PrezelPlayerSeekButton( + iconResId = PrezelIcons.SkipBackward, + contentDescription = stringResource(R.string.core_designsystem_player_backward_desc), + onClick = onBackwardClick, + ) PrezelPlayerPlayPauseButton( playing = playing, onClick = onPlayPauseClick, modifier = Modifier.weight(1f), ) - PrezelPlayerSeekButton(iconResId = PrezelIcons.SkipForward, onClick = onForwardClick) + PrezelPlayerSeekButton( + iconResId = PrezelIcons.SkipForward, + contentDescription = stringResource(R.string.core_designsystem_player_forward_desc), + onClick = onForwardClick, + ) } } @Composable private fun PrezelPlayerSeekButton( iconResId: Int, + contentDescription: String, onClick: () -> Unit, ) { PrezelIconButton( @@ -135,7 +143,9 @@ private fun PrezelPlayerSeekButton( type = ButtonType.GHOST, hierarchy = ButtonHierarchy.SECONDARY, onClick = onClick, - modifier = Modifier.widthIn(min = 80.dp), + modifier = Modifier + .widthIn(min = 80.dp) + .semantics { this.contentDescription = contentDescription }, ) } @@ -145,10 +155,19 @@ private fun PrezelPlayerPlayPauseButton( 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, + modifier = modifier.semantics { + contentDescription = if (playing) { + pauseContentDescription + } else { + playContentDescription + } + }, buttonDefault = PrezelButtonDefaults.getDefault( isIconOnly = true, type = ButtonType.FILLED, @@ -169,9 +188,8 @@ private fun PrezelPlayerPreview() { PlayerPreviewItem(name = "playing=off") { PrezelPlayer( playing = false, - progress = 0.671f, - durationMillis = 690_000, - currentMillis = 443_000, + durationMillis = 690_000L, + currentMillis = 100_000L, markers = previewMarkers, onPlayPauseClick = {}, onBackwardClick = {}, @@ -183,9 +201,8 @@ private fun PrezelPlayerPreview() { PlayerPreviewItem(name = "playing=on") { PrezelPlayer( playing = true, - progress = 0.671f, - durationMillis = 690_000, - currentMillis = 443_000, + durationMillis = 690_000L, + currentMillis = 100_000L, markers = previewMarkers, onPlayPauseClick = {}, onBackwardClick = {}, @@ -203,9 +220,8 @@ private fun PrezelPlayerPreview() { private fun PrezelPlayerPlaybackPreview() { PrezelTheme { var playing by remember { mutableStateOf(false) } - var currentMillis by remember { mutableLongStateOf(443_000L) } + var currentMillis by remember { mutableLongStateOf(100_000L) } val durationMillis = 690_000L - val progress = (currentMillis.toFloat() / durationMillis).coerceIn(0f, 1f) LaunchedEffect(playing) { while (playing) { @@ -219,7 +235,6 @@ private fun PrezelPlayerPlaybackPreview() { PlayerPreviewItem(name = if (playing) "playing" else "paused") { PrezelPlayer( playing = playing, - progress = progress, durationMillis = durationMillis, currentMillis = currentMillis, markers = previewMarkers, @@ -253,7 +268,7 @@ private fun PlayerPreviewItem( } private val previewMarkers = persistentListOf( - PrezelPlayerResourceMarkerItem.speech(position = 0.2188f, type = PrezelSpeechMarkerType.WARNING), - PrezelPlayerResourceMarkerItem.speech(position = 0.4594f, type = PrezelSpeechMarkerType.GOOD), - PrezelPlayerResourceMarkerItem.speech(position = 0.6469f, type = PrezelSpeechMarkerType.WARNING), + PrezelPlayerResourceMarkerItem.speech(timeSeconds = 151, type = PrezelSpeechMarkerType.WARNING), + PrezelPlayerResourceMarkerItem.speech(timeSeconds = 317, type = PrezelSpeechMarkerType.GOOD), + PrezelPlayerResourceMarkerItem.speech(timeSeconds = 443, type = PrezelSpeechMarkerType.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..6437c086 --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerModels.kt @@ -0,0 +1,58 @@ +package com.team.prezel.core.designsystem.component.player + +import androidx.compose.runtime.Immutable + +@Immutable +enum class PrezelPlayerResourceTrackType { + SPEECH, + SCRIPT_MATCH, +} + +@Immutable +enum class PrezelSpeechMarkerType { + GOOD, + WARNING, +} + +@Immutable +enum class PrezelScriptMatchMarkerType { + GOOD, + NEUTRAL, +} + +@Immutable +sealed interface PrezelPlayerResourceMarkerItem { + val timeSeconds: Long + + @Immutable + data class Speech( + override val timeSeconds: Long, + val type: PrezelSpeechMarkerType, + ) : PrezelPlayerResourceMarkerItem + + @Immutable + data class ScriptMatch( + override val timeSeconds: Long, + val type: PrezelScriptMatchMarkerType, + ) : PrezelPlayerResourceMarkerItem + + companion object { + fun speech( + timeSeconds: Long, + type: PrezelSpeechMarkerType, + ): PrezelPlayerResourceMarkerItem = + Speech( + timeSeconds = timeSeconds, + type = type, + ) + + fun scriptMatch( + timeSeconds: Long, + type: PrezelScriptMatchMarkerType, + ): PrezelPlayerResourceMarkerItem = + ScriptMatch( + timeSeconds = timeSeconds, + type = type, + ) + } +} 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 index 07a9d101..2bf3ddd4 100644 --- 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 @@ -22,8 +22,6 @@ enum class PrezelPlayerResourceMarkerType { NEUTRAL, } -internal val PlayerMarkerSize = 8.dp - @Composable fun PrezelPlayerResourceMarker( type: PrezelPlayerResourceMarkerType, @@ -31,13 +29,13 @@ fun PrezelPlayerResourceMarker( ) { Box( modifier = modifier - .size(PlayerMarkerSize) + .size(8.dp) .clip(PrezelTheme.shapes.V1000) .background(type.color), ) } -val PrezelPlayerResourceMarkerType.color: Color +private val PrezelPlayerResourceMarkerType.color: Color @Composable get() = when (this) { PrezelPlayerResourceMarkerType.GOOD -> PrezelTheme.colors.feedbackGoodRegular 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 index ad631f0b..374505fe 100644 --- 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 @@ -1,99 +1,27 @@ package com.team.prezel.core.designsystem.component.player -import androidx.annotation.FloatRange -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.Row 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.Immutable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -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.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.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex +import com.team.prezel.core.designsystem.R 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 - -@Immutable -enum class PrezelPlayerResourceTrackType { - SPEECH, - SCRIPT_MATCH, -} - -@Immutable -enum class PrezelSpeechMarkerType { - GOOD, - WARNING, -} - -@Immutable -enum class PrezelScriptMatchMarkerType { - GOOD, - NEUTRAL, -} - -@Immutable -class PrezelPlayerResourceMarkerItem private constructor( - @param:FloatRange(from = 0.0, to = 1.0) - val position: Float, - val type: PrezelPlayerResourceMarkerType, - internal val trackType: PrezelPlayerResourceTrackType, -) { - companion object { - fun speech( - @FloatRange(from = 0.0, to = 1.0) position: Float, - type: PrezelSpeechMarkerType, - ): PrezelPlayerResourceMarkerItem = - PrezelPlayerResourceMarkerItem( - position = position, - type = type.markerType, - trackType = PrezelPlayerResourceTrackType.SPEECH, - ) - - fun scriptMatch( - @FloatRange(from = 0.0, to = 1.0) position: Float, - type: PrezelScriptMatchMarkerType, - ): PrezelPlayerResourceMarkerItem = - PrezelPlayerResourceMarkerItem( - position = position, - type = type.markerType, - trackType = PrezelPlayerResourceTrackType.SCRIPT_MATCH, - ) - } -} @Composable fun PrezelPlayerResourceTrack( - @FloatRange(from = 0.0, to = 1.0) progress: Float, durationMillis: Long, currentMillis: Long, markers: ImmutableList, @@ -103,18 +31,21 @@ fun PrezelPlayerResourceTrack( idle: Boolean = false, showHandle: Boolean = false, ) { - val coercedProgress = progress.coerceIn(0f, 1f) - val displayedProgress = if (idle) 0f else coercedProgress - val displayedCurrentMillis = if (idle) 0L else currentMillis + val displayedDurationMillis = durationMillis.coerceAtLeast(0L) + val displayedCurrentMillis = if (idle) 0L else currentMillis.coercePlayerMillis(displayedDurationMillis) + val displayedProgress = if (idle) 0f else displayedCurrentMillis.toPlayerProgress(displayedDurationMillis) + val timelineContentDescription = stringResource(type.contentDescriptionResId) Column( - modifier = modifier.height(PlayerTrackHeight), + modifier = modifier.height(40.dp), verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), ) { PrezelPlayerTimeline( progress = displayedProgress, + durationMillis = displayedDurationMillis, markers = markers, type = type, + contentDescription = timelineContentDescription, showHandle = showHandle, onSeek = onSeek, modifier = Modifier @@ -122,175 +53,44 @@ fun PrezelPlayerResourceTrack( .fillMaxWidth(), ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = displayedCurrentMillis.formatPlayerTime(), - style = PrezelTheme.typography.caption2Medium, - color = PrezelTheme.colors.textRegular, - ) - Text( - text = durationMillis.formatPlayerTime(), - style = PrezelTheme.typography.caption2Medium, - color = PrezelTheme.colors.textRegular, - ) - } + PlayerTrackTimeLabels( + currentMillis = displayedCurrentMillis, + durationMillis = displayedDurationMillis, + ) } } @Composable -private fun PrezelPlayerTimeline( - progress: Float, - markers: ImmutableList, - type: PrezelPlayerResourceTrackType, - showHandle: Boolean, - onSeek: (Float) -> Unit, - modifier: Modifier = Modifier, +private fun PlayerTrackTimeLabels( + currentMillis: Long, + durationMillis: Long, ) { - var widthPx by remember { mutableIntStateOf(0) } - var dragging by remember { mutableStateOf(false) } - val handleVisible = showHandle || dragging - - fun seekTo(offsetX: Float) { - if (widthPx > 0) onSeek((offsetX / widthPx).coerceIn(0f, 1f)) - } - - BoxWithConstraints( - modifier = modifier.playerTimelineModifier( - progress = progress, - type = type, - onWidthChanged = { widthPx = it }, - onSeekTo = ::seekTo, - onDragStarted = { offsetX -> - dragging = true - seekTo(offsetX) - }, - onDragStopped = { dragging = false }, - ), - contentAlignment = Alignment.CenterStart, + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - PlayerTimelineBar( - progress = progress, - playedBarVisible = !handleVisible, + Text( + text = currentMillis.formatPlayerTime(), + style = PrezelTheme.typography.caption2Medium, + color = PrezelTheme.colors.textRegular, + ) + Text( + text = durationMillis.formatPlayerTime(), + style = PrezelTheme.typography.caption2Medium, + color = PrezelTheme.colors.textRegular, ) - PlayerTimelineMarkers(markers = markers, type = type) - - if (handleVisible) PlayerTimelineHandle(progress = progress, zIndex = markers.size + 1f) - } -} - -private fun Modifier.playerTimelineModifier( - progress: Float, - type: PrezelPlayerResourceTrackType, - onWidthChanged: (Int) -> Unit, - onSeekTo: (Float) -> Unit, - onDragStarted: (Float) -> Unit, - onDragStopped: () -> Unit, -): Modifier = - fillMaxWidth() - .height(PlayerTimelineHeight) - .onSizeChanged { onWidthChanged(it.width) } - .pointerInput(onSeekTo) { - detectTapGestures { offset -> onSeekTo(offset.x) } - }.pointerInput(onSeekTo, onDragStarted, onDragStopped) { - detectHorizontalDragGestures( - onDragStart = { offset -> onDragStarted(offset.x) }, - onDragEnd = onDragStopped, - onDragCancel = onDragStopped, - onHorizontalDrag = { change, _ -> onSeekTo(change.position.x) }, - ) - }.semantics { - progressBarRangeInfo = ProgressBarRangeInfo(current = progress, range = 0f..1f) - contentDescription = type.contentDescription - } - -@Composable -private fun PlayerTimelineBar( - progress: Float, - playedBarVisible: Boolean, -) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(PlayerBarHeight) - .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(PlayerBarHeight) - .clip(PrezelTheme.shapes.V1000) - .background(PrezelTheme.colors.bgLarge), - ) -} - -@Composable -private fun BoxWithConstraintsScope.PlayerTimelineMarkers( - markers: ImmutableList, - type: PrezelPlayerResourceTrackType, -) { - var visibleMarkerIndex = 0 - markers.forEach { marker -> - if (marker.trackType == type) { - PrezelPlayerResourceMarker( - type = marker.type, - modifier = Modifier - .align(Alignment.CenterStart) - .offset { - val markerX = ((maxWidth.toPx() - PlayerMarkerSize.toPx()) * marker.position.coerceIn(0f, 1f)).roundToInt() - IntOffset(x = markerX, y = 0) - }.zIndex(visibleMarkerIndex.toFloat()), - ) - visibleMarkerIndex += 1 - } } } -@Composable -private fun BoxWithConstraintsScope.PlayerTimelineHandle( - progress: Float, - zIndex: Float, -) { - Box( - modifier = Modifier - .align(Alignment.CenterStart) - .offset { - val handleX = ((maxWidth.toPx() - PlayerHandleSize.toPx()) * progress.coerceIn(0f, 1f)).roundToInt() - IntOffset(x = handleX, y = 0) - }.size(PlayerHandleSize) - .clip(PrezelTheme.shapes.V1000) - .background(PrezelTheme.colors.iconLarge) - .zIndex(zIndex), - ) -} - -private val PrezelPlayerResourceTrackType.contentDescription: String - get() = when (this) { - PrezelPlayerResourceTrackType.SPEECH -> "Speech track" - PrezelPlayerResourceTrackType.SCRIPT_MATCH -> "Script match track" +private fun Long.toPlayerProgress(durationMillis: Long): Float = + if (durationMillis <= 0L) { + 0f + } else { + (toFloat() / durationMillis.toFloat()).coerceIn(0f, 1f) } -private val PrezelSpeechMarkerType.markerType: PrezelPlayerResourceMarkerType - get() = when (this) { - PrezelSpeechMarkerType.GOOD -> PrezelPlayerResourceMarkerType.GOOD - PrezelSpeechMarkerType.WARNING -> PrezelPlayerResourceMarkerType.WARNING - } - -private val PrezelScriptMatchMarkerType.markerType: PrezelPlayerResourceMarkerType - get() = when (this) { - PrezelScriptMatchMarkerType.GOOD -> PrezelPlayerResourceMarkerType.GOOD - PrezelScriptMatchMarkerType.NEUTRAL -> PrezelPlayerResourceMarkerType.NEUTRAL - } +private fun Long.coercePlayerMillis(durationMillis: Long): Long = coerceIn(0L, durationMillis.coerceAtLeast(0L)) private fun Long.formatPlayerTime(): String { val totalSeconds = coerceAtLeast(0L) / 1_000L @@ -299,10 +99,11 @@ private fun Long.formatPlayerTime(): String { return "${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}" } -private val PlayerTrackHeight = 40.dp -private val PlayerTimelineHeight = 16.dp -private val PlayerBarHeight = 8.dp -private val PlayerHandleSize = 16.dp +private val PrezelPlayerResourceTrackType.contentDescriptionResId: Int + get() = when (this) { + PrezelPlayerResourceTrackType.SPEECH -> R.string.core_designsystem_player_speech_track_desc + PrezelPlayerResourceTrackType.SCRIPT_MATCH -> R.string.core_designsystem_player_script_match_track_desc + } @Preview(showBackground = true) @Composable @@ -312,7 +113,6 @@ private fun PrezelPlayerResourceTrackPreview() { PreviewColumn { PlayerResourceTrackPreviewItem(name = "type=speech / idle=off / showHandle=off") { PrezelPlayerResourceTrack( - progress = 0.671f, durationMillis = 690_000, currentMillis = 443_000, markers = previewSpeechMarkers, @@ -325,7 +125,6 @@ private fun PrezelPlayerResourceTrackPreview() { } PlayerResourceTrackPreviewItem(name = "type=scriptMatch / idle=off / showHandle=off") { PrezelPlayerResourceTrack( - progress = 0.671f, durationMillis = 690_000, currentMillis = 443_000, markers = previewScriptMatchMarkers, @@ -338,7 +137,6 @@ private fun PrezelPlayerResourceTrackPreview() { } PlayerResourceTrackPreviewItem(name = "idle=on") { PrezelPlayerResourceTrack( - progress = 0.671f, durationMillis = 690_000, currentMillis = 443_000, markers = previewSpeechMarkers, @@ -349,7 +147,6 @@ private fun PrezelPlayerResourceTrackPreview() { } PlayerResourceTrackPreviewItem(name = "showHandle=on") { PrezelPlayerResourceTrack( - progress = 0.671f, durationMillis = 690_000, currentMillis = 443_000, markers = previewSpeechMarkers, @@ -360,7 +157,6 @@ private fun PrezelPlayerResourceTrackPreview() { } PlayerResourceTrackPreviewItem(name = "overlapping markers") { PrezelPlayerResourceTrack( - progress = 0.671f, durationMillis = 690_000, currentMillis = 443_000, markers = previewOverlappingMarkers, @@ -391,19 +187,19 @@ private fun PlayerResourceTrackPreviewItem( } private val previewSpeechMarkers = persistentListOf( - PrezelPlayerResourceMarkerItem.speech(position = 0.2188f, type = PrezelSpeechMarkerType.WARNING), - PrezelPlayerResourceMarkerItem.speech(position = 0.4594f, type = PrezelSpeechMarkerType.GOOD), - PrezelPlayerResourceMarkerItem.speech(position = 0.6469f, type = PrezelSpeechMarkerType.WARNING), + PrezelPlayerResourceMarkerItem.speech(timeSeconds = 151, type = PrezelSpeechMarkerType.WARNING), + PrezelPlayerResourceMarkerItem.speech(timeSeconds = 317, type = PrezelSpeechMarkerType.GOOD), + PrezelPlayerResourceMarkerItem.speech(timeSeconds = 443, type = PrezelSpeechMarkerType.WARNING), ) private val previewScriptMatchMarkers = persistentListOf( - PrezelPlayerResourceMarkerItem.scriptMatch(position = 0.4594f, type = PrezelScriptMatchMarkerType.GOOD), - PrezelPlayerResourceMarkerItem.scriptMatch(position = 0.6469f, type = PrezelScriptMatchMarkerType.NEUTRAL), + PrezelPlayerResourceMarkerItem.scriptMatch(timeSeconds = 317, type = PrezelScriptMatchMarkerType.GOOD), + PrezelPlayerResourceMarkerItem.scriptMatch(timeSeconds = 443, type = PrezelScriptMatchMarkerType.NEUTRAL), ) private val previewOverlappingMarkers = persistentListOf( - PrezelPlayerResourceMarkerItem.speech(position = 0.4594f, type = PrezelSpeechMarkerType.GOOD), - PrezelPlayerResourceMarkerItem.speech(position = 0.6344f, type = PrezelSpeechMarkerType.GOOD), - PrezelPlayerResourceMarkerItem.speech(position = 0.2188f, type = PrezelSpeechMarkerType.WARNING), - PrezelPlayerResourceMarkerItem.speech(position = 0.6469f, type = PrezelSpeechMarkerType.WARNING), + PrezelPlayerResourceMarkerItem.speech(timeSeconds = 317, type = PrezelSpeechMarkerType.GOOD), + PrezelPlayerResourceMarkerItem.speech(timeSeconds = 438, type = PrezelSpeechMarkerType.GOOD), + PrezelPlayerResourceMarkerItem.speech(timeSeconds = 151, type = PrezelSpeechMarkerType.WARNING), + PrezelPlayerResourceMarkerItem.speech(timeSeconds = 443, type = PrezelSpeechMarkerType.WARNING), ) 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..618023f7 --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerTimeline.kt @@ -0,0 +1,214 @@ +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.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.BoxWithConstraintsScope +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.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.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.theme.PrezelTheme +import kotlinx.collections.immutable.ImmutableList +import kotlin.math.roundToInt + +@Composable +internal fun PrezelPlayerTimeline( + progress: Float, + durationMillis: Long, + markers: ImmutableList, + type: PrezelPlayerResourceTrackType, + contentDescription: String, + showHandle: Boolean, + onSeek: (Float) -> Unit, + modifier: Modifier = Modifier, +) { + var widthPx by remember { mutableIntStateOf(0) } + var dragging by remember { mutableStateOf(false) } + val handleVisible = showHandle || dragging + + 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 -> + dragging = true + seekTo(offsetX) + }, + onDragStopped = { dragging = false }, + ), + contentAlignment = Alignment.CenterStart, + ) { + PlayerTimelineBar( + progress = progress, + playedBarVisible = !handleVisible, + ) + PlayerTimelineMarkers( + markers = markers, + type = type, + durationMillis = durationMillis, + ) + + if (handleVisible) PlayerTimelineHandle(progress = progress, zIndex = markers.size + 1f) + } +} + +private fun Modifier.playerTimelineModifier( + progress: Float, + contentDescription: String, + onWidthChanged: (Int) -> Unit, + onSeekTo: (Float) -> Unit, + onSetProgress: (Float) -> Unit, + onDragStarted: (Float) -> Unit, + onDragStopped: () -> Unit, +): Modifier = + fillMaxWidth() + .height(16.dp) + .onSizeChanged { onWidthChanged(it.width) } + .pointerInput(onSeekTo) { + detectTapGestures { offset -> onSeekTo(offset.x) } + }.pointerInput(onSeekTo, onDragStarted, onDragStopped) { + detectHorizontalDragGestures( + onDragStart = { offset -> onDragStarted(offset.x) }, + onDragEnd = onDragStopped, + onDragCancel = onDragStopped, + onHorizontalDrag = { change, _ -> + onSeekTo(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( + markers: ImmutableList, + type: PrezelPlayerResourceTrackType, + durationMillis: Long, +) { + var visibleMarkerIndex = 0 + markers.forEach { marker -> + if (marker.matchesTrackType(type)) { + PrezelPlayerResourceMarker( + type = marker.resourceMarkerType, + modifier = Modifier + .align(Alignment.CenterStart) + .offset { + val markerX = ((maxWidth.toPx() - 8.dp.toPx()) * marker.progressIn(durationMillis)).roundToInt() + IntOffset(x = markerX, y = 0) + }.zIndex(visibleMarkerIndex.toFloat()), + ) + visibleMarkerIndex += 1 + } + } +} + +private fun PrezelPlayerResourceMarkerItem.matchesTrackType(type: PrezelPlayerResourceTrackType): Boolean = + when (type) { + PrezelPlayerResourceTrackType.SPEECH -> this is PrezelPlayerResourceMarkerItem.Speech + PrezelPlayerResourceTrackType.SCRIPT_MATCH -> this is PrezelPlayerResourceMarkerItem.ScriptMatch + } + +private val PrezelPlayerResourceMarkerItem.resourceMarkerType: PrezelPlayerResourceMarkerType + get() = when (this) { + is PrezelPlayerResourceMarkerItem.Speech -> type.markerType + is PrezelPlayerResourceMarkerItem.ScriptMatch -> type.markerType + } + +private fun PrezelPlayerResourceMarkerItem.progressIn(durationMillis: Long): Float = + if (durationMillis <= 0L) { + 0f + } else { + (timeSeconds * 1_000f / 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), + ) +} + +private val PrezelSpeechMarkerType.markerType: PrezelPlayerResourceMarkerType + get() = when (this) { + PrezelSpeechMarkerType.GOOD -> PrezelPlayerResourceMarkerType.GOOD + PrezelSpeechMarkerType.WARNING -> PrezelPlayerResourceMarkerType.WARNING + } + +private val PrezelScriptMatchMarkerType.markerType: PrezelPlayerResourceMarkerType + get() = when (this) { + PrezelScriptMatchMarkerType.GOOD -> PrezelPlayerResourceMarkerType.GOOD + PrezelScriptMatchMarkerType.NEUTRAL -> PrezelPlayerResourceMarkerType.NEUTRAL + } diff --git a/Prezel/core/designsystem/src/main/res/values/strings.xml b/Prezel/core/designsystem/src/main/res/values/strings.xml index f994348e..6e57056e 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월 + 뒤로 이동 + 앞으로 이동 + 일시정지 + 재생 + 스크립트 일치 트랙 + 발화 트랙 From 41248ebfad463e40254a581494c844d16e2597dd Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Thu, 7 May 2026 16:27:44 +0900 Subject: [PATCH 04/11] =?UTF-8?q?refactor:=20=EB=94=94=EC=9E=90=EC=9D=B8?= =?UTF-8?q?=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94=20=EB=B0=8F=20=EC=95=88=EC=A0=95=EC=84=B1=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: 불필요한 `@Immutable` 어노테이션 제거** * `enum class`는 기본적으로 불변(Immutable)으로 취급되므로, `PrezelPlayerResourceMarkerType`, `PrezelPlayerResourceTrackType`, `PrezelSpeechMarkerType`, `PrezelScriptMatchMarkerType`에 선언된 중복 어노테이션을 삭제했습니다. * **fix: `PrezelPlayerTimeline` 드래그 및 탭 이벤트 핸들링 개선** * `pointerInput` 내에서 외부 상태 참조 시 람다 캡처로 인한 부작용을 방지하기 위해 `rememberUpdatedState`를 적용했습니다. * `pointerInput`의 key를 `Unit`으로 설정하여 불필요한 코루틴 재실행을 방지하고, 최신 콜백(`onSeekTo`, `onDragStarted`, `onDragStopped`)을 안전하게 호출하도록 수정했습니다. * `playerTimelineModifier` 확장 함수에 `@Composable` 어노테이션을 추가했습니다. --- .../component/player/PrezelPlayerModels.kt | 3 --- .../player/PrezelPlayerResourceMarker.kt | 2 -- .../component/player/PrezelPlayerTimeline.kt | 25 ++++++++++++------- .../domain/repository/auth/AuthRepository.kt | 15 +++++++++++ .../usecase/auth/CheckLoginStatusUseCase.kt | 21 ++++++++++++++++ .../core/domain/usecase/auth/LoginUseCase.kt | 19 ++++++++++++++ .../core/domain/usecase/auth/LogoutUseCase.kt | 18 +++++++++++++ .../domain/usecase/auth/WithdrawUseCase.kt | 19 ++++++++++++++ 8 files changed, 108 insertions(+), 14 deletions(-) create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LoginUseCase.kt create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt create mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt 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 index 6437c086..6559ed90 100644 --- 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 @@ -2,19 +2,16 @@ package com.team.prezel.core.designsystem.component.player import androidx.compose.runtime.Immutable -@Immutable enum class PrezelPlayerResourceTrackType { SPEECH, SCRIPT_MATCH, } -@Immutable enum class PrezelSpeechMarkerType { GOOD, WARNING, } -@Immutable enum class PrezelScriptMatchMarkerType { GOOD, NEUTRAL, 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 index 2bf3ddd4..f2068ee0 100644 --- 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 @@ -6,7 +6,6 @@ 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.runtime.Immutable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -15,7 +14,6 @@ import androidx.compose.ui.unit.dp import com.team.prezel.core.designsystem.preview.PreviewSection import com.team.prezel.core.designsystem.theme.PrezelTheme -@Immutable enum class PrezelPlayerResourceMarkerType { GOOD, WARNING, 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 index 618023f7..2ef2d509 100644 --- 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 @@ -15,6 +15,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf 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 @@ -81,6 +82,7 @@ internal fun PrezelPlayerTimeline( } } +@Composable private fun Modifier.playerTimelineModifier( progress: Float, contentDescription: String, @@ -89,19 +91,23 @@ private fun Modifier.playerTimelineModifier( onSetProgress: (Float) -> Unit, onDragStarted: (Float) -> Unit, onDragStopped: () -> Unit, -): Modifier = - fillMaxWidth() +): 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(onSeekTo) { - detectTapGestures { offset -> onSeekTo(offset.x) } - }.pointerInput(onSeekTo, onDragStarted, onDragStopped) { + .pointerInput(Unit) { + detectTapGestures { offset -> currentOnSeekTo(offset.x) } + }.pointerInput(Unit) { detectHorizontalDragGestures( - onDragStart = { offset -> onDragStarted(offset.x) }, - onDragEnd = onDragStopped, - onDragCancel = onDragStopped, + onDragStart = { offset -> currentOnDragStarted(offset.x) }, + onDragEnd = currentOnDragStopped, + onDragCancel = currentOnDragStopped, onHorizontalDrag = { change, _ -> - onSeekTo(change.position.x) + currentOnSeekTo(change.position.x) change.consume() }, ) @@ -113,6 +119,7 @@ private fun Modifier.playerTimelineModifier( true } } +} @Composable private fun PlayerTimelineBar( diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt new file mode 100644 index 00000000..64b8efdd --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt @@ -0,0 +1,15 @@ +package com.team.prezel.core.domain.repository.auth + +import com.team.prezel.core.model.auth.LoginStatus +import com.team.prezel.core.model.auth.WithdrawReason +import kotlinx.coroutines.flow.Flow + +interface AuthRepository { + val loginStatus: Flow + + suspend fun logout(): Result + + suspend fun login(idToken: String): Result + + suspend fun withdraw(reason: WithdrawReason): Result +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt new file mode 100644 index 00000000..c68009a6 --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt @@ -0,0 +1,21 @@ +package com.team.prezel.core.domain.usecase.auth + +import com.team.prezel.core.domain.repository.auth.AuthRepository +import com.team.prezel.core.model.auth.LoginStatus +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +/** + * 저장된 인증 토큰을 기반으로 현재 로그인 상태를 확인하는 UseCase. + * + * ### 동작 흐름 + * 1. [com.team.prezel.core.domain.repository.auth.AuthRepository.loginStatus]를 구독합니다. + * 2. 저장된 토큰을 기반으로 로그인 상태 판별은 repository 내부에서 처리합니다. + * 3. 판별 결과를 [Flow] 형태로 반환합니다. + * + */ +class CheckLoginStatusUseCase @Inject constructor( + private val authRepository: AuthRepository, +) { + operator fun invoke(): Flow = authRepository.loginStatus +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LoginUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LoginUseCase.kt new file mode 100644 index 00000000..77ba85e4 --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LoginUseCase.kt @@ -0,0 +1,19 @@ +package com.team.prezel.core.domain.usecase.auth + +import com.team.prezel.core.domain.repository.auth.AuthRepository +import javax.inject.Inject + +/** + * 소셜 로그인에 사용되는 ID 토큰으로 서버 로그인을 수행하는 UseCase. + * + * ### 동작 흐름 + * 1. 호출부로부터 전달받은 ID 토큰을 입력값으로 받습니다. + * 2. [com.team.prezel.core.domain.repository.auth.AuthRepository.login]을 호출하여 서버 로그인 요청을 수행합니다. + * 3. 로그인 결과에 따라 성공 여부 또는 예외를 포함한 [Result]를 반환합니다. + * + */ +class LoginUseCase @Inject constructor( + private val authRepository: AuthRepository, +) { + suspend operator fun invoke(idToken: String): Result = authRepository.login(idToken = idToken) +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt new file mode 100644 index 00000000..7c1545d6 --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt @@ -0,0 +1,18 @@ +package com.team.prezel.core.domain.usecase.auth + +import com.team.prezel.core.domain.repository.auth.AuthRepository +import javax.inject.Inject + +/** + * 현재 로그인 세션의 로그아웃을 요청하는 UseCase. + * + * ### 동작 흐름 + * 1. [com.team.prezel.core.domain.repository.auth.AuthRepository.logout]을 호출합니다. + * 2. repository가 저장된 토큰 조회와 서버 로그아웃 요청을 처리합니다. + * 3. 결과를 [Result]로 반환합니다. + */ +class LogoutUseCase @Inject constructor( + private val authRepository: AuthRepository, +) { + suspend operator fun invoke(): Result = authRepository.logout() +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt new file mode 100644 index 00000000..b21e139b --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt @@ -0,0 +1,19 @@ +package com.team.prezel.core.domain.usecase.auth + +import com.team.prezel.core.domain.repository.auth.AuthRepository +import com.team.prezel.core.model.auth.WithdrawReason +import javax.inject.Inject + +/** + * 회원 탈퇴 사유와 함께 탈퇴 요청을 수행하는 UseCase. + * + * ### 동작 흐름 + * 1. 호출부로부터 전달받은 [WithdrawReason]으로 [com.team.prezel.core.domain.repository.auth.AuthRepository.withdraw]를 호출합니다. + * 2. repository가 저장된 토큰 조회와 서버 회원 탈퇴 요청을 처리합니다. + * 3. 결과를 [Result]로 반환합니다. + */ +class WithdrawUseCase @Inject constructor( + private val authRepository: AuthRepository, +) { + suspend operator fun invoke(reason: WithdrawReason): Result = authRepository.withdraw(reason = reason) +} From a4e030d61aca59d14bb4ae674b1e710b4bb763e0 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Thu, 7 May 2026 20:24:31 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat:=20PrezelPlayer=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC?= =?UTF-8?q?=EC=B6=95=20=EB=B0=8F=20=EB=AF=B8=EB=94=94=EC=96=B4=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EB=A6=AC=ED=8C=A9=ED=84=B0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: PrezelPlayerState 및 Hoisting 로직 추가** * 재생 상태(`playing`), 재생 시간(`currentMillis`), 전체 길이(`durationMillis`), 아이템 목록(`items`) 등을 통합 관리하는 `PrezelPlayerState` 클래스를 추가했습니다. * `rememberPrezelPlayerState`를 통해 Compose 생기주기에 맞게 상태를 보존하고, `SideEffect`를 사용하여 외부 상태 변화를 동기화합니다. * 드래그 상태 관리(`dragging`) 및 이전/다음 아이템 이동 가능 여부(`previousEnabled`, `nextEnabled`) 판단 로직을 포함합니다. * **refactor: 플레이어 컨트롤러 및 네비게이션 로직 개선** * 단순히 앞/뒤로 이동하던 로직을 `PrezelPlayerItem` 기반의 '이전/다음 아이템' 이동 로직으로 고도화했습니다. * `PrezelPlayer` 컴포넌트가 `PrezelPlayerState`를 주입받아 동작하도록 구조를 변경했습니다. * `PrezelIconButton`에 `enabled` 상태를 적용하여 이동 가능 여부에 따라 버튼 활성화 상태가 시각적으로 표시되도록 개선했습니다. * **refactor: 데이터 모델 단순화 및 마커 시스템 개편** * `PrezelPlayerResourceMarkerItem`을 `sealed interface`에서 단일 `data class`로 통합하여 관리 효율성을 높였습니다. * `PrezelSpeechMarkerType`, `PrezelScriptMatchMarkerType`으로 분리되어 있던 마커 타입을 `PrezelPlayerResourceMarkerType`으로 단일화했습니다. * **docs: Preview 및 리소스 문자열 업데이트** * `BasicPreview` 어노테이션을 적용하고, 상태 기반의 플레이어 동작을 확인할 수 있도록 Preview 코드를 보강했습니다. * 플레이어 컨트롤러 관련 접근성 설명(Content Description)을 '뒤로/앞으로'에서 '이전/다음 아이템'으로 수정했습니다. --- .../component/player/PrezelPlayer.kt | 145 +++++++++++++----- .../component/player/PrezelPlayerModels.kt | 50 ++---- .../player/PrezelPlayerResourceMarker.kt | 12 +- .../player/PrezelPlayerResourceTrack.kt | 64 ++++++-- .../component/player/PrezelPlayerState.kt | 136 ++++++++++++++++ .../component/player/PrezelPlayerTimeline.kt | 39 +---- .../src/main/res/values/strings.xml | 4 +- 7 files changed, 318 insertions(+), 132 deletions(-) create mode 100644 Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerState.kt 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 index aa1ea210..f5e8e006 100644 --- 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 @@ -20,7 +20,6 @@ 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.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.team.prezel.core.designsystem.R import com.team.prezel.core.designsystem.component.actions.button.PrezelIconButton @@ -29,6 +28,7 @@ import com.team.prezel.core.designsystem.component.actions.button.config.ButtonS 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 @@ -37,18 +37,49 @@ import kotlinx.coroutines.delay @Composable fun PrezelPlayer( + state: PrezelPlayerState, + markers: ImmutableList, + modifier: Modifier = Modifier, + trackType: PrezelPlayerResourceTrackType = PrezelPlayerResourceTrackType.SPEECH, +) { + PrezelPlayerContent( + playing = state.playing, + durationMillis = state.durationMillis, + currentMillis = state.currentMillis, + markers = markers, + onPlayPauseClick = state::playPause, + onPreviousClick = state::moveToPreviousItem, + onNextClick = state::moveToNextItem, + onSeek = state::seekToProgress, + modifier = modifier, + trackType = trackType, + 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, markers: ImmutableList, onPlayPauseClick: () -> Unit, - onBackwardClick: () -> Unit, - onForwardClick: () -> Unit, + onPreviousClick: () -> Unit, + onNextClick: () -> Unit, onSeek: (Float) -> Unit, modifier: Modifier = Modifier, trackType: PrezelPlayerResourceTrackType = PrezelPlayerResourceTrackType.SPEECH, idle: Boolean = false, showHandle: Boolean = false, + onDragStarted: () -> Unit = {}, + onDragStopped: () -> Unit = {}, + previousEnabled: Boolean = true, + nextEnabled: Boolean = true, ) { Column( modifier = modifier @@ -64,13 +95,17 @@ fun PrezelPlayer( idle = idle, showHandle = showHandle, onSeek = onSeek, + onDragStarted = onDragStarted, + onDragStopped = onDragStopped, ) PrezelPlayerControls( playing = playing, onPlayPauseClick = onPlayPauseClick, - onBackwardClick = onBackwardClick, - onForwardClick = onForwardClick, + onPreviousClick = onPreviousClick, + onNextClick = onNextClick, + previousEnabled = previousEnabled, + nextEnabled = nextEnabled, ) } } @@ -84,6 +119,8 @@ private fun PrezelPlayerTrackSection( idle: Boolean, showHandle: Boolean, onSeek: (Float) -> Unit, + onDragStarted: () -> Unit, + onDragStopped: () -> Unit, ) { PrezelPlayerResourceTrack( durationMillis = durationMillis, @@ -93,6 +130,8 @@ private fun PrezelPlayerTrackSection( idle = idle, showHandle = showHandle, onSeek = onSeek, + onDragStarted = onDragStarted, + onDragStopped = onDragStopped, modifier = Modifier .fillMaxWidth() .padding(horizontal = PrezelTheme.spacing.V20), @@ -103,8 +142,10 @@ private fun PrezelPlayerTrackSection( private fun PrezelPlayerControls( playing: Boolean, onPlayPauseClick: () -> Unit, - onBackwardClick: () -> Unit, - onForwardClick: () -> Unit, + onPreviousClick: () -> Unit, + onNextClick: () -> Unit, + previousEnabled: Boolean, + nextEnabled: Boolean, ) { Row( modifier = Modifier.fillMaxWidth(), @@ -116,8 +157,9 @@ private fun PrezelPlayerControls( ) { PrezelPlayerSeekButton( iconResId = PrezelIcons.SkipBackward, - contentDescription = stringResource(R.string.core_designsystem_player_backward_desc), - onClick = onBackwardClick, + contentDescription = stringResource(R.string.core_designsystem_player_previous_desc), + enabled = previousEnabled, + onClick = onPreviousClick, ) PrezelPlayerPlayPauseButton( playing = playing, @@ -126,8 +168,9 @@ private fun PrezelPlayerControls( ) PrezelPlayerSeekButton( iconResId = PrezelIcons.SkipForward, - contentDescription = stringResource(R.string.core_designsystem_player_forward_desc), - onClick = onForwardClick, + contentDescription = stringResource(R.string.core_designsystem_player_next_desc), + enabled = nextEnabled, + onClick = onNextClick, ) } } @@ -136,12 +179,14 @@ private fun PrezelPlayerControls( 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) @@ -180,34 +225,36 @@ private fun PrezelPlayerPlayPauseButton( ) } -@Preview(showBackground = true) +@BasicPreview @Composable private fun PrezelPlayerPreview() { PrezelTheme { PreviewSection(title = "Player") { PlayerPreviewItem(name = "playing=off") { PrezelPlayer( - playing = false, - durationMillis = 690_000L, - currentMillis = 100_000L, + state = rememberPrezelPlayerState( + playing = false, + durationMillis = 690_000L, + currentMillis = 0L, + items = previewPlayerItems, + onPlayPauseClick = {}, + onSeekToMillis = {}, + ), markers = previewMarkers, - onPlayPauseClick = {}, - onBackwardClick = {}, - onForwardClick = {}, - onSeek = {}, modifier = Modifier.width(360.dp), ) } PlayerPreviewItem(name = "playing=on") { PrezelPlayer( - playing = true, - durationMillis = 690_000L, - currentMillis = 100_000L, + state = rememberPrezelPlayerState( + playing = true, + durationMillis = 690_000L, + currentMillis = 234_000L, + items = previewPlayerItems, + onPlayPauseClick = {}, + onSeekToMillis = {}, + ), markers = previewMarkers, - onPlayPauseClick = {}, - onBackwardClick = {}, - onForwardClick = {}, - onSeek = {}, modifier = Modifier.width(360.dp), ) } @@ -215,13 +262,21 @@ private fun PrezelPlayerPreview() { } } -@Preview(showBackground = true) +@BasicPreview @Composable private fun PrezelPlayerPlaybackPreview() { PrezelTheme { var playing by remember { mutableStateOf(false) } - var currentMillis by remember { mutableLongStateOf(100_000L) } + var currentMillis by remember { mutableLongStateOf(0L) } val durationMillis = 690_000L + val playerState = rememberPrezelPlayerState( + playing = playing, + durationMillis = durationMillis, + currentMillis = currentMillis, + items = previewPlayerItems, + onPlayPauseClick = { playing = !playing }, + onSeekToMillis = { targetMillis -> currentMillis = targetMillis }, + ) LaunchedEffect(playing) { while (playing) { @@ -234,16 +289,9 @@ private fun PrezelPlayerPlaybackPreview() { PreviewSection(title = "Player Playback") { PlayerPreviewItem(name = if (playing) "playing" else "paused") { PrezelPlayer( - playing = playing, - durationMillis = durationMillis, - currentMillis = currentMillis, + state = playerState, markers = previewMarkers, - onPlayPauseClick = { playing = !playing }, - onBackwardClick = { currentMillis = (currentMillis - 5_000L).coerceAtLeast(0L) }, - onForwardClick = { currentMillis = (currentMillis + 5_000L).coerceAtMost(durationMillis) }, - onSeek = { seekProgress -> currentMillis = (durationMillis * seekProgress).toLong() }, modifier = Modifier.width(360.dp), - showHandle = false, ) } } @@ -268,7 +316,26 @@ private fun PlayerPreviewItem( } private val previewMarkers = persistentListOf( - PrezelPlayerResourceMarkerItem.speech(timeSeconds = 151, type = PrezelSpeechMarkerType.WARNING), - PrezelPlayerResourceMarkerItem.speech(timeSeconds = 317, type = PrezelSpeechMarkerType.GOOD), - PrezelPlayerResourceMarkerItem.speech(timeSeconds = 443, type = PrezelSpeechMarkerType.WARNING), + PrezelPlayerResourceMarkerItem( + timeSeconds = 151, + trackType = PrezelPlayerResourceTrackType.SPEECH, + markerType = PrezelPlayerResourceMarkerType.WARNING, + ), + PrezelPlayerResourceMarkerItem( + timeSeconds = 317, + trackType = PrezelPlayerResourceTrackType.SPEECH, + markerType = PrezelPlayerResourceMarkerType.GOOD, + ), + PrezelPlayerResourceMarkerItem( + timeSeconds = 443, + trackType = PrezelPlayerResourceTrackType.SPEECH, + markerType = PrezelPlayerResourceMarkerType.WARNING, + ), +) + +private val previewPlayerItems = persistentListOf( + PrezelPlayerItem(id = "preview-script-item-0", startMillis = 0L), + PrezelPlayerItem(id = "preview-script-item-1", startMillis = 151_000L), + PrezelPlayerItem(id = "preview-script-item-2", startMillis = 317_000L), + PrezelPlayerItem(id = "preview-script-item-3", startMillis = 443_000L), ) 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 index 6559ed90..f017473c 100644 --- 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 @@ -7,49 +7,21 @@ enum class PrezelPlayerResourceTrackType { SCRIPT_MATCH, } -enum class PrezelSpeechMarkerType { +enum class PrezelPlayerResourceMarkerType { GOOD, WARNING, -} - -enum class PrezelScriptMatchMarkerType { - GOOD, NEUTRAL, } @Immutable -sealed interface PrezelPlayerResourceMarkerItem { - val timeSeconds: Long - - @Immutable - data class Speech( - override val timeSeconds: Long, - val type: PrezelSpeechMarkerType, - ) : PrezelPlayerResourceMarkerItem +data class PrezelPlayerItem( + val id: String, + val startMillis: Long, +) - @Immutable - data class ScriptMatch( - override val timeSeconds: Long, - val type: PrezelScriptMatchMarkerType, - ) : PrezelPlayerResourceMarkerItem - - companion object { - fun speech( - timeSeconds: Long, - type: PrezelSpeechMarkerType, - ): PrezelPlayerResourceMarkerItem = - Speech( - timeSeconds = timeSeconds, - type = type, - ) - - fun scriptMatch( - timeSeconds: Long, - type: PrezelScriptMatchMarkerType, - ): PrezelPlayerResourceMarkerItem = - ScriptMatch( - timeSeconds = timeSeconds, - type = type, - ) - } -} +@Immutable +data class PrezelPlayerResourceMarkerItem( + val timeSeconds: Long, + val trackType: PrezelPlayerResourceTrackType, + val markerType: PrezelPlayerResourceMarkerType, +) 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 index f2068ee0..22ef6583 100644 --- 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 @@ -9,19 +9,13 @@ 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.tooling.preview.Preview 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 -enum class PrezelPlayerResourceMarkerType { - GOOD, - WARNING, - NEUTRAL, -} - @Composable -fun PrezelPlayerResourceMarker( +internal fun PrezelPlayerResourceMarker( type: PrezelPlayerResourceMarkerType, modifier: Modifier = Modifier, ) { @@ -41,7 +35,7 @@ private val PrezelPlayerResourceMarkerType.color: Color PrezelPlayerResourceMarkerType.NEUTRAL -> PrezelTheme.colors.iconRegular } -@Preview(showBackground = true) +@BasicPreview @Composable private fun PrezelPlayerResourceMarkerPreview() { PrezelTheme { 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 index 374505fe..ffee4b72 100644 --- 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 @@ -11,9 +11,9 @@ 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.tooling.preview.Preview 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 @@ -21,7 +21,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @Composable -fun PrezelPlayerResourceTrack( +internal fun PrezelPlayerResourceTrack( durationMillis: Long, currentMillis: Long, markers: ImmutableList, @@ -30,6 +30,8 @@ fun PrezelPlayerResourceTrack( type: PrezelPlayerResourceTrackType = PrezelPlayerResourceTrackType.SPEECH, idle: Boolean = false, showHandle: Boolean = false, + onDragStarted: () -> Unit = {}, + onDragStopped: () -> Unit = {}, ) { val displayedDurationMillis = durationMillis.coerceAtLeast(0L) val displayedCurrentMillis = if (idle) 0L else currentMillis.coercePlayerMillis(displayedDurationMillis) @@ -48,6 +50,8 @@ fun PrezelPlayerResourceTrack( contentDescription = timelineContentDescription, showHandle = showHandle, onSeek = onSeek, + onDragStarted = onDragStarted, + onDragStopped = onDragStopped, modifier = Modifier .weight(1f) .fillMaxWidth(), @@ -105,7 +109,7 @@ private val PrezelPlayerResourceTrackType.contentDescriptionResId: Int PrezelPlayerResourceTrackType.SCRIPT_MATCH -> R.string.core_designsystem_player_script_match_track_desc } -@Preview(showBackground = true) +@BasicPreview @Composable private fun PrezelPlayerResourceTrackPreview() { PrezelTheme { @@ -187,19 +191,55 @@ private fun PlayerResourceTrackPreviewItem( } private val previewSpeechMarkers = persistentListOf( - PrezelPlayerResourceMarkerItem.speech(timeSeconds = 151, type = PrezelSpeechMarkerType.WARNING), - PrezelPlayerResourceMarkerItem.speech(timeSeconds = 317, type = PrezelSpeechMarkerType.GOOD), - PrezelPlayerResourceMarkerItem.speech(timeSeconds = 443, type = PrezelSpeechMarkerType.WARNING), + PrezelPlayerResourceMarkerItem( + timeSeconds = 151, + trackType = PrezelPlayerResourceTrackType.SPEECH, + markerType = PrezelPlayerResourceMarkerType.WARNING, + ), + PrezelPlayerResourceMarkerItem( + timeSeconds = 317, + trackType = PrezelPlayerResourceTrackType.SPEECH, + markerType = PrezelPlayerResourceMarkerType.GOOD, + ), + PrezelPlayerResourceMarkerItem( + timeSeconds = 443, + trackType = PrezelPlayerResourceTrackType.SPEECH, + markerType = PrezelPlayerResourceMarkerType.WARNING, + ), ) private val previewScriptMatchMarkers = persistentListOf( - PrezelPlayerResourceMarkerItem.scriptMatch(timeSeconds = 317, type = PrezelScriptMatchMarkerType.GOOD), - PrezelPlayerResourceMarkerItem.scriptMatch(timeSeconds = 443, type = PrezelScriptMatchMarkerType.NEUTRAL), + PrezelPlayerResourceMarkerItem( + timeSeconds = 317, + trackType = PrezelPlayerResourceTrackType.SCRIPT_MATCH, + markerType = PrezelPlayerResourceMarkerType.GOOD, + ), + PrezelPlayerResourceMarkerItem( + timeSeconds = 443, + trackType = PrezelPlayerResourceTrackType.SCRIPT_MATCH, + markerType = PrezelPlayerResourceMarkerType.NEUTRAL, + ), ) private val previewOverlappingMarkers = persistentListOf( - PrezelPlayerResourceMarkerItem.speech(timeSeconds = 317, type = PrezelSpeechMarkerType.GOOD), - PrezelPlayerResourceMarkerItem.speech(timeSeconds = 438, type = PrezelSpeechMarkerType.GOOD), - PrezelPlayerResourceMarkerItem.speech(timeSeconds = 151, type = PrezelSpeechMarkerType.WARNING), - PrezelPlayerResourceMarkerItem.speech(timeSeconds = 443, type = PrezelSpeechMarkerType.WARNING), + PrezelPlayerResourceMarkerItem( + timeSeconds = 317, + trackType = PrezelPlayerResourceTrackType.SPEECH, + markerType = PrezelPlayerResourceMarkerType.GOOD, + ), + PrezelPlayerResourceMarkerItem( + timeSeconds = 438, + trackType = PrezelPlayerResourceTrackType.SPEECH, + markerType = PrezelPlayerResourceMarkerType.GOOD, + ), + PrezelPlayerResourceMarkerItem( + timeSeconds = 151, + trackType = PrezelPlayerResourceTrackType.SPEECH, + markerType = PrezelPlayerResourceMarkerType.WARNING, + ), + PrezelPlayerResourceMarkerItem( + timeSeconds = 443, + trackType = PrezelPlayerResourceTrackType.SPEECH, + markerType = PrezelPlayerResourceMarkerType.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..9fcad60c --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/player/PrezelPlayerState.kt @@ -0,0 +1,136 @@ +package com.team.prezel.core.designsystem.component.player + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun rememberPrezelPlayerState( + playing: Boolean, + durationMillis: Long, + currentMillis: Long, + items: ImmutableList, + onPlayPauseClick: () -> Unit, + onSeekToMillis: (Long) -> Unit, +): PrezelPlayerState { + val currentOnPlayPauseClick = rememberUpdatedState(onPlayPauseClick) + val currentOnSeekToMillis = rememberUpdatedState(onSeekToMillis) + + val state = remember { + PrezelPlayerState( + playing = playing, + durationMillis = durationMillis, + currentMillis = currentMillis, + items = items, + onPlayPauseClick = { currentOnPlayPauseClick.value() }, + onSeekToMillis = { targetMillis -> currentOnSeekToMillis.value(targetMillis) }, + ) + } + + SideEffect { + state.update( + playing = playing, + durationMillis = durationMillis, + currentMillis = currentMillis, + items = items, + ) + } + + return state +} + +@Stable +class PrezelPlayerState internal constructor( + playing: Boolean, + durationMillis: Long, + currentMillis: Long, + items: ImmutableList, + private val onPlayPauseClick: () -> Unit, + private val onSeekToMillis: (Long) -> Unit, +) { + var playing by mutableStateOf(playing) + private set + + var durationMillis by mutableLongStateOf(durationMillis.coerceAtLeast(0L)) + private set + + var currentMillis by mutableLongStateOf(currentMillis.coercePlayerMillis(this.durationMillis)) + 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 + + val currentItemIndex: Int + get() = if (items.isEmpty()) { + -1 + } else { + items.indexOfLast { item -> currentMillis >= item.startMillis }.coerceAtLeast(0) + } + + val currentItem: PrezelPlayerItem? + get() = items.getOrNull(currentItemIndex) + + val previousEnabled: Boolean + get() = currentItemIndex > 0 + + val nextEnabled: Boolean + get() = currentItemIndex >= 0 && currentItemIndex < items.lastIndex + + fun playPause() { + onPlayPauseClick() + } + + fun seekToProgress(progress: Float) { + seekToMillis((durationMillis * progress.coerceIn(0f, 1f)).toLong()) + } + + fun moveToPreviousItem() { + if (previousEnabled) seekToMillis(items[currentItemIndex - 1].startMillis) + } + + fun moveToNextItem() { + if (nextEnabled) seekToMillis(items[currentItemIndex + 1].startMillis) + } + + fun startDrag() { + dragging = true + } + + fun stopDrag() { + dragging = false + } + + private fun seekToMillis(targetMillis: Long) { + onSeekToMillis(targetMillis.coercePlayerMillis(durationMillis)) + } + + internal fun update( + playing: Boolean, + durationMillis: Long, + currentMillis: Long, + items: ImmutableList, + ) { + this.playing = playing + this.durationMillis = durationMillis.coerceAtLeast(0L) + this.currentMillis = currentMillis.coercePlayerMillis(this.durationMillis) + this.items = items + } +} + +private fun Long.coercePlayerMillis(durationMillis: Long): Long = coerceIn(0L, durationMillis.coerceAtLeast(0L)) 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 index 2ef2d509..752e90b0 100644 --- 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 @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue @@ -43,11 +42,11 @@ internal fun PrezelPlayerTimeline( contentDescription: String, showHandle: Boolean, onSeek: (Float) -> Unit, + onDragStarted: () -> Unit, + onDragStopped: () -> Unit, modifier: Modifier = Modifier, ) { var widthPx by remember { mutableIntStateOf(0) } - var dragging by remember { mutableStateOf(false) } - val handleVisible = showHandle || dragging fun seekTo(offsetX: Float) { if (widthPx > 0) onSeek((offsetX / widthPx).coerceIn(0f, 1f)) @@ -61,16 +60,16 @@ internal fun PrezelPlayerTimeline( onSeekTo = ::seekTo, onSetProgress = onSeek, onDragStarted = { offsetX -> - dragging = true + onDragStarted() seekTo(offsetX) }, - onDragStopped = { dragging = false }, + onDragStopped = onDragStopped, ), contentAlignment = Alignment.CenterStart, ) { PlayerTimelineBar( progress = progress, - playedBarVisible = !handleVisible, + playedBarVisible = !showHandle, ) PlayerTimelineMarkers( markers = markers, @@ -78,7 +77,7 @@ internal fun PrezelPlayerTimeline( durationMillis = durationMillis, ) - if (handleVisible) PlayerTimelineHandle(progress = progress, zIndex = markers.size + 1f) + if (showHandle) PlayerTimelineHandle(progress = progress, zIndex = markers.size + 1f) } } @@ -158,7 +157,7 @@ private fun BoxWithConstraintsScope.PlayerTimelineMarkers( markers.forEach { marker -> if (marker.matchesTrackType(type)) { PrezelPlayerResourceMarker( - type = marker.resourceMarkerType, + type = marker.markerType, modifier = Modifier .align(Alignment.CenterStart) .offset { @@ -171,17 +170,7 @@ private fun BoxWithConstraintsScope.PlayerTimelineMarkers( } } -private fun PrezelPlayerResourceMarkerItem.matchesTrackType(type: PrezelPlayerResourceTrackType): Boolean = - when (type) { - PrezelPlayerResourceTrackType.SPEECH -> this is PrezelPlayerResourceMarkerItem.Speech - PrezelPlayerResourceTrackType.SCRIPT_MATCH -> this is PrezelPlayerResourceMarkerItem.ScriptMatch - } - -private val PrezelPlayerResourceMarkerItem.resourceMarkerType: PrezelPlayerResourceMarkerType - get() = when (this) { - is PrezelPlayerResourceMarkerItem.Speech -> type.markerType - is PrezelPlayerResourceMarkerItem.ScriptMatch -> type.markerType - } +private fun PrezelPlayerResourceMarkerItem.matchesTrackType(type: PrezelPlayerResourceTrackType): Boolean = trackType == type private fun PrezelPlayerResourceMarkerItem.progressIn(durationMillis: Long): Float = if (durationMillis <= 0L) { @@ -207,15 +196,3 @@ private fun BoxWithConstraintsScope.PlayerTimelineHandle( .zIndex(zIndex), ) } - -private val PrezelSpeechMarkerType.markerType: PrezelPlayerResourceMarkerType - get() = when (this) { - PrezelSpeechMarkerType.GOOD -> PrezelPlayerResourceMarkerType.GOOD - PrezelSpeechMarkerType.WARNING -> PrezelPlayerResourceMarkerType.WARNING - } - -private val PrezelScriptMatchMarkerType.markerType: PrezelPlayerResourceMarkerType - get() = when (this) { - PrezelScriptMatchMarkerType.GOOD -> PrezelPlayerResourceMarkerType.GOOD - PrezelScriptMatchMarkerType.NEUTRAL -> PrezelPlayerResourceMarkerType.NEUTRAL - } diff --git a/Prezel/core/designsystem/src/main/res/values/strings.xml b/Prezel/core/designsystem/src/main/res/values/strings.xml index 6e57056e..2822b5c4 100644 --- a/Prezel/core/designsystem/src/main/res/values/strings.xml +++ b/Prezel/core/designsystem/src/main/res/values/strings.xml @@ -6,8 +6,8 @@ 체크박스 선택하기 %1$d년 %2$d월 - 뒤로 이동 - 앞으로 이동 + 이전 아이템으로 이동 + 다음 아이템으로 이동 일시정지 재생 스크립트 일치 트랙 From 5bb38a5b7bbe081240312724e7ecf493bd9f48d5 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Thu, 7 May 2026 22:28:44 +0900 Subject: [PATCH 06/11] =?UTF-8?q?refactor:=20`core:domain`=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EB=82=B4=20=EC=9D=B8=EC=A6=9D=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EB=B0=8F=20?= =?UTF-8?q?UseCase=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인증 관련 도메인 레이어의 구성 요소들을 삭제했습니다. (기존 로직의 위치 이동 또는 구조 재설계로 인한 정리) * **`AuthRepository` 인터페이스 삭제** * 로그인 상태 확인(`loginStatus`), 로그인/로그아웃 및 회원 탈퇴 메서드 정의가 포함된 인터페이스를 제거했습니다. * **인증 관련 UseCase 클래스 삭제** * `CheckLoginStatusUseCase`: 로그인 상태 구독 로직 제거 * `LoginUseCase`: ID 토큰 기반 서버 로그인 호출 로직 제거 * `LogoutUseCase`: 로그아웃 요청 처리 로직 제거 * `WithdrawUseCase`: 회원 탈퇴 요청 처리 로직 제거 --- .../domain/repository/auth/AuthRepository.kt | 15 ------------- .../usecase/auth/CheckLoginStatusUseCase.kt | 21 ------------------- .../core/domain/usecase/auth/LoginUseCase.kt | 19 ----------------- .../core/domain/usecase/auth/LogoutUseCase.kt | 18 ---------------- .../domain/usecase/auth/WithdrawUseCase.kt | 19 ----------------- 5 files changed, 92 deletions(-) delete mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt delete mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt delete mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LoginUseCase.kt delete mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt delete mode 100644 Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt deleted file mode 100644 index 64b8efdd..00000000 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.team.prezel.core.domain.repository.auth - -import com.team.prezel.core.model.auth.LoginStatus -import com.team.prezel.core.model.auth.WithdrawReason -import kotlinx.coroutines.flow.Flow - -interface AuthRepository { - val loginStatus: Flow - - suspend fun logout(): Result - - suspend fun login(idToken: String): Result - - suspend fun withdraw(reason: WithdrawReason): Result -} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt deleted file mode 100644 index c68009a6..00000000 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.team.prezel.core.domain.usecase.auth - -import com.team.prezel.core.domain.repository.auth.AuthRepository -import com.team.prezel.core.model.auth.LoginStatus -import kotlinx.coroutines.flow.Flow -import javax.inject.Inject - -/** - * 저장된 인증 토큰을 기반으로 현재 로그인 상태를 확인하는 UseCase. - * - * ### 동작 흐름 - * 1. [com.team.prezel.core.domain.repository.auth.AuthRepository.loginStatus]를 구독합니다. - * 2. 저장된 토큰을 기반으로 로그인 상태 판별은 repository 내부에서 처리합니다. - * 3. 판별 결과를 [Flow] 형태로 반환합니다. - * - */ -class CheckLoginStatusUseCase @Inject constructor( - private val authRepository: AuthRepository, -) { - operator fun invoke(): Flow = authRepository.loginStatus -} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LoginUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LoginUseCase.kt deleted file mode 100644 index 77ba85e4..00000000 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LoginUseCase.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.team.prezel.core.domain.usecase.auth - -import com.team.prezel.core.domain.repository.auth.AuthRepository -import javax.inject.Inject - -/** - * 소셜 로그인에 사용되는 ID 토큰으로 서버 로그인을 수행하는 UseCase. - * - * ### 동작 흐름 - * 1. 호출부로부터 전달받은 ID 토큰을 입력값으로 받습니다. - * 2. [com.team.prezel.core.domain.repository.auth.AuthRepository.login]을 호출하여 서버 로그인 요청을 수행합니다. - * 3. 로그인 결과에 따라 성공 여부 또는 예외를 포함한 [Result]를 반환합니다. - * - */ -class LoginUseCase @Inject constructor( - private val authRepository: AuthRepository, -) { - suspend operator fun invoke(idToken: String): Result = authRepository.login(idToken = idToken) -} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt deleted file mode 100644 index 7c1545d6..00000000 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.team.prezel.core.domain.usecase.auth - -import com.team.prezel.core.domain.repository.auth.AuthRepository -import javax.inject.Inject - -/** - * 현재 로그인 세션의 로그아웃을 요청하는 UseCase. - * - * ### 동작 흐름 - * 1. [com.team.prezel.core.domain.repository.auth.AuthRepository.logout]을 호출합니다. - * 2. repository가 저장된 토큰 조회와 서버 로그아웃 요청을 처리합니다. - * 3. 결과를 [Result]로 반환합니다. - */ -class LogoutUseCase @Inject constructor( - private val authRepository: AuthRepository, -) { - suspend operator fun invoke(): Result = authRepository.logout() -} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt deleted file mode 100644 index b21e139b..00000000 --- a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.team.prezel.core.domain.usecase.auth - -import com.team.prezel.core.domain.repository.auth.AuthRepository -import com.team.prezel.core.model.auth.WithdrawReason -import javax.inject.Inject - -/** - * 회원 탈퇴 사유와 함께 탈퇴 요청을 수행하는 UseCase. - * - * ### 동작 흐름 - * 1. 호출부로부터 전달받은 [WithdrawReason]으로 [com.team.prezel.core.domain.repository.auth.AuthRepository.withdraw]를 호출합니다. - * 2. repository가 저장된 토큰 조회와 서버 회원 탈퇴 요청을 처리합니다. - * 3. 결과를 [Result]로 반환합니다. - */ -class WithdrawUseCase @Inject constructor( - private val authRepository: AuthRepository, -) { - suspend operator fun invoke(reason: WithdrawReason): Result = authRepository.withdraw(reason = reason) -} From 5b07dd34ed16e070d7c8b9c802b1c45ad1823d69 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Thu, 7 May 2026 23:18:37 +0900 Subject: [PATCH 07/11] =?UTF-8?q?refactor:=20`PrezelPlayer`=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=ED=86=B5=ED=95=A9=20=EB=B0=8F=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: `PrezelPlayerItem` Sealed Interface 도입 및 모델 통합** * 기존의 `PrezelPlayerItem`과 `PrezelPlayerResourceMarkerItem`으로 분리되어 있던 모델을 `PrezelPlayerItem` 인터페이스 아래 `Segment`와 `Marker`로 통합했습니다. * 마커 타입을 정의하던 `PrezelPlayerResourceMarkerType`을 `PrezelPlayerMarkerType`으로 변경했습니다. * 기존 `PrezelPlayerResourceTrackType`에 의존하던 로직을 제거하고, `trackContentDescription`을 외부에서 주입받도록 유연하게 개선했습니다. * **refactor: 플레이어 상태 관리 및 탐색 로직 개선** * `PrezelPlayerState`: 드래그 중일 때 `currentMillis` 업데이트 방식을 수정하여 탐색 중 화면 끊김 현상을 개선했습니다. * `PrezelPlayerState`: `currentItemIndex` 계산 시 `startMillis` 대신 통합된 `timeMillis` 필드를 사용하도록 변경했습니다. * `PrezelPlayerTimeline`: 마커 표시 로직에서 `PrezelPlayerItem.Marker` 타입만 필터링하여 렌더링하도록 수정했습니다. * **ui: 디자인 시스템 컴포넌트 레이아웃 조정** * `PrezelPlayerResourceTrack`: 고정 높이(`height`) 대신 `heightIn(min = 40.dp)`을 사용하여 유연성을 높였습니다. * `PrezelPlayerTimeline`: 타임라인 내 Played Bar와 Drag Handle의 z-index 및 배치 로직을 최적화했습니다. * **test: 미리보기(Preview) 데이터 및 컴포넌트 업데이트** * 변경된 `PrezelPlayerItem` 구조에 맞춰 `previewSpeechItems`, `previewScriptMatchItems` 등 테스트 데이터를 갱신했습니다. * `PrezelPlayerTimelinePreview`를 추가하여 타임라인 단독 컴포넌트의 시각적 검증을 강화했습니다. --- .../component/player/PrezelPlayer.kt | 62 +++---- .../component/player/PrezelPlayerModels.kt | 30 ++-- .../player/PrezelPlayerResourceMarker.kt | 16 +- .../player/PrezelPlayerResourceTrack.kt | 123 +++++++------- .../component/player/PrezelPlayerState.kt | 21 ++- .../component/player/PrezelPlayerTimeline.kt | 159 +++++++++++++++--- 6 files changed, 254 insertions(+), 157 deletions(-) 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 index f5e8e006..32d007d5 100644 --- 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 @@ -38,21 +38,20 @@ import kotlinx.coroutines.delay @Composable fun PrezelPlayer( state: PrezelPlayerState, - markers: ImmutableList, + trackContentDescription: String, modifier: Modifier = Modifier, - trackType: PrezelPlayerResourceTrackType = PrezelPlayerResourceTrackType.SPEECH, ) { PrezelPlayerContent( playing = state.playing, durationMillis = state.durationMillis, currentMillis = state.currentMillis, - markers = markers, + items = state.items, + trackContentDescription = trackContentDescription, onPlayPauseClick = state::playPause, onPreviousClick = state::moveToPreviousItem, onNextClick = state::moveToNextItem, onSeek = state::seekToProgress, modifier = modifier, - trackType = trackType, idle = state.idle, showHandle = state.showHandle, onDragStarted = state::startDrag, @@ -67,13 +66,13 @@ private fun PrezelPlayerContent( playing: Boolean, durationMillis: Long, currentMillis: Long, - markers: ImmutableList, + items: ImmutableList, + trackContentDescription: String, onPlayPauseClick: () -> Unit, onPreviousClick: () -> Unit, onNextClick: () -> Unit, onSeek: (Float) -> Unit, modifier: Modifier = Modifier, - trackType: PrezelPlayerResourceTrackType = PrezelPlayerResourceTrackType.SPEECH, idle: Boolean = false, showHandle: Boolean = false, onDragStarted: () -> Unit = {}, @@ -90,8 +89,8 @@ private fun PrezelPlayerContent( PrezelPlayerTrackSection( durationMillis = durationMillis, currentMillis = currentMillis, - markers = markers, - trackType = trackType, + items = items, + trackContentDescription = trackContentDescription, idle = idle, showHandle = showHandle, onSeek = onSeek, @@ -114,8 +113,8 @@ private fun PrezelPlayerContent( private fun PrezelPlayerTrackSection( durationMillis: Long, currentMillis: Long, - markers: ImmutableList, - trackType: PrezelPlayerResourceTrackType, + items: ImmutableList, + trackContentDescription: String, idle: Boolean, showHandle: Boolean, onSeek: (Float) -> Unit, @@ -125,8 +124,8 @@ private fun PrezelPlayerTrackSection( PrezelPlayerResourceTrack( durationMillis = durationMillis, currentMillis = currentMillis, - markers = markers, - type = trackType, + items = items, + contentDescription = trackContentDescription, idle = idle, showHandle = showHandle, onSeek = onSeek, @@ -240,7 +239,7 @@ private fun PrezelPlayerPreview() { onPlayPauseClick = {}, onSeekToMillis = {}, ), - markers = previewMarkers, + trackContentDescription = stringResource(R.string.core_designsystem_player_speech_track_desc), modifier = Modifier.width(360.dp), ) } @@ -254,7 +253,7 @@ private fun PrezelPlayerPreview() { onPlayPauseClick = {}, onSeekToMillis = {}, ), - markers = previewMarkers, + trackContentDescription = stringResource(R.string.core_designsystem_player_speech_track_desc), modifier = Modifier.width(360.dp), ) } @@ -290,7 +289,7 @@ private fun PrezelPlayerPlaybackPreview() { PlayerPreviewItem(name = if (playing) "playing" else "paused") { PrezelPlayer( state = playerState, - markers = previewMarkers, + trackContentDescription = stringResource(R.string.core_designsystem_player_speech_track_desc), modifier = Modifier.width(360.dp), ) } @@ -315,27 +314,20 @@ private fun PlayerPreviewItem( } } -private val previewMarkers = persistentListOf( - PrezelPlayerResourceMarkerItem( - timeSeconds = 151, - trackType = PrezelPlayerResourceTrackType.SPEECH, - markerType = PrezelPlayerResourceMarkerType.WARNING, +private val previewPlayerItems = persistentListOf( + PrezelPlayerItem.Segment(timeMillis = 0L), + PrezelPlayerItem.Segment(timeMillis = 90_000L), + PrezelPlayerItem.Marker( + timeMillis = 151_000L, + markerType = PrezelPlayerMarkerType.WARNING, ), - PrezelPlayerResourceMarkerItem( - timeSeconds = 317, - trackType = PrezelPlayerResourceTrackType.SPEECH, - markerType = PrezelPlayerResourceMarkerType.GOOD, + PrezelPlayerItem.Segment(timeMillis = 234_000L), + PrezelPlayerItem.Marker( + timeMillis = 317_000L, + markerType = PrezelPlayerMarkerType.GOOD, ), - PrezelPlayerResourceMarkerItem( - timeSeconds = 443, - trackType = PrezelPlayerResourceTrackType.SPEECH, - markerType = PrezelPlayerResourceMarkerType.WARNING, + PrezelPlayerItem.Marker( + timeMillis = 443_000L, + markerType = PrezelPlayerMarkerType.WARNING, ), ) - -private val previewPlayerItems = persistentListOf( - PrezelPlayerItem(id = "preview-script-item-0", startMillis = 0L), - PrezelPlayerItem(id = "preview-script-item-1", startMillis = 151_000L), - PrezelPlayerItem(id = "preview-script-item-2", startMillis = 317_000L), - PrezelPlayerItem(id = "preview-script-item-3", startMillis = 443_000L), -) 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 index f017473c..f36736a5 100644 --- 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 @@ -2,26 +2,24 @@ package com.team.prezel.core.designsystem.component.player import androidx.compose.runtime.Immutable -enum class PrezelPlayerResourceTrackType { - SPEECH, - SCRIPT_MATCH, -} - -enum class PrezelPlayerResourceMarkerType { +enum class PrezelPlayerMarkerType { GOOD, WARNING, NEUTRAL, } @Immutable -data class PrezelPlayerItem( - val id: String, - val startMillis: Long, -) +sealed interface PrezelPlayerItem { + val timeMillis: Long -@Immutable -data class PrezelPlayerResourceMarkerItem( - val timeSeconds: Long, - val trackType: PrezelPlayerResourceTrackType, - val markerType: PrezelPlayerResourceMarkerType, -) + @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 index 22ef6583..149d6641 100644 --- 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 @@ -16,7 +16,7 @@ import com.team.prezel.core.designsystem.theme.PrezelTheme @Composable internal fun PrezelPlayerResourceMarker( - type: PrezelPlayerResourceMarkerType, + type: PrezelPlayerMarkerType, modifier: Modifier = Modifier, ) { Box( @@ -27,12 +27,12 @@ internal fun PrezelPlayerResourceMarker( ) } -private val PrezelPlayerResourceMarkerType.color: Color +private val PrezelPlayerMarkerType.color: Color @Composable get() = when (this) { - PrezelPlayerResourceMarkerType.GOOD -> PrezelTheme.colors.feedbackGoodRegular - PrezelPlayerResourceMarkerType.WARNING -> PrezelTheme.colors.feedbackWarningRegular - PrezelPlayerResourceMarkerType.NEUTRAL -> PrezelTheme.colors.iconRegular + PrezelPlayerMarkerType.GOOD -> PrezelTheme.colors.feedbackGoodRegular + PrezelPlayerMarkerType.WARNING -> PrezelTheme.colors.feedbackWarningRegular + PrezelPlayerMarkerType.NEUTRAL -> PrezelTheme.colors.iconRegular } @BasicPreview @@ -41,9 +41,9 @@ private fun PrezelPlayerResourceMarkerPreview() { PrezelTheme { PreviewSection(title = "Player Resource Marker") { Row(horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16)) { - PrezelPlayerResourceMarker(type = PrezelPlayerResourceMarkerType.GOOD) - PrezelPlayerResourceMarker(type = PrezelPlayerResourceMarkerType.WARNING) - PrezelPlayerResourceMarker(type = PrezelPlayerResourceMarkerType.NEUTRAL) + 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 index ffee4b72..a7b95706 100644 --- 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 @@ -4,7 +4,7 @@ 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.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.width import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -24,10 +24,10 @@ import kotlinx.collections.immutable.persistentListOf internal fun PrezelPlayerResourceTrack( durationMillis: Long, currentMillis: Long, - markers: ImmutableList, + items: ImmutableList, + contentDescription: String, onSeek: (Float) -> Unit, modifier: Modifier = Modifier, - type: PrezelPlayerResourceTrackType = PrezelPlayerResourceTrackType.SPEECH, idle: Boolean = false, showHandle: Boolean = false, onDragStarted: () -> Unit = {}, @@ -36,25 +36,21 @@ internal fun PrezelPlayerResourceTrack( val displayedDurationMillis = durationMillis.coerceAtLeast(0L) val displayedCurrentMillis = if (idle) 0L else currentMillis.coercePlayerMillis(displayedDurationMillis) val displayedProgress = if (idle) 0f else displayedCurrentMillis.toPlayerProgress(displayedDurationMillis) - val timelineContentDescription = stringResource(type.contentDescriptionResId) Column( - modifier = modifier.height(40.dp), + modifier = modifier.heightIn(min = 40.dp), verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), ) { PrezelPlayerTimeline( progress = displayedProgress, durationMillis = displayedDurationMillis, - markers = markers, - type = type, - contentDescription = timelineContentDescription, + items = items, + contentDescription = contentDescription, showHandle = showHandle, onSeek = onSeek, onDragStarted = onDragStarted, onDragStopped = onDragStopped, - modifier = Modifier - .weight(1f) - .fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), ) PlayerTrackTimeLabels( @@ -103,38 +99,32 @@ private fun Long.formatPlayerTime(): String { return "${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}" } -private val PrezelPlayerResourceTrackType.contentDescriptionResId: Int - get() = when (this) { - PrezelPlayerResourceTrackType.SPEECH -> R.string.core_designsystem_player_speech_track_desc - PrezelPlayerResourceTrackType.SCRIPT_MATCH -> R.string.core_designsystem_player_script_match_track_desc - } - @BasicPreview @Composable private fun PrezelPlayerResourceTrackPreview() { PrezelTheme { PreviewSection(title = "Player Resource Track") { PreviewColumn { - PlayerResourceTrackPreviewItem(name = "type=speech / idle=off / showHandle=off") { + PlayerResourceTrackPreviewItem(name = "speech / idle=off / showHandle=off") { PrezelPlayerResourceTrack( durationMillis = 690_000, currentMillis = 443_000, - markers = previewSpeechMarkers, + items = previewSpeechItems, + contentDescription = stringResource(R.string.core_designsystem_player_speech_track_desc), onSeek = {}, modifier = Modifier.width(320.dp), - type = PrezelPlayerResourceTrackType.SPEECH, idle = false, showHandle = false, ) } - PlayerResourceTrackPreviewItem(name = "type=scriptMatch / idle=off / showHandle=off") { + PlayerResourceTrackPreviewItem(name = "script match / idle=off / showHandle=off") { PrezelPlayerResourceTrack( durationMillis = 690_000, currentMillis = 443_000, - markers = previewScriptMatchMarkers, + items = previewScriptMatchItems, + contentDescription = stringResource(R.string.core_designsystem_player_script_match_track_desc), onSeek = {}, modifier = Modifier.width(320.dp), - type = PrezelPlayerResourceTrackType.SCRIPT_MATCH, idle = false, showHandle = false, ) @@ -143,7 +133,8 @@ private fun PrezelPlayerResourceTrackPreview() { PrezelPlayerResourceTrack( durationMillis = 690_000, currentMillis = 443_000, - markers = previewSpeechMarkers, + items = previewSpeechItems, + contentDescription = stringResource(R.string.core_designsystem_player_speech_track_desc), onSeek = {}, modifier = Modifier.width(320.dp), idle = true, @@ -153,7 +144,8 @@ private fun PrezelPlayerResourceTrackPreview() { PrezelPlayerResourceTrack( durationMillis = 690_000, currentMillis = 443_000, - markers = previewSpeechMarkers, + items = previewSpeechItems, + contentDescription = stringResource(R.string.core_designsystem_player_speech_track_desc), onSeek = {}, modifier = Modifier.width(320.dp), showHandle = true, @@ -163,7 +155,8 @@ private fun PrezelPlayerResourceTrackPreview() { PrezelPlayerResourceTrack( durationMillis = 690_000, currentMillis = 443_000, - markers = previewOverlappingMarkers, + items = previewOverlappingItems, + contentDescription = stringResource(R.string.core_designsystem_player_speech_track_desc), onSeek = {}, modifier = Modifier.width(320.dp), ) @@ -190,56 +183,56 @@ private fun PlayerResourceTrackPreviewItem( } } -private val previewSpeechMarkers = persistentListOf( - PrezelPlayerResourceMarkerItem( - timeSeconds = 151, - trackType = PrezelPlayerResourceTrackType.SPEECH, - markerType = PrezelPlayerResourceMarkerType.WARNING, +private val previewSpeechItems = persistentListOf( + PrezelPlayerItem.Segment(timeMillis = 0L), + PrezelPlayerItem.Segment(timeMillis = 90_000L), + PrezelPlayerItem.Marker( + timeMillis = 151_000L, + markerType = PrezelPlayerMarkerType.WARNING, ), - PrezelPlayerResourceMarkerItem( - timeSeconds = 317, - trackType = PrezelPlayerResourceTrackType.SPEECH, - markerType = PrezelPlayerResourceMarkerType.GOOD, + PrezelPlayerItem.Segment(timeMillis = 234_000L), + PrezelPlayerItem.Marker( + timeMillis = 317_000L, + markerType = PrezelPlayerMarkerType.GOOD, ), - PrezelPlayerResourceMarkerItem( - timeSeconds = 443, - trackType = PrezelPlayerResourceTrackType.SPEECH, - markerType = PrezelPlayerResourceMarkerType.WARNING, + PrezelPlayerItem.Marker( + timeMillis = 443_000L, + markerType = PrezelPlayerMarkerType.WARNING, ), ) -private val previewScriptMatchMarkers = persistentListOf( - PrezelPlayerResourceMarkerItem( - timeSeconds = 317, - trackType = PrezelPlayerResourceTrackType.SCRIPT_MATCH, - markerType = PrezelPlayerResourceMarkerType.GOOD, +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, ), - PrezelPlayerResourceMarkerItem( - timeSeconds = 443, - trackType = PrezelPlayerResourceTrackType.SCRIPT_MATCH, - markerType = PrezelPlayerResourceMarkerType.NEUTRAL, + PrezelPlayerItem.Marker( + timeMillis = 443_000L, + markerType = PrezelPlayerMarkerType.NEUTRAL, ), ) -private val previewOverlappingMarkers = persistentListOf( - PrezelPlayerResourceMarkerItem( - timeSeconds = 317, - trackType = PrezelPlayerResourceTrackType.SPEECH, - markerType = PrezelPlayerResourceMarkerType.GOOD, +private val previewOverlappingItems = persistentListOf( + PrezelPlayerItem.Segment(timeMillis = 0L), + PrezelPlayerItem.Segment(timeMillis = 151_000L), + PrezelPlayerItem.Marker( + timeMillis = 317_000L, + markerType = PrezelPlayerMarkerType.GOOD, ), - PrezelPlayerResourceMarkerItem( - timeSeconds = 438, - trackType = PrezelPlayerResourceTrackType.SPEECH, - markerType = PrezelPlayerResourceMarkerType.GOOD, + PrezelPlayerItem.Segment(timeMillis = 360_000L), + PrezelPlayerItem.Marker( + timeMillis = 438_000L, + markerType = PrezelPlayerMarkerType.GOOD, ), - PrezelPlayerResourceMarkerItem( - timeSeconds = 151, - trackType = PrezelPlayerResourceTrackType.SPEECH, - markerType = PrezelPlayerResourceMarkerType.WARNING, + PrezelPlayerItem.Marker( + timeMillis = 151_000L, + markerType = PrezelPlayerMarkerType.WARNING, ), - PrezelPlayerResourceMarkerItem( - timeSeconds = 443, - trackType = PrezelPlayerResourceTrackType.SPEECH, - markerType = PrezelPlayerResourceMarkerType.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 index 9fcad60c..b7880334 100644 --- 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 @@ -76,16 +76,13 @@ class PrezelPlayerState internal constructor( val showHandle: Boolean get() = dragging - val currentItemIndex: Int + private val currentItemIndex: Int get() = if (items.isEmpty()) { -1 } else { - items.indexOfLast { item -> currentMillis >= item.startMillis }.coerceAtLeast(0) + items.indexOfLast { item -> currentMillis >= item.timeMillis }.coerceAtLeast(0) } - val currentItem: PrezelPlayerItem? - get() = items.getOrNull(currentItemIndex) - val previousEnabled: Boolean get() = currentItemIndex > 0 @@ -101,11 +98,11 @@ class PrezelPlayerState internal constructor( } fun moveToPreviousItem() { - if (previousEnabled) seekToMillis(items[currentItemIndex - 1].startMillis) + if (previousEnabled) seekToMillis(items[currentItemIndex - 1].timeMillis) } fun moveToNextItem() { - if (nextEnabled) seekToMillis(items[currentItemIndex + 1].startMillis) + if (nextEnabled) seekToMillis(items[currentItemIndex + 1].timeMillis) } fun startDrag() { @@ -117,7 +114,9 @@ class PrezelPlayerState internal constructor( } private fun seekToMillis(targetMillis: Long) { - onSeekToMillis(targetMillis.coercePlayerMillis(durationMillis)) + val coercedMillis = targetMillis.coercePlayerMillis(durationMillis) + if (dragging) currentMillis = coercedMillis + onSeekToMillis(coercedMillis) } internal fun update( @@ -128,7 +127,11 @@ class PrezelPlayerState internal constructor( ) { this.playing = playing this.durationMillis = durationMillis.coerceAtLeast(0L) - this.currentMillis = currentMillis.coercePlayerMillis(this.durationMillis) + if (!dragging) { + this.currentMillis = currentMillis.coercePlayerMillis(this.durationMillis) + } else { + this.currentMillis = this.currentMillis.coercePlayerMillis(this.durationMillis) + } this.items = items } } 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 index 752e90b0..2d6267b2 100644 --- 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 @@ -3,13 +3,17 @@ 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 @@ -29,16 +33,19 @@ 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, - markers: ImmutableList, - type: PrezelPlayerResourceTrackType, + items: ImmutableList, contentDescription: String, showHandle: Boolean, onSeek: (Float) -> Unit, @@ -72,12 +79,11 @@ internal fun PrezelPlayerTimeline( playedBarVisible = !showHandle, ) PlayerTimelineMarkers( - markers = markers, - type = type, + items = items, durationMillis = durationMillis, ) - if (showHandle) PlayerTimelineHandle(progress = progress, zIndex = markers.size + 1f) + if (showHandle) PlayerTimelineHandle(progress = progress, zIndex = items.size + 1f) } } @@ -149,34 +155,31 @@ private fun PlayerTimelinePlayedBar(progress: Float) { @Composable private fun BoxWithConstraintsScope.PlayerTimelineMarkers( - markers: ImmutableList, - type: PrezelPlayerResourceTrackType, + items: ImmutableList, durationMillis: Long, ) { var visibleMarkerIndex = 0 - markers.forEach { marker -> - if (marker.matchesTrackType(type)) { - PrezelPlayerResourceMarker( - type = marker.markerType, - modifier = Modifier - .align(Alignment.CenterStart) - .offset { - val markerX = ((maxWidth.toPx() - 8.dp.toPx()) * marker.progressIn(durationMillis)).roundToInt() - IntOffset(x = markerX, y = 0) - }.zIndex(visibleMarkerIndex.toFloat()), - ) - visibleMarkerIndex += 1 - } + 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 PrezelPlayerResourceMarkerItem.matchesTrackType(type: PrezelPlayerResourceTrackType): Boolean = trackType == type - -private fun PrezelPlayerResourceMarkerItem.progressIn(durationMillis: Long): Float = +private fun PrezelPlayerItem.progressIn(durationMillis: Long): Float = if (durationMillis <= 0L) { 0f } else { - (timeSeconds * 1_000f / durationMillis).coerceIn(0f, 1f) + (timeMillis.toFloat() / durationMillis).coerceIn(0f, 1f) } @Composable @@ -196,3 +199,111 @@ private fun BoxWithConstraintsScope.PlayerTimelineHandle( .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, + ), +) From 215e9cf7e25b2cd85add68f0d579c6b7d6422344 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 10 May 2026 14:44:29 +0900 Subject: [PATCH 08/11] =?UTF-8?q?refactor:=20`PrezelPlayerState`=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=84?= =?UTF-8?q?=EC=86=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `PrezelPlayerState` 호이스팅 및 업데이트 로직 변경** * `rememberPrezelPlayerState`에서 외부 콜백(`onPlayPauseClick`, `onSeekToMillis`) 및 `SideEffect`를 통한 자동 업데이트 로직을 제거했습니다. * 상태 객체 내부에서 직접 상태를 변경할 수 있도록 `updatePlaying`, `updateCurrentMillis`, `togglePlaying` 메서드를 추가했습니다. * `currentMillis` 및 `durationMillis` 설정 시 사용되던 `coercePlayerMillis` 유틸리티와 불필요한 제약 로직을 제거했습니다. * **refactor: `PrezelPlayer` 컴포넌트 내부 리팩터링** * 플레이어 제어 콜백을 `state` 객체의 메서드(`togglePlaying`, `seekToProgress` 등)를 직접 호출하도록 변경했습니다. * `PrezelPlayer` 레이아웃 구성 시 `Arrangement.spacedBy` 대신 명시적인 `Spacer`를 사용하도록 수정했습니다. * 프리뷰(Preview) 코드에서 외부 변수로 관리하던 재생 상태를 `playerState` 내부 상태를 사용하도록 업데이트했습니다. * **refactor: `PrezelPlayerResourceTrack` 최적화** * 시간 포맷팅 및 진행률 계산 시 불필요하게 호출되던 범위를 제한하는 로직(`coercePlayerMillis`)을 제거하고 원본 값을 사용하도록 단순화했습니다. --- .../component/player/PrezelPlayer.kt | 73 ++++-------------- .../player/PrezelPlayerResourceTrack.kt | 13 ++-- .../component/player/PrezelPlayerState.kt | 77 +++++-------------- 3 files changed, 42 insertions(+), 121 deletions(-) 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 index 32d007d5..18f1fda6 100644 --- 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 @@ -3,18 +3,15 @@ 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.runtime.getValue -import androidx.compose.runtime.mutableLongStateOf -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.res.stringResource @@ -47,10 +44,10 @@ fun PrezelPlayer( currentMillis = state.currentMillis, items = state.items, trackContentDescription = trackContentDescription, - onPlayPauseClick = state::playPause, + onPlayPauseClick = state::togglePlaying, onPreviousClick = state::moveToPreviousItem, onNextClick = state::moveToNextItem, - onSeek = state::seekToProgress, + onSeek = { progress -> state.seekToProgress(progress) }, modifier = modifier, idle = state.idle, showHandle = state.showHandle, @@ -84,7 +81,6 @@ private fun PrezelPlayerContent( modifier = modifier .fillMaxWidth() .padding(vertical = PrezelTheme.spacing.V20), - verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), ) { PrezelPlayerTrackSection( durationMillis = durationMillis, @@ -98,6 +94,8 @@ private fun PrezelPlayerContent( onDragStopped = onDragStopped, ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + PrezelPlayerControls( playing = playing, onPlayPauseClick = onPlayPauseClick, @@ -224,69 +222,30 @@ private fun PrezelPlayerPlayPauseButton( ) } -@BasicPreview -@Composable -private fun PrezelPlayerPreview() { - PrezelTheme { - PreviewSection(title = "Player") { - PlayerPreviewItem(name = "playing=off") { - PrezelPlayer( - state = rememberPrezelPlayerState( - playing = false, - durationMillis = 690_000L, - currentMillis = 0L, - items = previewPlayerItems, - onPlayPauseClick = {}, - onSeekToMillis = {}, - ), - trackContentDescription = stringResource(R.string.core_designsystem_player_speech_track_desc), - modifier = Modifier.width(360.dp), - ) - } - PlayerPreviewItem(name = "playing=on") { - PrezelPlayer( - state = rememberPrezelPlayerState( - playing = true, - durationMillis = 690_000L, - currentMillis = 234_000L, - items = previewPlayerItems, - onPlayPauseClick = {}, - onSeekToMillis = {}, - ), - trackContentDescription = stringResource(R.string.core_designsystem_player_speech_track_desc), - modifier = Modifier.width(360.dp), - ) - } - } - } -} - @BasicPreview @Composable private fun PrezelPlayerPlaybackPreview() { PrezelTheme { - var playing by remember { mutableStateOf(false) } - var currentMillis by remember { mutableLongStateOf(0L) } val durationMillis = 690_000L val playerState = rememberPrezelPlayerState( - playing = playing, durationMillis = durationMillis, - currentMillis = currentMillis, - items = previewPlayerItems, - onPlayPauseClick = { playing = !playing }, - onSeekToMillis = { targetMillis -> currentMillis = targetMillis }, + initialItems = previewPlayerItems, ) - LaunchedEffect(playing) { - while (playing) { + LaunchedEffect(playerState.playing) { + while (playerState.playing) { delay(1_000L) - currentMillis = (currentMillis + 1_000L).coerceAtMost(durationMillis) - if (currentMillis == durationMillis) playing = false + playerState.updateCurrentMillis( + currentMillis = (playerState.currentMillis + 1_000L).coerceAtMost(playerState.durationMillis), + ) + if (playerState.currentMillis == playerState.durationMillis) { + playerState.updatePlaying(false) + } } } PreviewSection(title = "Player Playback") { - PlayerPreviewItem(name = if (playing) "playing" else "paused") { + PlayerPreviewItem(name = if (playerState.playing) "playing" else "paused") { PrezelPlayer( state = playerState, trackContentDescription = stringResource(R.string.core_designsystem_player_speech_track_desc), 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 index a7b95706..fe9d3fb6 100644 --- 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 @@ -33,9 +33,8 @@ internal fun PrezelPlayerResourceTrack( onDragStarted: () -> Unit = {}, onDragStopped: () -> Unit = {}, ) { - val displayedDurationMillis = durationMillis.coerceAtLeast(0L) - val displayedCurrentMillis = if (idle) 0L else currentMillis.coercePlayerMillis(displayedDurationMillis) - val displayedProgress = if (idle) 0f else displayedCurrentMillis.toPlayerProgress(displayedDurationMillis) + val displayedCurrentMillis = if (idle) 0L else currentMillis + val displayedProgress = if (idle) 0f else displayedCurrentMillis.toPlayerProgress(durationMillis) Column( modifier = modifier.heightIn(min = 40.dp), @@ -43,7 +42,7 @@ internal fun PrezelPlayerResourceTrack( ) { PrezelPlayerTimeline( progress = displayedProgress, - durationMillis = displayedDurationMillis, + durationMillis = durationMillis, items = items, contentDescription = contentDescription, showHandle = showHandle, @@ -55,7 +54,7 @@ internal fun PrezelPlayerResourceTrack( PlayerTrackTimeLabels( currentMillis = displayedCurrentMillis, - durationMillis = displayedDurationMillis, + durationMillis = durationMillis, ) } } @@ -90,10 +89,8 @@ private fun Long.toPlayerProgress(durationMillis: Long): Float = (toFloat() / durationMillis.toFloat()).coerceIn(0f, 1f) } -private fun Long.coercePlayerMillis(durationMillis: Long): Long = coerceIn(0L, durationMillis.coerceAtLeast(0L)) - private fun Long.formatPlayerTime(): String { - val totalSeconds = coerceAtLeast(0L) / 1_000L + val totalSeconds = this / 1_000L val minutes = totalSeconds / 60L val seconds = totalSeconds % 60L return "${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}" 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 index b7880334..4ae0cfdb 100644 --- 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 @@ -1,67 +1,44 @@ package com.team.prezel.core.designsystem.component.player import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import kotlinx.collections.immutable.ImmutableList @Composable fun rememberPrezelPlayerState( - playing: Boolean, durationMillis: Long, - currentMillis: Long, - items: ImmutableList, - onPlayPauseClick: () -> Unit, - onSeekToMillis: (Long) -> Unit, -): PrezelPlayerState { - val currentOnPlayPauseClick = rememberUpdatedState(onPlayPauseClick) - val currentOnSeekToMillis = rememberUpdatedState(onSeekToMillis) - - val state = remember { + initialItems: ImmutableList, + playing: Boolean = false, + currentMillis: Long = 0L, +): PrezelPlayerState = + remember { PrezelPlayerState( playing = playing, durationMillis = durationMillis, currentMillis = currentMillis, - items = items, - onPlayPauseClick = { currentOnPlayPauseClick.value() }, - onSeekToMillis = { targetMillis -> currentOnSeekToMillis.value(targetMillis) }, - ) - } - - SideEffect { - state.update( - playing = playing, - durationMillis = durationMillis, - currentMillis = currentMillis, - items = items, + items = initialItems, ) } - return state -} - @Stable class PrezelPlayerState internal constructor( playing: Boolean, durationMillis: Long, currentMillis: Long, items: ImmutableList, - private val onPlayPauseClick: () -> Unit, - private val onSeekToMillis: (Long) -> Unit, ) { var playing by mutableStateOf(playing) private set - var durationMillis by mutableLongStateOf(durationMillis.coerceAtLeast(0L)) + var durationMillis by mutableLongStateOf(durationMillis) private set - var currentMillis by mutableLongStateOf(currentMillis.coercePlayerMillis(this.durationMillis)) + var currentMillis by mutableLongStateOf(currentMillis) private set var items by mutableStateOf(items) @@ -89,10 +66,6 @@ class PrezelPlayerState internal constructor( val nextEnabled: Boolean get() = currentItemIndex >= 0 && currentItemIndex < items.lastIndex - fun playPause() { - onPlayPauseClick() - } - fun seekToProgress(progress: Float) { seekToMillis((durationMillis * progress.coerceIn(0f, 1f)).toLong()) } @@ -105,6 +78,18 @@ class PrezelPlayerState internal constructor( if (nextEnabled) seekToMillis(items[currentItemIndex + 1].timeMillis) } + fun updatePlaying(playing: Boolean) { + this.playing = playing + } + + fun togglePlaying() { + playing = !playing + } + + fun updateCurrentMillis(currentMillis: Long) { + this.currentMillis = currentMillis + } + fun startDrag() { dragging = true } @@ -114,26 +99,6 @@ class PrezelPlayerState internal constructor( } private fun seekToMillis(targetMillis: Long) { - val coercedMillis = targetMillis.coercePlayerMillis(durationMillis) - if (dragging) currentMillis = coercedMillis - onSeekToMillis(coercedMillis) - } - - internal fun update( - playing: Boolean, - durationMillis: Long, - currentMillis: Long, - items: ImmutableList, - ) { - this.playing = playing - this.durationMillis = durationMillis.coerceAtLeast(0L) - if (!dragging) { - this.currentMillis = currentMillis.coercePlayerMillis(this.durationMillis) - } else { - this.currentMillis = this.currentMillis.coercePlayerMillis(this.durationMillis) - } - this.items = items + currentMillis = targetMillis } } - -private fun Long.coercePlayerMillis(durationMillis: Long): Long = coerceIn(0L, durationMillis.coerceAtLeast(0L)) From b968f129d06e31ed503775b9eaa779c0d796d292 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Sun, 10 May 2026 15:31:24 +0900 Subject: [PATCH 09/11] =?UTF-8?q?feat:=20PrezelPlayerState=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=B3=B4=EC=A1=B4=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?Saver=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20rememberSaveable=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: PrezelPlayerState 상태 보존 로직 추가** * 구성 변경(화면 회전 등) 시에도 플레이어의 상태가 유지되도록 `remember`를 `rememberSaveable`로 변경했습니다. * `PrezelPlayerState`를 저장하고 복원하기 위한 `Saver`를 companion object에 구현했습니다. * **refactor: PrezelPlayerItem 직렬화 로직 구현** * `PrezelPlayerItem`의 하위 타입인 `Segment`와 `Marker`를 `List` 형태로 변환하여 저장하고 복구하는 매핑 로직(`toSaveable`, `toPrezelPlayerItem`)을 추가했습니다. * 플레이어 상태 값(`playing`, `durationMillis`, `currentMillis`)과 아이템 리스트를 모두 `Saver`를 통해 관리하도록 개선했습니다. --- .../component/player/PrezelPlayerState.kt | 67 ++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) 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 index 4ae0cfdb..6259164f 100644 --- 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 @@ -5,9 +5,11 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +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( @@ -16,7 +18,9 @@ fun rememberPrezelPlayerState( playing: Boolean = false, currentMillis: Long = 0L, ): PrezelPlayerState = - remember { + rememberSaveable( + saver = PrezelPlayerState.Saver, + ) { PrezelPlayerState( playing = playing, durationMillis = durationMillis, @@ -101,4 +105,63 @@ class PrezelPlayerState internal constructor( private fun seekToMillis(targetMillis: Long) { currentMillis = targetMillis } + + 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" + } } From 1e8a5b3e076a87a17fb68864b4a36a354a6a74fb Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Mon, 11 May 2026 15:19:23 +0900 Subject: [PATCH 10/11] =?UTF-8?q?refactor:=20PrezelPlayer=20=EC=9E=AC?= =?UTF-8?q?=EC=83=9D=20=EC=A0=9C=EC=96=B4=20=EB=B0=8F=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: PrezelPlayerState 재생/일시정지 로직 고도화** * `updatePlaying(Boolean)` 대신 명시적인 `play()`, `pause()` 메서드를 도입했습니다. * `play()` 호출 시 재생이 이미 종료된 상태(`playbackEnded`)라면 처음(0ms)부터 다시 재생하도록 개선했습니다. * `togglePlaying()`에서 위에서 정의된 `play`/`pause` 로직을 사용하도록 변경했습니다. * **refactor: 이전/다음 아이템 이동 및 탐색(Seek) 로직 개선** * `previousEnabled`: 현재 재생 위치가 현재 아이템의 시작점보다 뒤에 있는 경우에도 이전 버튼을 활성화하도록 조건을 확장했습니다. * `moveToPreviousItem`: 현재 재생 위치에 따라 현재 아이템의 시작점으로 돌아가거나, 진짜 이전 아이템으로 이동하도록 로직을 세분화했습니다. * `seekToMillis` 및 `updateCurrentMillis`: 입력값이 0과 전체 재생 시간(`durationMillis`) 범위를 벗어나지 않도록 `coerceIn`을 적용했습니다. * **refactor: PrezelPlayer 재생 종료 처리 수정** * 재생 시간이 끝에 도달했을 때 직접 상태를 변경하던 로직을 `playerState.pause()` 호출로 변경하여 일관성을 유지했습니다. --- .../component/player/PrezelPlayer.kt | 2 +- .../component/player/PrezelPlayerState.kt | 36 +++++++++++++++---- 2 files changed, 30 insertions(+), 8 deletions(-) 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 index 18f1fda6..7f648965 100644 --- 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 @@ -239,7 +239,7 @@ private fun PrezelPlayerPlaybackPreview() { currentMillis = (playerState.currentMillis + 1_000L).coerceAtMost(playerState.durationMillis), ) if (playerState.currentMillis == playerState.durationMillis) { - playerState.updatePlaying(false) + playerState.pause() } } } 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 index 6259164f..3707de23 100644 --- 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 @@ -57,6 +57,9 @@ class PrezelPlayerState internal constructor( val showHandle: Boolean get() = dragging + private val playbackEnded: Boolean + get() = currentMillis >= durationMillis + private val currentItemIndex: Int get() = if (items.isEmpty()) { -1 @@ -65,7 +68,10 @@ class PrezelPlayerState internal constructor( } val previousEnabled: Boolean - get() = currentItemIndex > 0 + get() { + val currentItem = items.getOrNull(currentItemIndex) ?: return false + return currentItemIndex > 0 || currentMillis > currentItem.timeMillis + } val nextEnabled: Boolean get() = currentItemIndex >= 0 && currentItemIndex < items.lastIndex @@ -75,23 +81,39 @@ class PrezelPlayerState internal constructor( } fun moveToPreviousItem() { - if (previousEnabled) seekToMillis(items[currentItemIndex - 1].timeMillis) + 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 updatePlaying(playing: Boolean) { - this.playing = playing + fun play() { + if (playbackEnded) seekToMillis(0L) + playing = true + } + + fun pause() { + playing = false } fun togglePlaying() { - playing = !playing + if (playing) { + pause() + } else { + play() + } } fun updateCurrentMillis(currentMillis: Long) { - this.currentMillis = currentMillis + this.currentMillis = currentMillis.coerceIn(0L, durationMillis) } fun startDrag() { @@ -103,7 +125,7 @@ class PrezelPlayerState internal constructor( } private fun seekToMillis(targetMillis: Long) { - currentMillis = targetMillis + currentMillis = targetMillis.coerceIn(0L, durationMillis) } companion object { From 4722cfb85dcc9a467e1d9a0e64201607bcc88667 Mon Sep 17 00:00:00 2001 From: Ham BeomJoon Date: Mon, 11 May 2026 15:39:05 +0900 Subject: [PATCH 11/11] =?UTF-8?q?refactor:=20PrezelPlayer=20=EC=9E=AC?= =?UTF-8?q?=EC=83=9D=20=EC=99=84=EB=A3=8C=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: PrezelPlayerState 내 재생 상태 관리 및 초기화 로직 개선 `rememberSaveable`의 갱신 기준을 강화하고, 재생 종료에 따른 상태 변경 로직을 상태 클래스 내부로 캡슐화했습니다. * `rememberSaveable`의 `inputs` 파라미터에 `durationMillis`와 `initialItems`를 추가하여 데이터 변경 시 상태가 올바르게 재설정되도록 수정 * `updateCurrentMillis` 메서드 내에 재생 종료(`playbackEnded`) 시 자동으로 일시정지(`pause()`)하는 로직을 추가하여 상태 일관성 확보 * refactor: PrezelPlayer 컴포넌트 코드 정리 및 책임 분리 UI 레이어에 존재하던 비즈니스 로직을 상태 클래스로 이관하고 코드를 간결하게 수정했습니다. * `onSeek` 콜백 함수를 메서드 참조(`state::seekToProgress`) 방식으로 변경 * 매 초마다 현재 시간을 업데이트할 때 수행하던 재생 종료 체크 로직을 `PrezelPlayerState` 내부로 이동하여 UI 컴포넌트의 복잡도 감소 --- .../core/designsystem/component/player/PrezelPlayer.kt | 5 +---- .../core/designsystem/component/player/PrezelPlayerState.kt | 3 +++ 2 files changed, 4 insertions(+), 4 deletions(-) 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 index 7f648965..47afcf99 100644 --- 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 @@ -47,7 +47,7 @@ fun PrezelPlayer( onPlayPauseClick = state::togglePlaying, onPreviousClick = state::moveToPreviousItem, onNextClick = state::moveToNextItem, - onSeek = { progress -> state.seekToProgress(progress) }, + onSeek = state::seekToProgress, modifier = modifier, idle = state.idle, showHandle = state.showHandle, @@ -238,9 +238,6 @@ private fun PrezelPlayerPlaybackPreview() { playerState.updateCurrentMillis( currentMillis = (playerState.currentMillis + 1_000L).coerceAtMost(playerState.durationMillis), ) - if (playerState.currentMillis == playerState.durationMillis) { - playerState.pause() - } } } 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 index 3707de23..df108db4 100644 --- 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 @@ -19,6 +19,8 @@ fun rememberPrezelPlayerState( currentMillis: Long = 0L, ): PrezelPlayerState = rememberSaveable( + durationMillis, + initialItems, saver = PrezelPlayerState.Saver, ) { PrezelPlayerState( @@ -114,6 +116,7 @@ class PrezelPlayerState internal constructor( fun updateCurrentMillis(currentMillis: Long) { this.currentMillis = currentMillis.coerceIn(0L, durationMillis) + if (playbackEnded) pause() } fun startDrag() {