1 /*
<lambda>null2 * Copyright (C) 2023 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16 package com.google.jetpackcamera.feature.preview.ui
17
18 import android.content.res.Configuration
19 import android.os.Build
20 import android.util.Log
21 import android.widget.Toast
22 import androidx.camera.core.SurfaceRequest
23 import androidx.camera.viewfinder.surface.ImplementationMode
24 import androidx.compose.animation.core.EaseOutExpo
25 import androidx.compose.animation.core.LinearEasing
26 import androidx.compose.animation.core.animateFloatAsState
27 import androidx.compose.animation.core.tween
28 import androidx.compose.foundation.Canvas
29 import androidx.compose.foundation.background
30 import androidx.compose.foundation.border
31 import androidx.compose.foundation.clickable
32 import androidx.compose.foundation.gestures.detectTapGestures
33 import androidx.compose.foundation.gestures.rememberTransformableState
34 import androidx.compose.foundation.gestures.transformable
35 import androidx.compose.foundation.layout.Arrangement
36 import androidx.compose.foundation.layout.Box
37 import androidx.compose.foundation.layout.BoxWithConstraints
38 import androidx.compose.foundation.layout.Column
39 import androidx.compose.foundation.layout.Row
40 import androidx.compose.foundation.layout.aspectRatio
41 import androidx.compose.foundation.layout.fillMaxHeight
42 import androidx.compose.foundation.layout.fillMaxSize
43 import androidx.compose.foundation.layout.height
44 import androidx.compose.foundation.layout.padding
45 import androidx.compose.foundation.layout.size
46 import androidx.compose.foundation.layout.width
47 import androidx.compose.foundation.shape.CircleShape
48 import androidx.compose.foundation.shape.RoundedCornerShape
49 import androidx.compose.material.icons.Icons
50 import androidx.compose.material.icons.filled.CameraAlt
51 import androidx.compose.material.icons.filled.FlipCameraAndroid
52 import androidx.compose.material.icons.filled.Mic
53 import androidx.compose.material.icons.filled.MicOff
54 import androidx.compose.material.icons.filled.Nightlight
55 import androidx.compose.material.icons.filled.Settings
56 import androidx.compose.material.icons.filled.VideoStable
57 import androidx.compose.material.icons.filled.Videocam
58 import androidx.compose.material.icons.outlined.CameraAlt
59 import androidx.compose.material.icons.outlined.Nightlight
60 import androidx.compose.material.icons.outlined.Videocam
61 import androidx.compose.material3.Icon
62 import androidx.compose.material3.IconButton
63 import androidx.compose.material3.LocalContentColor
64 import androidx.compose.material3.MaterialTheme
65 import androidx.compose.material3.SnackbarHostState
66 import androidx.compose.material3.SnackbarResult
67 import androidx.compose.material3.SuggestionChip
68 import androidx.compose.material3.Surface
69 import androidx.compose.material3.Text
70 import androidx.compose.runtime.Composable
71 import androidx.compose.runtime.LaunchedEffect
72 import androidx.compose.runtime.getValue
73 import androidx.compose.runtime.mutableStateOf
74 import androidx.compose.runtime.remember
75 import androidx.compose.runtime.rememberCoroutineScope
76 import androidx.compose.runtime.rememberUpdatedState
77 import androidx.compose.runtime.setValue
78 import androidx.compose.ui.Alignment
79 import androidx.compose.ui.Modifier
80 import androidx.compose.ui.draw.alpha
81 import androidx.compose.ui.draw.clip
82 import androidx.compose.ui.graphics.Color
83 import androidx.compose.ui.graphics.painter.Painter
84 import androidx.compose.ui.graphics.vector.rememberVectorPainter
85 import androidx.compose.ui.input.pointer.pointerInput
86 import androidx.compose.ui.layout.layout
87 import androidx.compose.ui.platform.LocalContext
88 import androidx.compose.ui.platform.testTag
89 import androidx.compose.ui.res.stringResource
90 import androidx.compose.ui.tooling.preview.Preview
91 import androidx.compose.ui.unit.Dp
92 import androidx.compose.ui.unit.dp
93 import com.google.jetpackcamera.feature.preview.PreviewUiState
94 import com.google.jetpackcamera.feature.preview.R
95 import com.google.jetpackcamera.feature.preview.VideoRecordingState
96 import com.google.jetpackcamera.feature.preview.ui.theme.PreviewPreviewTheme
97 import com.google.jetpackcamera.settings.model.AspectRatio
98 import com.google.jetpackcamera.settings.model.LowLightBoost
99 import com.google.jetpackcamera.settings.model.Stabilization
100 import kotlinx.coroutines.delay
101 import kotlinx.coroutines.launch
102
103 private const val TAG = "PreviewScreen"
104 private const val BLINK_TIME = 100L
105
106 @Composable
107 fun AmplitudeVisualizer(
108 modifier: Modifier = Modifier,
109 size: Int = 100,
110 audioAmplitude: Double,
111 onToggleMute: () -> Unit
112 ) {
113 // Tweak the multiplier to amplitude to adjust the visualizer sensitivity
114 val animatedScaling by animateFloatAsState(
115 targetValue = EaseOutExpo.transform(1 + (1.75f * audioAmplitude.toFloat())),
116 label = "AudioAnimation"
117 )
118 Box(modifier = modifier.clickable { onToggleMute() }) {
119 // animated circle
120 Canvas(
121 modifier = Modifier
122 .align(Alignment.Center),
123 onDraw = {
124 drawCircle(
125 // tweak the multiplier to size to adjust the maximum size of the visualizer
126 radius = (size * animatedScaling).coerceIn(size.toFloat(), size * 1.65f),
127 alpha = .5f,
128 color = Color.White
129 )
130 }
131 )
132
133 // static circle
134 Canvas(
135 modifier = Modifier
136 .align(Alignment.Center),
137 onDraw = {
138 drawCircle(
139 radius = (size.toFloat()),
140 color = Color.White
141 )
142 }
143 )
144
145 Icon(
146 modifier = Modifier
147 .align(Alignment.Center)
148 .size((0.5 * size).dp)
149 .apply {
150 if (audioAmplitude != 0.0) {
151 testTag(AMPLITUDE_HOT_TAG)
152 } else {
153 testTag(AMPLITUDE_NONE_TAG)
154 }
155 },
156 tint = Color.Black,
157 imageVector = if (audioAmplitude != 0.0) {
158 Icons.Filled.Mic
159 } else {
160 Icons.Filled.MicOff
161 },
162 contentDescription = stringResource(id = R.string.audio_visualizer_icon)
163 )
164 }
165 }
166
167 /**
168 * An invisible box that will display a [Toast] with specifications set by a [ToastMessage].
169 *
170 * @param toastMessage the specifications for the [Toast].
171 * @param onToastShown called once the Toast has been displayed.
172 *
173 */
174 @Composable
TestableToastnull175 fun TestableToast(
176 toastMessage: ToastMessage,
177 onToastShown: () -> Unit,
178 modifier: Modifier = Modifier
179 ) {
180 Box(
181 // box seems to need to have some size to be detected by UiAutomator
182 modifier = modifier
183 .size(20.dp)
184 .testTag(toastMessage.testTag)
185 ) {
186 val context = LocalContext.current
187 LaunchedEffect(toastMessage) {
188 if (toastMessage.shouldShowToast) {
189 Toast.makeText(
190 context,
191 context.getText(toastMessage.stringResource),
192 toastMessage.toastLength
193 ).show()
194 }
195
196 onToastShown()
197 }
198 Log.d(
199 TAG,
200 "Toast Displayed with message: ${stringResource(id = toastMessage.stringResource)}"
201 )
202 }
203 }
204
205 @Composable
TestableSnackbarnull206 fun TestableSnackbar(
207 modifier: Modifier = Modifier,
208 snackbarToShow: SnackbarData,
209 snackbarHostState: SnackbarHostState,
210 onSnackbarResult: (String) -> Unit
211 ) {
212 Box(
213 // box seems to need to have some size to be detected by UiAutomator
214 modifier = modifier
215 .size(20.dp)
216 .testTag(snackbarToShow.testTag)
217 ) {
218 val context = LocalContext.current
219 LaunchedEffect(snackbarToShow) {
220 val message = context.getString(snackbarToShow.stringResource)
221 Log.d(TAG, "Snackbar Displayed with message: $message")
222 try {
223 val result =
224 snackbarHostState.showSnackbar(
225 message = message,
226 duration = snackbarToShow.duration,
227 withDismissAction = snackbarToShow.withDismissAction,
228 actionLabel = if (snackbarToShow.actionLabelRes == null) {
229 null
230 } else {
231 context.getString(snackbarToShow.actionLabelRes)
232 }
233 )
234 when (result) {
235 SnackbarResult.ActionPerformed,
236 SnackbarResult.Dismissed -> onSnackbarResult(snackbarToShow.cookie)
237 }
238 } catch (e: Exception) {
239 // This is equivalent to dismissing the snackbar
240 onSnackbarResult(snackbarToShow.cookie)
241 }
242 }
243 }
244 }
245
246 /**
247 * this is the preview surface display. This view implements gestures tap to focus, pinch to zoom,
248 * and double-tap to flip camera
249 */
250 @Composable
PreviewDisplaynull251 fun PreviewDisplay(
252 previewUiState: PreviewUiState.Ready,
253 onTapToFocus: (x: Float, y: Float) -> Unit,
254 onFlipCamera: () -> Unit,
255 onZoomChange: (Float) -> Unit,
256 onRequestWindowColorMode: (Int) -> Unit,
257 aspectRatio: AspectRatio,
258 surfaceRequest: SurfaceRequest?,
259 modifier: Modifier = Modifier
260 ) {
261 val transformableState = rememberTransformableState(
262 onTransformation = { zoomChange, _, _ ->
263 onZoomChange(zoomChange)
264 }
265 )
266
267 val currentOnFlipCamera by rememberUpdatedState(onFlipCamera)
268
269 surfaceRequest?.let {
270 BoxWithConstraints(
271 Modifier
272 .testTag(PREVIEW_DISPLAY)
273 .fillMaxSize()
274 .background(Color.Black)
275 .pointerInput(Unit) {
276 detectTapGestures(
277 onDoubleTap = { offset ->
278 // double tap to flip camera
279 Log.d(TAG, "onDoubleTap $offset")
280 currentOnFlipCamera()
281 }
282 )
283 },
284
285 contentAlignment = Alignment.Center
286 ) {
287 val maxAspectRatio: Float = maxWidth / maxHeight
288 val aspectRatioFloat: Float = aspectRatio.ratio.toFloat()
289 val shouldUseMaxWidth = maxAspectRatio <= aspectRatioFloat
290 val width = if (shouldUseMaxWidth) maxWidth else maxHeight * aspectRatioFloat
291 val height = if (!shouldUseMaxWidth) maxHeight else maxWidth / aspectRatioFloat
292 var imageVisible by remember { mutableStateOf(true) }
293
294 val imageAlpha: Float by animateFloatAsState(
295 targetValue = if (imageVisible) 1f else 0f,
296 animationSpec = tween(
297 durationMillis = (BLINK_TIME / 2).toInt(),
298 easing = LinearEasing
299 ),
300 label = ""
301 )
302
303 LaunchedEffect(previewUiState.lastBlinkTimeStamp) {
304 if (previewUiState.lastBlinkTimeStamp != 0L) {
305 imageVisible = false
306 delay(BLINK_TIME)
307 imageVisible = true
308 }
309 }
310
311 Box(
312 modifier = Modifier
313 .width(width)
314 .height(height)
315 .transformable(state = transformableState)
316 .alpha(imageAlpha)
317 .clip(RoundedCornerShape(16.dp))
318 ) {
319 CameraXViewfinder(
320 modifier = Modifier.fillMaxSize(),
321 surfaceRequest = it,
322 implementationMode = when {
323 Build.VERSION.SDK_INT > 24 -> ImplementationMode.EXTERNAL
324 else -> ImplementationMode.EMBEDDED
325 },
326 onRequestWindowColorMode = onRequestWindowColorMode,
327 onTap = { x, y -> onTapToFocus(x, y) }
328 )
329 }
330 }
331 }
332 }
333
334 @Composable
StabilizationIconnull335 fun StabilizationIcon(
336 videoStabilization: Stabilization,
337 previewStabilization: Stabilization,
338 modifier: Modifier = Modifier
339 ) {
340 if (videoStabilization == Stabilization.ON || previewStabilization == Stabilization.ON) {
341 val descriptionText = if (videoStabilization == Stabilization.ON) {
342 stringResource(id = R.string.stabilization_icon_description_preview_and_video)
343 } else {
344 // previewStabilization will not be on for high quality
345 stringResource(id = R.string.stabilization_icon_description_video_only)
346 }
347 Icon(
348 imageVector = Icons.Filled.VideoStable,
349 contentDescription = descriptionText,
350 modifier = modifier
351 )
352 }
353 }
354
355 /**
356 * LowLightBoostIcon has 3 states
357 * - disabled: hidden
358 * - enabled and inactive: outline
359 * - enabled and active: filled
360 */
361 @Composable
LowLightBoostIconnull362 fun LowLightBoostIcon(lowLightBoost: LowLightBoost, modifier: Modifier = Modifier) {
363 when (lowLightBoost) {
364 LowLightBoost.ENABLED -> {
365 Icon(
366 imageVector = Icons.Outlined.Nightlight,
367 contentDescription =
368 stringResource(id = R.string.quick_settings_lowlightboost_enabled),
369 modifier = modifier.alpha(0.5f)
370 )
371 }
372 LowLightBoost.DISABLED -> {
373 }
374 }
375 }
376
377 /**
378 * A temporary button that can be added to preview for quick testing purposes
379 */
380 @Composable
TestingButtonnull381 fun TestingButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
382 SuggestionChip(
383 onClick = { onClick() },
384 modifier = modifier,
385 label = {
386 Text(text = text)
387 }
388 )
389 }
390
391 @Composable
FlipCameraButtonnull392 fun FlipCameraButton(
393 enabledCondition: Boolean,
394 onClick: () -> Unit,
395 modifier: Modifier = Modifier
396 ) {
397 IconButton(
398 modifier = modifier.size(40.dp),
399 onClick = onClick,
400 enabled = enabledCondition
401 ) {
402 Icon(
403 imageVector = Icons.Filled.FlipCameraAndroid,
404 contentDescription = stringResource(id = R.string.flip_camera_content_description),
405 modifier = Modifier.size(72.dp)
406 )
407 }
408 }
409
410 @Composable
SettingsNavButtonnull411 fun SettingsNavButton(onNavigateToSettings: () -> Unit, modifier: Modifier = Modifier) {
412 IconButton(
413 modifier = modifier,
414 onClick = onNavigateToSettings
415 ) {
416 Icon(
417 imageVector = Icons.Filled.Settings,
418 contentDescription = stringResource(R.string.settings_content_description),
419 modifier = Modifier.size(72.dp)
420 )
421 }
422 }
423
424 @Composable
ZoomScaleTextnull425 fun ZoomScaleText(zoomScale: Float) {
426 val contentAlpha = animateFloatAsState(
427 targetValue = 10f,
428 label = "zoomScaleAlphaAnimation",
429 animationSpec = tween()
430 )
431 Text(
432 modifier = Modifier
433 .alpha(contentAlpha.value)
434 .testTag(ZOOM_RATIO_TAG),
435 text = stringResource(id = R.string.zoom_scale_text, zoomScale)
436 )
437 }
438
439 @Composable
CurrentCameraIdTextnull440 fun CurrentCameraIdText(physicalCameraId: String?, logicalCameraId: String?) {
441 Column(horizontalAlignment = Alignment.CenterHorizontally) {
442 Row {
443 Text(text = stringResource(R.string.debug_text_logical_camera_id_prefix))
444 Text(
445 modifier = Modifier.testTag(LOGICAL_CAMERA_ID_TAG),
446 text = logicalCameraId ?: "---"
447 )
448 }
449 Row {
450 Text(text = stringResource(R.string.debug_text_physical_camera_id_prefix))
451 Text(
452 modifier = Modifier.testTag(PHYSICAL_CAMERA_ID_TAG),
453 text = physicalCameraId ?: "---"
454 )
455 }
456 }
457 }
458
459 @Composable
CaptureButtonnull460 fun CaptureButton(
461 onClick: () -> Unit,
462 onLongPress: () -> Unit,
463 onRelease: () -> Unit,
464 videoRecordingState: VideoRecordingState,
465 modifier: Modifier = Modifier
466 ) {
467 var isPressedDown by remember {
468 mutableStateOf(false)
469 }
470 val currentColor = LocalContentColor.current
471 Box(
472 modifier = modifier
473 .pointerInput(Unit) {
474 detectTapGestures(
475 onLongPress = {
476 onLongPress()
477 },
478 // TODO: @kimblebee - stopVideoRecording is being called every time the capture
479 // button is pressed -- regardless of tap or long press
480 onPress = {
481 isPressedDown = true
482 awaitRelease()
483 isPressedDown = false
484 onRelease()
485 },
486 onTap = { onClick() }
487 )
488 }
489 .size(120.dp)
490 .padding(18.dp)
491 .border(4.dp, currentColor, CircleShape)
492 ) {
493 Canvas(modifier = Modifier.size(110.dp), onDraw = {
494 drawCircle(
495 color =
496 when (videoRecordingState) {
497 VideoRecordingState.INACTIVE -> {
498 if (isPressedDown) currentColor else Color.Transparent
499 }
500
501 VideoRecordingState.ACTIVE -> Color.Red
502 }
503 )
504 })
505 }
506 }
507
508 enum class ToggleState {
509 Left,
510 Right
511 }
512
513 @Composable
ToggleButtonnull514 fun ToggleButton(
515 leftIcon: Painter,
516 rightIcon: Painter,
517 modifier: Modifier = Modifier
518 .width(64.dp)
519 .height(32.dp),
520 initialState: ToggleState = ToggleState.Left,
521 onToggleStateChanged: (newState: ToggleState) -> Unit = {},
<lambda>null522 onToggleWhenDisabled: () -> Unit = {},
523 enabled: Boolean = true,
524 leftIconDescription: String = "leftIcon",
525 rightIconDescription: String = "rightIcon",
526 iconPadding: Dp = 8.dp
527 ) {
528 val backgroundColor = MaterialTheme.colorScheme.surfaceContainerHighest
529 val disableColor = MaterialTheme.colorScheme.onSurface
530 val iconSelectionColor = MaterialTheme.colorScheme.onPrimary
531 val iconUnSelectionColor = MaterialTheme.colorScheme.primary
532 val circleSelectionColor = MaterialTheme.colorScheme.primary
533 val circleColor = if (enabled) circleSelectionColor else disableColor.copy(alpha = 0.12f)
<lambda>null534 var toggleState by remember { mutableStateOf(initialState) }
535 val animatedTogglePosition by animateFloatAsState(
536 when (toggleState) {
537 ToggleState.Left -> 0f
538 ToggleState.Right -> 1f
539 },
540 label = "togglePosition"
541 )
542 val scope = rememberCoroutineScope()
543
544 Surface(
545 modifier = modifier
546 .clip(shape = RoundedCornerShape(50))
547 .then(
<lambda>null548 Modifier.clickable {
549 scope.launch {
550 if (enabled) {
551 toggleState = when (toggleState) {
552 ToggleState.Left -> ToggleState.Right
553 ToggleState.Right -> ToggleState.Left
554 }
555 onToggleStateChanged(toggleState)
556 } else {
557 onToggleWhenDisabled()
558 }
559 }
560 }
561 ),
562 color = backgroundColor
<lambda>null563 ) {
564 Box {
565 Row(
566 modifier = Modifier.matchParentSize(),
567 verticalAlignment = Alignment.CenterVertically
568 ) {
569 Box(
570 Modifier
571 .layout { measurable, constraints ->
572 val placeable = measurable.measure(constraints)
573 layout(placeable.width, placeable.height) {
574 val xPos = animatedTogglePosition *
575 (constraints.maxWidth - placeable.width)
576 placeable.placeRelative(xPos.toInt(), 0)
577 }
578 }
579 .fillMaxHeight()
580 .aspectRatio(1f)
581 .clip(RoundedCornerShape(50))
582 .background(circleColor)
583 )
584 }
585 Row(
586 modifier = Modifier
587 .matchParentSize()
588 .then(
589 if (enabled) Modifier else Modifier.alpha(0.38f)
590 ),
591 verticalAlignment = Alignment.CenterVertically,
592 horizontalArrangement = Arrangement.SpaceBetween
593 ) {
594 Icon(
595 painter = leftIcon,
596 contentDescription = leftIconDescription,
597 modifier = Modifier.padding(iconPadding),
598 tint = if (!enabled) {
599 disableColor
600 } else if (toggleState == ToggleState.Left) {
601 iconSelectionColor
602 } else {
603 iconUnSelectionColor
604 }
605 )
606 Icon(
607 painter = rightIcon,
608 contentDescription = rightIconDescription,
609 modifier = Modifier.padding(iconPadding),
610 tint = if (!enabled) {
611 disableColor
612 } else if (toggleState == ToggleState.Right) {
613 iconSelectionColor
614 } else {
615 iconUnSelectionColor
616 }
617 )
618 }
619 }
620 }
621 }
622
623 @Preview(name = "Light Mode")
624 @Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
625 @Composable
Preview_ToggleButton_Selecting_Leftnull626 private fun Preview_ToggleButton_Selecting_Left() {
627 val initialState = ToggleState.Left
628 var toggleState by remember {
629 mutableStateOf(initialState)
630 }
631 PreviewPreviewTheme(dynamicColor = false) {
632 ToggleButton(
633 leftIcon = if (toggleState == ToggleState.Left) {
634 rememberVectorPainter(image = Icons.Filled.CameraAlt)
635 } else {
636 rememberVectorPainter(image = Icons.Outlined.CameraAlt)
637 },
638 rightIcon = if (toggleState == ToggleState.Right) {
639 rememberVectorPainter(image = Icons.Filled.Videocam)
640 } else {
641 rememberVectorPainter(image = Icons.Outlined.Videocam)
642 },
643 initialState = ToggleState.Left,
644 onToggleStateChanged = {
645 toggleState = it
646 }
647 )
648 }
649 }
650
651 @Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
652 @Composable
Preview_ToggleButton_Selecting_Rightnull653 private fun Preview_ToggleButton_Selecting_Right() {
654 PreviewPreviewTheme(dynamicColor = false) {
655 ToggleButton(
656 leftIcon = rememberVectorPainter(image = Icons.Outlined.CameraAlt),
657 rightIcon = rememberVectorPainter(image = Icons.Filled.Videocam),
658 initialState = ToggleState.Right
659 )
660 }
661 }
662
663 @Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
664 @Composable
Preview_ToggleButton_Disablednull665 private fun Preview_ToggleButton_Disabled() {
666 PreviewPreviewTheme(dynamicColor = false) {
667 ToggleButton(
668 leftIcon = rememberVectorPainter(image = Icons.Outlined.CameraAlt),
669 rightIcon = rememberVectorPainter(image = Icons.Filled.Videocam),
670 initialState = ToggleState.Right,
671 enabled = false
672 )
673 }
674 }
675