Hide flash toggle when recording video.

This commit is contained in:
Alex Hart
2026-09-09 16:44:26 -04:00
committed by Cody Henthorne
parent 533fcdc604
commit 406be67a6f
4 changed files with 132 additions and 96 deletions
@@ -0,0 +1,47 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
/** The controls laid over a full-bleed surface, such as a camera preview or a photo being edited. */
object Chrome {
/**
* A control on such a surface, and the two ways it gets out of the user's way. Going not-[visible] gives up its
* layout space, letting the rest of the stack settle into it, while [faded] holds onto the space -- releasing it
* mid-gesture would move whatever the user is dragging out from under their finger.
*/
@Composable
fun Control(
modifier: Modifier = Modifier,
visible: Boolean = true,
faded: Boolean = false,
enter: EnterTransition = fadeIn(),
exit: ExitTransition = fadeOut(),
content: @Composable () -> Unit
) {
val alpha by animateFloatAsState(targetValue = if (faded) 0f else 1f, label = "ChromeControlAlpha")
AnimatedVisibility(
visible = visible,
enter = enter,
exit = exit,
modifier = modifier.alpha(alpha)
) {
content()
}
}
}
@@ -74,6 +74,7 @@ import org.signal.camera.R
import org.signal.camera.test.TestTags
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.compose.AllNightPreviews
import org.signal.core.ui.compose.Chrome
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.rememberWindowBreakpoint
import java.util.Locale
@@ -93,7 +94,7 @@ private val ZOOM_BAR_SIDE_MARGIN = 16.dp
/** How long the time and the paused label take to trade the recording red between them. */
private const val PAUSED_TRANSITION_MS = 200
/** The close and flash buttons along the top of the window, which the recording pill lines its middle up with. */
/** The close and flash buttons along the top of the window, whose middle the recording pill lines itself up with. */
private val TOP_CONTROL_SIZE = 48.dp
/** How far the top controls sit in from the edges of the window. */
@@ -285,38 +286,44 @@ private fun BoxScope.StandardCameraHudContent(
ShutterOverlay(state.showShutter)
IconButton(
onClick = { emitter(StandardCameraHudEvents.CloseClick) },
enabled = !isRecordingHeld,
modifier = modifier
.padding(TOP_CONTROL_MARGIN)
.size(TOP_CONTROL_SIZE)
.fadedIn(!isRecordingHeld)
.background(colorResource(R.color.CameraHud_control_background), shape = CircleShape)
.testTag(TestTags.CAMERA_HUD_CLOSE_BUTTON)
Chrome.Control(
faded = isRecordingHeld,
modifier = modifier.padding(TOP_CONTROL_MARGIN)
) {
Icon(
imageVector = SignalIcons.X.imageVector,
contentDescription = null,
tint = colorResource(R.color.CameraHud_control_foreground),
IconButton(
onClick = { emitter(StandardCameraHudEvents.CloseClick) },
enabled = !isRecordingHeld,
modifier = Modifier
.size(24.dp)
.rotate(iconRotation)
)
.size(TOP_CONTROL_SIZE)
.background(colorResource(R.color.CameraHud_control_background), shape = CircleShape)
.testTag(TestTags.CAMERA_HUD_CLOSE_BUTTON)
) {
Icon(
imageVector = SignalIcons.X.imageVector,
contentDescription = null,
tint = colorResource(R.color.CameraHud_control_foreground),
modifier = Modifier
.size(24.dp)
.rotate(iconRotation)
)
}
}
if (isPortraitPhone) {
FlashToggleButton(
flashMode = state.flashMode,
onToggle = { emitter(StandardCameraHudEvents.ToggleFlash) },
stringResources = stringResources,
enabled = !isRecordingHeld,
// The flash belongs to a photo the camera has yet to take, so the button goes for as long as a recording runs.
Chrome.Control(
visible = !state.isRecording,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(TOP_CONTROL_MARGIN)
.fadedIn(!isRecordingHeld)
.rotate(iconRotation)
)
) {
FlashToggleButton(
flashMode = state.flashMode,
onToggle = { emitter(StandardCameraHudEvents.ToggleFlash) },
stringResources = stringResources,
modifier = Modifier.rotate(iconRotation)
)
}
}
if (state.isRecording) {
@@ -397,17 +404,6 @@ private fun Offset.rotatedBy(degrees: Float): Offset {
return Offset(x = x * cosine - y * sine, y = x * sine + y * cosine)
}
/**
* Fades a piece of chrome in or out in place. It keeps its space in the layout while it is gone, so what is around it
* cannot move out from under a finger that is midway through a gesture.
*/
@Composable
private fun Modifier.fadedIn(visible: Boolean): Modifier {
val chromeAlpha by animateFloatAsState(targetValue = if (visible) 1f else 0f, label = "HudChromeAlpha")
return graphicsLayer { alpha = chromeAlpha }
}
/** Degrees to rotate a HUD icon so it stays upright at the given committed [Surface] rotation. */
private fun uprightRotationDegrees(surfaceRotation: Int): Float = when (surfaceRotation) {
Surface.ROTATION_90 -> 90f
@@ -657,10 +653,8 @@ private fun BoxScope.CameraSwitchCorner(
stringResources: StringResources,
emitter: (StandardCameraHudEvents) -> Unit
) {
AnimatedVisibility(
Chrome.Control(
visible = !isRecording,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.CenterEnd)
) {
Box(modifier = Modifier.rotate(iconRotation)) {
@@ -695,14 +689,14 @@ private fun VerticalControlBar(
.weight(1f)
.padding(bottom = 40.dp)
) {
FlashAndCameraTogglePill(
flashMode = flashMode,
emitter = emitter,
stringResources = stringResources,
isRecording = captureButtonState.isRecording,
enabled = captureButtonState != CaptureButtonState.RECORDING_HELD,
modifier = Modifier.fadedIn(captureButtonState != CaptureButtonState.RECORDING_HELD)
)
// Neither the flash nor the camera switch has anything to offer a running recording, so the pill goes whole.
Chrome.Control(visible = !captureButtonState.isRecording) {
FlashAndCameraTogglePill(
flashMode = flashMode,
emitter = emitter,
stringResources = stringResources
)
}
}
captureSlot(captureButtonState)
@@ -723,8 +717,6 @@ private fun FlashAndCameraTogglePill(
flashMode: FlashMode,
stringResources: StringResources,
emitter: (StandardCameraHudEvents) -> Unit,
isRecording: Boolean,
enabled: Boolean = true,
modifier: Modifier = Modifier
) {
Column(
@@ -735,7 +727,6 @@ private fun FlashAndCameraTogglePill(
) {
IconButton(
onClick = { emitter(StandardCameraHudEvents.ToggleFlash) },
enabled = enabled,
modifier = Modifier.testTag(TestTags.CAMERA_HUD_FLASH_BUTTON)
) {
FlashToggleButtonIcon(
@@ -744,20 +735,15 @@ private fun FlashAndCameraTogglePill(
)
}
// The camera cannot be swapped out from under a running recording, so the pill gives the button up and closes
// around the flash for as long as one runs.
AnimatedVisibility(visible = !isRecording) {
IconButton(
onClick = { emitter(StandardCameraHudEvents.SwitchCamera) },
enabled = enabled,
modifier = Modifier.testTag(TestTags.CAMERA_HUD_SWITCH_BUTTON)
) {
Icon(
imageVector = SignalIcons.CameraSwitch.imageVector,
contentDescription = if (stringResources.switchCamera != 0) stringResource(stringResources.switchCamera) else null,
tint = colorResource(R.color.CameraHud_control_foreground)
)
}
IconButton(
onClick = { emitter(StandardCameraHudEvents.SwitchCamera) },
modifier = Modifier.testTag(TestTags.CAMERA_HUD_SWITCH_BUTTON)
) {
Icon(
imageVector = SignalIcons.CameraSwitch.imageVector,
contentDescription = if (stringResources.switchCamera != 0) stringResource(stringResources.switchCamera) else null,
tint = colorResource(R.color.CameraHud_control_foreground)
)
}
}
}
@@ -865,12 +851,10 @@ private fun FlashToggleButton(
flashMode: FlashMode,
onToggle: () -> Unit,
stringResources: StringResources,
enabled: Boolean = true,
modifier: Modifier = Modifier
) {
IconButton(
onClick = onToggle,
enabled = enabled,
modifier = modifier
.size(TOP_CONTROL_SIZE)
.background(colorResource(R.color.CameraHud_control_background), shape = CircleShape)
@@ -90,16 +90,29 @@ class StandardCameraHudTest {
setContent(state = heldRecording())
composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_CLOSE_BUTTON).assertIsNotEnabled()
composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_FLASH_BUTTON).assertIsNotEnabled()
}
/** A locked recording leaves the hand free, so nothing but the camera switch has to be taken away. */
/** A locked recording leaves the hand free, so nothing but the flash and the camera switch has to be taken away. */
@Test
fun `Given a recording that is locked, when displayed, then the chrome around it can still be used`() {
setContent(state = lockedRecording())
composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_CLOSE_BUTTON).assertIsEnabled()
composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_FLASH_BUTTON).assertIsEnabled()
}
/** The flash is for a photo the camera is not going to take, however the recording was started. */
@Test
fun `Given a recording, when displayed, then the flash is gone`() {
setContent(state = lockedRecording())
composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_FLASH_BUTTON).assertDoesNotExist()
}
@Test
fun `Given a recording that is being held, when displayed, then the flash is gone`() {
setContent(state = heldRecording())
composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_FLASH_BUTTON).assertDoesNotExist()
}
/** The camera cannot be swapped out from under a running recording, however that recording was started. */
@@ -259,7 +272,6 @@ class StandardCameraHudTest {
setContent(state = heldRecording())
composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_CLOSE_BUTTON).performClick()
composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_FLASH_BUTTON).performClick()
assertThat(events).isEmpty()
}
@@ -339,20 +351,22 @@ class StandardCameraHudTest {
assertThat(events).containsExactly(StandardCameraHudEvents.ToggleFlash, StandardCameraHudEvents.SwitchCamera)
}
/** Neither of the two has anything to offer a running recording, so the pill goes rather than empties. */
@Test
@Config(qualifiers = "w840dp-h1000dp")
fun `Given a window too large for the bottom bar, when a recording is held, then the pill cannot be used`() {
setContent(state = heldRecording())
composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_FLASH_BUTTON).assertIsNotEnabled()
}
/** The pill gives the switch up and closes around the flash, the same as the bottom bar drops it. */
@Test
@Config(qualifiers = "w840dp-h1000dp")
fun `Given a window too large for the bottom bar, when a recording runs, then the pill has no camera switch`() {
fun `Given a window too large for the bottom bar, when a recording runs, then the pill is gone`() {
setContent(state = lockedRecording())
composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_FLASH_BUTTON).assertDoesNotExist()
composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_SWITCH_BUTTON).assertDoesNotExist()
}
@Test
@Config(qualifiers = "w840dp-h1000dp")
fun `Given a window too large for the bottom bar, when a recording is held, then the pill is gone`() {
setContent(state = heldRecording())
composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_FLASH_BUTTON).assertDoesNotExist()
composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_SWITCH_BUTTON).assertDoesNotExist()
}
@@ -5,21 +5,14 @@
package org.signal.mediasend.screens.edit
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import org.signal.core.ui.compose.Chrome
import org.signal.mediasend.screens.MediaSendMetrics
/**
* A control on the edit screen, and the two ways it gets out of the user's way. Going not-[visible] gives up its layout
* space, letting the rest of the stack settle into it, while [faded] holds onto the space -- releasing it mid-gesture
* would move whatever the user is dragging out from under their finger.
*/
/** A [Chrome.Control] on the edit screen, carrying the transitions the rest of the screen animates with. */
@Composable
internal fun MediaEditControl(
faded: Boolean,
@@ -29,14 +22,12 @@ internal fun MediaEditControl(
exit: ExitTransition = MediaSendMetrics.ControlExitTransition,
content: @Composable () -> Unit
) {
val alpha by animateFloatAsState(targetValue = if (faded) 0f else 1f)
AnimatedVisibility(
Chrome.Control(
modifier = modifier,
visible = visible,
faded = faded,
enter = enter,
exit = exit,
modifier = modifier.alpha(alpha)
) {
content()
}
content = content
)
}