@file:Suppress("FunctionName") package link.roadio.riff.ui import android.graphics.Bitmap import androidx.compose.foundation.Canvas import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.absolutePadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue 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.focus.onFocusChanged import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.disabled import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import link.roadio.riff.player.core.selection.AiAxisRange import link.roadio.riff.player.core.selection.AiHeatmap import link.roadio.riff.player.core.selection.AiHeatmapRenderStyle import link.roadio.riff.player.core.selection.aiSmptePlacement import link.roadio.riff.player.core.selection.rasterizeAiHeatmap @Composable internal fun AiCanvasWithStatus( selection: AiSelectionUiState, heatmap: AiHeatmap?, enabled: Boolean, style: AiHeatmapRenderStyle, letterboxColor: Int, statusText: String?, statusTextSp: Float, night: Boolean, onPointChanged: (Float, Float) -> Unit, modifier: Modifier, ) { Box(modifier) { AvCanvas( selection = selection, heatmap = heatmap, enabled = enabled, style = style, letterboxColor = letterboxColor, onPointChanged = onPointChanged, modifier = Modifier.fillMaxSize(), ) CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { Box( Modifier .fillMaxSize() .clearAndSetSemantics { }, ) { AiCanvasText( value = "A", sizeSp = statusTextSp, night = night, textAlign = TextAlign.Left, modifier = Modifier .align(Alignment.TopStart) .absolutePadding( left = AI_CANVAS_TEXT_INSET_DP.dp, top = AI_CANVAS_TEXT_INSET_DP.dp, ), ) statusText?.let { value -> AiCanvasText( value = value, sizeSp = statusTextSp, night = night, textAlign = TextAlign.Center, modifier = Modifier .align(Alignment.TopCenter) .fillMaxWidth() .absolutePadding( left = AI_CANVAS_TEXT_INSET_DP.dp, top = AI_CANVAS_TEXT_INSET_DP.dp, right = AI_CANVAS_TEXT_INSET_DP.dp, ), ) } AiCanvasText( value = "V", sizeSp = statusTextSp, night = night, textAlign = TextAlign.Right, modifier = Modifier .align(Alignment.BottomEnd) .absolutePadding( right = AI_CANVAS_TEXT_INSET_DP.dp, bottom = AI_CANVAS_TEXT_INSET_DP.dp, ), ) AiCanvasText( value = AI_CANVAS_ADVISORY, sizeSp = statusTextSp, night = night, textAlign = TextAlign.Center, modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() .absolutePadding( left = AI_CANVAS_TEXT_INSET_DP.dp, right = AI_CANVAS_TEXT_INSET_DP.dp, bottom = AI_CANVAS_TEXT_INSET_DP.dp, ), ) } } } } @Composable internal fun AiCanvasText( value: String, sizeSp: Float, night: Boolean, textAlign: TextAlign, modifier: Modifier, ) { GlyphBackdropText( value = value, style = TextStyle( color = if (night) Color.White else Color.Black, fontSize = sizeSp.sp, fontFamily = FontFamily.Monospace, textAlign = textAlign, ), modifier = modifier, backdropColor = if (night) Color.Black else Color.White, maxLines = 1, softWrap = false, ) } internal data class RenderedAiHeatmap( val heatmap: AiHeatmap, val image: ImageBitmap, val width: Int, val height: Int, val rtl: Boolean, val style: AiHeatmapRenderStyle, ) @Composable internal fun AvCanvas( selection: AiSelectionUiState, heatmap: AiHeatmap?, enabled: Boolean, style: AiHeatmapRenderStyle, letterboxColor: Int, onPointChanged: (Float, Float) -> Unit, modifier: Modifier, ) { val context = LocalContext.current val layoutDirection = LocalLayoutDirection.current val rtl = layoutDirection == LayoutDirection.Rtl val shape = RoundedCornerShape(16.dp) val palette = LocalRiffPalette.current val config = LocalRiffConfig.current var focused by remember { mutableStateOf(false) } var canvasSize by remember { mutableStateOf(IntSize.Zero) } var renderedHeatmap by remember { mutableStateOf(null) } val readyHeatmap = renderedHeatmap?.takeIf { rendered -> rendered.width == canvasSize.width && rendered.height == canvasSize.height && rendered.rtl == rtl && rendered.style === style } val arousalRange = readyHeatmap?.heatmap?.arousalRange ?: AiAxisRange.FULL val valenceRange = readyHeatmap?.heatmap?.valenceRange ?: AiAxisRange.FULL val arousalStep = arousalRange.span / 10f val valenceStep = valenceRange.span / 10f val smpteBars = remember(context) { UiDrawableRegistry.drawable(context, "ic_smpte_bars").apply { setBounds(0, 0, 480, 360) } } LaunchedEffect(heatmap, canvasSize, rtl, style) { if (heatmap == null) { renderedHeatmap = null } else if (canvasSize.width > 0 && canvasSize.height > 0) { val rendered = withContext(Dispatchers.Default) { val raster = rasterizeAiHeatmap(heatmap, canvasSize.width, canvasSize.height, rtl, style) val image = Bitmap .createBitmap(raster.width, raster.height, Bitmap.Config.ARGB_8888) .apply { setPixels(raster.pixels, 0, raster.width, 0, 0, raster.width, raster.height) }.asImageBitmap() RenderedAiHeatmap( heatmap = heatmap, image = image, width = canvasSize.width, height = canvasSize.height, rtl = rtl, style = style, ) } renderedHeatmap = rendered } } Box( modifier .clip(shape) .onFocusChanged { focused = it.isFocused } .semantics { contentDescription = "Arousal ${selection.arousal.asAiValue()}; " + "Valence ${selection.valence.asAiValue()}; $AI_CANVAS_ADVISORY" if (enabled) { customActions = listOf( CustomAccessibilityAction("Increase Arousal") { onPointChanged( (selection.arousal + arousalStep).coerceAtMost(arousalRange.maximum), selection.valence, ) true }, CustomAccessibilityAction("Decrease Arousal") { onPointChanged( (selection.arousal - arousalStep).coerceAtLeast(arousalRange.minimum), selection.valence, ) true }, CustomAccessibilityAction("Increase Valence") { onPointChanged( selection.arousal, (selection.valence + valenceStep).coerceAtMost(valenceRange.maximum), ) true }, CustomAccessibilityAction("Decrease Valence") { onPointChanged( selection.arousal, (selection.valence - valenceStep).coerceAtLeast(valenceRange.minimum), ) true }, ) } else { disabled() customActions = emptyList() } }.focusable(enabled), ) { Canvas( Modifier .fillMaxSize() .onSizeChanged { canvasSize = it } .pointerInput(enabled, rtl, arousalRange, valenceRange) { if (enabled) { detectTapGestures { offset -> val point = aiPointFromCanvas( offset.x, offset.y, size.width.toFloat(), size.height.toFloat(), rtl, arousalRange, valenceRange, ) onPointChanged(point.arousal, point.valence) } } }.pointerInput(enabled, rtl, arousalRange, valenceRange) { if (enabled) { fun update(offset: Offset) { val point = aiPointFromCanvas( offset.x, offset.y, size.width.toFloat(), size.height.toFloat(), rtl, arousalRange, valenceRange, ) onPointChanged(point.arousal, point.valence) } detectDragGestures( onDragStart = ::update, onDrag = { change, _ -> change.consume() update(change.position) }, ) } }, ) { drawRect(Color(letterboxColor)) val readyImage = readyHeatmap?.image if (readyImage == null) { val placement = aiSmptePlacement(size.width, size.height) drawIntoCanvas { canvas -> val nativeCanvas = canvas.nativeCanvas val save = nativeCanvas.save() nativeCanvas.translate(placement.left, placement.top) nativeCanvas.scale(placement.width / 480f, placement.height / 360f) smpteBars.draw(nativeCanvas) nativeCanvas.restoreToCount(save) } return@Canvas } drawImage(readyImage) } if (readyHeatmap != null) { Canvas(Modifier.matchParentSize().contrastHalo(if (config.night) Color.Black else Color.White)) { val canvasPoint = aiPointToCanvas( point = AiPoint(selection.arousal, selection.valence), width = size.width, height = size.height, rtl = rtl, arousalRange = requireNotNull(readyHeatmap).heatmap.arousalRange, valenceRange = readyHeatmap.heatmap.valenceRange, ) val markerX = canvasPoint.x val markerY = canvasPoint.y val marker = Offset(markerX, markerY) val markerColor = Color(palette.primary) drawLine(markerColor, Offset(markerX, 0f), Offset(markerX, size.height), 2f) drawLine(markerColor, Offset(0f, markerY), Offset(size.width, markerY), 2f) drawCircle(markerColor, radius = 17f, center = marker, style = Stroke(width = 3f)) } } RiffStateOutline( Modifier.matchParentSize(), Color(if (focused && enabled) palette.focusRing else palette.outline), shape, if (focused && enabled) 3.dp else 2.dp, surround = true, ) } }