Coverage Summary for Class: Mark (com.vsevolodganin.clicktrack.ui.piece)

Class Class, % Method, % Branch, % Line, % Instruction, %
Mark 0% (0/1) 0% (0/1) 0% (0/4) 0% (0/17)


 package com.vsevolodganin.clicktrack.ui.piece
 
 import androidx.compose.animation.core.Animatable
 import androidx.compose.animation.core.AnimationSpec
 import androidx.compose.animation.core.LinearEasing
 import androidx.compose.animation.core.Spring
 import androidx.compose.animation.core.spring
 import androidx.compose.animation.core.tween
 import androidx.compose.foundation.Canvas
 import androidx.compose.foundation.gestures.awaitEachGesture
 import androidx.compose.foundation.gestures.awaitFirstDown
 import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
 import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.BoxWithConstraints
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.wrapContentSize
 import androidx.compose.material.ContentAlpha
 import androidx.compose.material.MaterialTheme
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.State
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.composed
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.geometry.Rect
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.drawscope.Stroke
 import androidx.compose.ui.graphics.drawscope.withTransform
 import androidx.compose.ui.hapticfeedback.HapticFeedbackType
 import androidx.compose.ui.input.pointer.PointerInputChange
 import androidx.compose.ui.input.pointer.pointerInput
 import androidx.compose.ui.input.pointer.util.VelocityTracker
 import androidx.compose.ui.layout.Layout
 import androidx.compose.ui.layout.layoutId
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.platform.LocalHapticFeedback
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.util.fastAny
 import com.vsevolodganin.clicktrack.model.ClickTrack
 import com.vsevolodganin.clicktrack.model.PlayProgress
 import com.vsevolodganin.clicktrack.model.interval
 import com.vsevolodganin.clicktrack.ui.preview.PREVIEW_CLICK_TRACK_1
 import com.vsevolodganin.clicktrack.utils.compose.AnimatableFloat
 import com.vsevolodganin.clicktrack.utils.compose.AnimatableRect
 import com.vsevolodganin.clicktrack.utils.compose.KeepScreenOn
 import com.vsevolodganin.clicktrack.utils.compose.Preview
 import com.vsevolodganin.clicktrack.utils.compose.detectTransformGesturesWithEndCallbacks
 import kotlinx.coroutines.CoroutineStart
 import kotlinx.coroutines.launch
 import kotlin.math.roundToInt
 import kotlin.time.Clock
 import kotlin.time.Duration
 import kotlin.time.Duration.Companion.seconds
 import kotlin.time.ExperimentalTime
 
 @Composable
 fun ClickTrackView(
     clickTrack: ClickTrack,
     modifier: Modifier = Modifier,
     playTrackingMode: Boolean = false,
     drawAllBeatsMarks: Boolean = false,
     drawTextMarks: Boolean = true,
     progress: PlayProgress? = null,
     progressDragAndDropEnabled: Boolean = false,
     onProgressDragStart: () -> Unit = {},
     onProgressDrop: (Double) -> Unit = {},
     viewportPanEnabled: Boolean = false,
     defaultLineWidth: Float = Stroke.HairlineWidth,
 ) {
     BoxWithConstraints(modifier = modifier) {
         // Bounds
         val width = minWidth
         val height = minHeight
         val widthPx = with(LocalDensity.current) { width.toPx() }
         val heightPx = with(LocalDensity.current) { height.toPx() }
 
         // Progress
         var isProgressCaptured by remember { mutableStateOf(false) }
         val progressLineWidth by progressLineWidth(defaultLineWidth, isProgressCaptured)
         val progressLinePosition = progressLinePosition(progress, clickTrack.durationInTime, widthPx)
         val progressLineColor = MaterialTheme.colors.secondaryVariant
 
         // Camera
         val bounds = remember { Rect(0f, 0f, widthPx, heightPx) }
         val viewportState = remember { AnimatableRect(bounds) }
         val viewportTransformations = viewportState.transformations
         val scaleX = viewportTransformations.scaleX
         val scaleY = viewportTransformations.scaleY
         val translateX = viewportTransformations.translateX
         val translateY = viewportTransformations.translateY
 
         // Play tracking mode
         var isGestureInProcess by remember { mutableStateOf(false) }
         PlayTrackingModeImpl(
             viewportState = viewportState,
             progressLinePosition = progressLinePosition,
             playTrackingMode = playTrackingMode,
             isProgressCaptured = isProgressCaptured,
             isGestureInProcess = isGestureInProcess,
         )
 
         // Marks
         val marks = clickTrack.asMarks(widthPx, drawAllBeatsMarks)
 
         fun Float.transformXAndPixelAlign(): Float {
             return ((this + translateX) * scaleX).roundToInt().toFloat()
         }
 
         val transformedAndPixelAlignedMarks = remember(marks, translateX, scaleX) {
             marks.map { mark -> mark.copy(x = mark.x.transformXAndPixelAlign()) }
         }
 
         // Wakelock on playing
         // FIXME: Should be im model layer
         if (progress != null) {
             KeepScreenOn()
         }
 
         Canvas(
             modifier = Modifier
                 .clickTrackGestures(
                     viewportZoomAndPanEnabled = viewportPanEnabled,
                     progressDragAndDropEnabled = progressDragAndDropEnabled,
                     viewportState = viewportState,
                     progressPosition = progressLinePosition,
                     onGestureStarted = { isGestureInProcess = true },
                     onGestureEnded = { isGestureInProcess = false },
                     onProgressDragStart = { progress ->
                         isProgressCaptured = true
                         progress.stop()
                         onProgressDragStart()
                     },
                     onProgressDrop = { progress ->
                         isProgressCaptured = false
                         onProgressDrop(progress.value.toDouble() / widthPx)
                     },
                 )
                 .size(width, height),
         ) {
             withTransform(transformBlock = {
                 // Transform only Y because X transformation needs post pixel alignment
                 scale(1f, scaleY, Offset(0f, 0f))
                 translate(0f, translateY)
             }, drawBlock = {
                 for (mark in transformedAndPixelAlignedMarks) {
                     val markX = mark.x
                     drawLine(
                         color = mark.color,
                         strokeWidth = defaultLineWidth,
                         start = Offset(markX, 0f),
                         end = Offset(markX, size.height),
                     )
                 }
 
                 if (progressLinePosition != null) {
                     val progressX = progressLinePosition.value.transformXAndPixelAlign()
                     drawLine(
                         color = progressLineColor,
                         strokeWidth = progressLineWidth,
                         start = Offset(progressX, 0f),
                         end = Offset(progressX, size.height),
                     )
                 }
             })
         }
 
         if (drawTextMarks) {
             Layout(modifier = Modifier.wrapContentSize(), content = {
                 for ((index, mark) in transformedAndPixelAlignedMarks.withIndex()) {
                     mark.summary?.let { summary ->
                         Box(modifier = Modifier.layoutId(index)) {
                             summary()
                         }
                     }
                 }
             }, measurePolicy = { measurables, constraints ->
                 val placeables = measurables.map { measurable ->
                     val index = measurable.layoutId as Int
                     val mark = transformedAndPixelAlignedMarks[index]
                     mark to measurable.measure(Constraints())
                 }.sortedWith { (lhs, _), (rhs, _) -> lhs.x.compareTo(rhs.x) }
 
                 layout(constraints.maxWidth, constraints.maxHeight) {
                     class Obstacle(val offset: IntOffset, val size: IntSize)
 
                     val obstacles = mutableListOf<Obstacle>()
 
                     placeables.forEach { (mark, placeable) ->
                         val x = mark.x.roundToInt()
                         var y = 0
 
                         val menacingObstacles = obstacles
                             .filter { it.offset.x + it.size.width >= x }
                             .sortedBy { it.offset.y }
 
                         for (menacingObstacle in menacingObstacles) {
                             if (menacingObstacle.offset.y > y + placeable.height) {
                                 break
                             } else {
                                 y = menacingObstacle.offset.y + menacingObstacle.size.height + 1
                             }
                         }
 
                         placeable.placeRelative(x, y)
 
                         obstacles += Obstacle(IntOffset(x, y), IntSize(placeable.width, placeable.height))
                     }
                 }
             })
         }
     }
 }
 
 @Composable
 private fun progressLinePosition(progress: PlayProgress?, totalDuration: Duration, totalWidthPx: Float): AnimatableFloat? {
     progress ?: return null
 
     val animatableProgressLinePosition = remember { Animatable(0f) }.apply {
         updateBounds(0f, totalWidthPx)
     }
 
     LaunchedEffect(progress, totalDuration, totalWidthPx) {
         val progressTimePosition = progress.realPosition
         val progressXPosition = progressTimePosition.toX(totalDuration, totalWidthPx)
 
         animatableProgressLinePosition.snapTo(progressXPosition)
 
         if (!progress.isPaused) {
             val animationDuration = (totalDuration - progressTimePosition).coerceAtLeast(Duration.ZERO)
             animatableProgressLinePosition.animateTo(
                 targetValue = totalWidthPx,
                 animationSpec = tween(
                     durationMillis = animationDuration.inWholeMilliseconds.toInt(),
                     easing = LinearEasing,
                 ),
             )
         }
     }
 
     return animatableProgressLinePosition
 }
 
 @Composable
 private fun progressLineWidth(defaultWidth: Float, isProgressCaptured: Boolean): State<Float> {
     val animatableWidth = remember { Animatable(defaultWidth) }
 
     LaunchedEffect(isProgressCaptured) {
         val newProgressLineWidth = when (isProgressCaptured) {
             true -> PROGRESS_LINE_WIDTH_CAPTURED
             false -> defaultWidth
         }
         val animSpec: AnimationSpec<Float> = when (isProgressCaptured) {
             true -> spring(
                 dampingRatio = Spring.DampingRatioHighBouncy,
                 stiffness = Spring.StiffnessLow,
             )
 
             false -> spring()
         }
 
         animatableWidth.animateTo(
             targetValue = newProgressLineWidth,
             animationSpec = animSpec,
         )
     }
 
     return animatableWidth.asState()
 }
 
 private data class Mark(
     val x: Float,
     val color: Color,
     val summary: (@Composable () -> Unit)?,
 )
 
 @Composable
 private fun ClickTrack.asMarks(width: Float, drawAllBeatsMarks: Boolean): List<Mark> {
     val primaryMarkColor = MaterialTheme.colors.onSurface
     val secondaryMarkColor = primaryMarkColor.copy(alpha = ContentAlpha.medium)
 
     return remember(cues, width, drawAllBeatsMarks) {
         val result = mutableListOf<Mark>()
         val duration = durationInTime
 
         var currentTimestamp = Duration.ZERO
         var currentX = 0f
         for (cue in cues) {
             result += Mark(
                 x = currentX,
                 color = primaryMarkColor,
                 summary = { CueSummaryView(cue, tempoOffset) },
             )
             if (drawAllBeatsMarks) {
                 for (i in 1 until cue.timeSignature.noteCount) {
                     result += Mark(
                         x = (currentTimestamp + cue.bpm.interval * i).toX(duration, width),
                         color = secondaryMarkColor,
                         summary = null,
                     )
                 }
             }
             val nextTimestamp = currentTimestamp + cue.durationAsTimeWithBpmOffset(tempoOffset)
             currentX = nextTimestamp.toX(duration, width)
             currentTimestamp = nextTimestamp
         }
 
         result.distinctBy(Mark::x)
     }
 }
 
 private fun Duration.toX(totalDuration: Duration, viewWidth: Float): Float {
     return if (totalDuration == Duration.ZERO || viewWidth == 0f) {
         0f
     } else {
         (this / totalDuration * viewWidth).toFloat()
     }
 }
 
 @OptIn(ExperimentalTime::class)
 private fun Modifier.clickTrackGestures(
     viewportZoomAndPanEnabled: Boolean,
     progressDragAndDropEnabled: Boolean,
     viewportState: AnimatableRect,
     progressPosition: AnimatableFloat?,
     onGestureStarted: () -> Unit,
     onGestureEnded: () -> Unit,
     onProgressDragStart: suspend (progress: AnimatableFloat) -> Unit,
     onProgressDrop: suspend (progress: AnimatableFloat) -> Unit,
 ): Modifier = composed {
     if ((progressDragAndDropEnabled && progressPosition != null) || viewportZoomAndPanEnabled) {
         val hapticFeedback = LocalHapticFeedback.current
         val coroutineScope = rememberCoroutineScope()
         var progressDragInProgress by remember { mutableStateOf(false) }
 
         this
             .pointerInput(Unit) {
                 awaitEachGesture {
                     awaitFirstDown(requireUnconsumed = false)
                     onGestureStarted()
                     while (awaitPointerEvent().changes.fastAny(PointerInputChange::pressed)) Unit
                     onGestureEnded()
                 }
             }
             .pointerInput(
                 progressDragAndDropEnabled,
                 progressPosition,
             ) {
                 if (progressDragAndDropEnabled && progressPosition != null) {
                     detectDragGesturesAfterLongPress(
                         onDragStart = {
                             progressDragInProgress = true
                             hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
                             coroutineScope.launch {
                                 onProgressDragStart(progressPosition)
                             }
                         },
                         onDragEnd = {
                             progressDragInProgress = false
                             coroutineScope.launch {
                                 onProgressDrop(progressPosition)
                             }
                         },
                         onDragCancel = {
                             progressDragInProgress = false
                             coroutineScope.launch {
                                 onProgressDrop(progressPosition)
                             }
                         },
                         onDrag = { _, dragAmount ->
                             val currentViewportTransformations = viewportState.transformations
                             val snapTo = progressPosition.value + dragAmount.x / currentViewportTransformations.scaleX
                             coroutineScope.launch {
                                 progressPosition.snapTo(snapTo)
                             }
                         },
                     )
                 }
             }
             .pointerInput(viewportZoomAndPanEnabled, progressDragInProgress) {
                 if (viewportZoomAndPanEnabled && !progressDragInProgress) {
                     detectTapGestures(
                         onDoubleTap = { offset ->
                             val currentViewportTransformations = viewportState.transformations
                             val viewport = viewportState.value
                             val bounds = viewportState.bounds
                             val newScale = if (currentViewportTransformations.scaleX < DETAILED_SCALE) DETAILED_SCALE else BASE_SCALE
                             val newWidth = bounds.width / newScale
                             val newLeft = viewport.left - (newWidth - viewport.width) * (offset.x - bounds.left) / bounds.width
                             val newRight = newLeft + newWidth
 
                             coroutineScope.launch {
                                 viewportState.animateTo(
                                     newLeft = newLeft.coerceIn(bounds.left, bounds.right - newWidth),
                                     newRight = newRight.coerceIn(bounds.left + newWidth, bounds.right),
                                 )
                             }
                         },
                     )
                 }
             }
             .pointerInput(viewportZoomAndPanEnabled, progressDragInProgress) {
                 if (viewportZoomAndPanEnabled && !progressDragInProgress) {
                     val velocityTracker = VelocityTracker()
                     detectTransformGesturesWithEndCallbacks(
                         panZoomLock = false,
                         onGesture = { centroid, pan, zoom, _ ->
                             val currentViewportTransformations = viewportState.transformations
                             val viewport = viewportState.value
                             val bounds = viewportState.bounds
                             val newWidth = (viewport.width / zoom).coerceAtMost(bounds.width)
                             val newLeft = viewport.left -
                                 (newWidth - viewport.width) * (centroid.x - bounds.left) / bounds.width -
                                 pan.x / currentViewportTransformations.scaleX
                             val newRight = newLeft + newWidth
 
                             if (zoom == 1f) {
                                 velocityTracker.addPosition(
                                     timeMillis = Clock.System.now().toEpochMilliseconds(),
                                     position = centroid,
                                 )
                             } else {
                                 velocityTracker.resetTracking()
                             }
 
                             coroutineScope.launch {
                                 viewportState.snapTo(
                                     newLeft = newLeft.coerceIn(bounds.left, bounds.right - newWidth),
                                     newRight = newRight.coerceIn(bounds.left + newWidth, bounds.right),
                                 )
                             }
                         },
                         onGestureEnd = {
                             val currentViewportTransformations = viewportState.transformations
                             val velocity = -velocityTracker
                                 .calculateVelocity()
                                 .run { Offset(x, y) } / currentViewportTransformations.scaleX
                             velocityTracker.resetTracking()
 
                             coroutineScope.launch {
                                 viewportState.animateDecay(velocity)
                             }
                         },
                     )
                 }
             }
     } else {
         this
     }
 }
 
 @Composable
 private fun PlayTrackingModeImpl(
     viewportState: AnimatableRect,
     progressLinePosition: AnimatableFloat?,
     playTrackingMode: Boolean,
     isProgressCaptured: Boolean,
     isGestureInProcess: Boolean,
 ) {
     // Sync camera position with progress position
     if (playTrackingMode && !isProgressCaptured && !isGestureInProcess && progressLinePosition != null) {
         val trackingModePadding = with(LocalDensity.current) { TRACKING_MODE_PADDING.toPx() }
         val coroutineScope = rememberCoroutineScope()
         coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
             viewportState.animateTo(left = progressLinePosition.value - trackingModePadding, scale = DETAILED_SCALE)
         }
     }
 
     // Zoom out if tracking mode is disabled
     LaunchedEffect(playTrackingMode) {
         if (!playTrackingMode) {
             viewportState.animateTo(left = 0f, scale = BASE_SCALE)
         }
     }
 
     // Zoom out if tracking mode is enabled but the track stopped
     LaunchedEffect(playTrackingMode, progressLinePosition) {
         if (playTrackingMode && progressLinePosition == null) {
             viewportState.animateTo(left = 0f, scale = BASE_SCALE)
         }
     }
 }
 
 private val AnimatableRect.transformations
     get() = object {
         val scaleX: Float
         val scaleY: Float
         val translateX: Float
         val translateY: Float
 
         init {
             val rect = value
             scaleX = bounds.width / rect.width
             scaleY = bounds.height / rect.height
             translateX = -rect.left
             translateY = -rect.top
         }
     }
 
 private suspend fun AnimatableRect.animateTo(left: Float, scale: Float) {
     val newWidth = bounds.width / scale
     val newRight = left + newWidth
     animateTo(
         newLeft = left.coerceIn(bounds.left, bounds.right - newWidth),
         newRight = newRight.coerceIn(bounds.left + newWidth, bounds.right),
     )
 }
 
 private const val PROGRESS_LINE_WIDTH_CAPTURED = 10f
 private const val BASE_SCALE = 1f
 private const val DETAILED_SCALE = 4f
 private val TRACKING_MODE_PADDING = 6.dp
 
 @Preview
 @Composable
 private fun Preview() {
     ClickTrackView(
         clickTrack = PREVIEW_CLICK_TRACK_1.value,
         drawTextMarks = true,
         progress = PlayProgress(1.seconds),
         modifier = Modifier.fillMaxSize(),
     )
 }