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.view.Display
22 import android.widget.Toast
23 import androidx.camera.core.SurfaceRequest
24 import androidx.camera.viewfinder.surface.ImplementationMode
25 import androidx.compose.animation.core.EaseOutExpo
26 import androidx.compose.animation.core.LinearEasing
27 import androidx.compose.animation.core.animateFloatAsState
28 import androidx.compose.animation.core.tween
29 import androidx.compose.foundation.Canvas
30 import androidx.compose.foundation.background
31 import androidx.compose.foundation.border
32 import androidx.compose.foundation.clickable
33 import androidx.compose.foundation.gestures.detectTapGestures
34 import androidx.compose.foundation.gestures.rememberTransformableState
35 import androidx.compose.foundation.gestures.transformable
36 import androidx.compose.foundation.layout.Arrangement
37 import androidx.compose.foundation.layout.Box
38 import androidx.compose.foundation.layout.BoxWithConstraints
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.Settings
55 import androidx.compose.material.icons.filled.VideoStable
56 import androidx.compose.material.icons.filled.Videocam
57 import androidx.compose.material.icons.outlined.CameraAlt
58 import androidx.compose.material.icons.outlined.Videocam
59 import androidx.compose.material3.Icon
60 import androidx.compose.material3.IconButton
61 import androidx.compose.material3.LocalContentColor
62 import androidx.compose.material3.MaterialTheme
63 import androidx.compose.material3.SnackbarHostState
64 import androidx.compose.material3.SnackbarResult
65 import androidx.compose.material3.SuggestionChip
66 import androidx.compose.material3.Surface
67 import androidx.compose.material3.Text
68 import androidx.compose.runtime.Composable
69 import androidx.compose.runtime.LaunchedEffect
70 import androidx.compose.runtime.getValue
71 import androidx.compose.runtime.mutableStateOf
72 import androidx.compose.runtime.remember
73 import androidx.compose.runtime.rememberCoroutineScope
74 import androidx.compose.runtime.rememberUpdatedState
75 import androidx.compose.runtime.setValue
76 import androidx.compose.ui.Alignment
77 import androidx.compose.ui.Modifier
78 import androidx.compose.ui.draw.alpha
79 import androidx.compose.ui.draw.clip
80 import androidx.compose.ui.graphics.Color
81 import androidx.compose.ui.graphics.painter.Painter
82 import androidx.compose.ui.graphics.vector.rememberVectorPainter
83 import androidx.compose.ui.input.pointer.pointerInput
84 import androidx.compose.ui.layout.layout
85 import androidx.compose.ui.platform.LocalContext
86 import androidx.compose.ui.platform.testTag
87 import androidx.compose.ui.res.stringResource
88 import androidx.compose.ui.tooling.preview.Preview
89 import androidx.compose.ui.unit.Dp
90 import androidx.compose.ui.unit.dp
91 import androidx.compose.ui.unit.sp
92 import com.google.jetpackcamera.feature.preview.PreviewUiState
93 import com.google.jetpackcamera.feature.preview.R
94 import com.google.jetpackcamera.feature.preview.VideoRecordingState
95 import com.google.jetpackcamera.feature.preview.ui.theme.PreviewPreviewTheme
96 import com.google.jetpackcamera.settings.model.AspectRatio
97 import com.google.jetpackcamera.settings.model.Stabilization
98 import kotlinx.coroutines.delay
99 import kotlinx.coroutines.launch
100
101 private const val TAG = "PreviewScreen"
102 private const val BLINK_TIME = 100L
103
104 @Composable
105 fun AmplitudeVisualizer(modifier: Modifier = Modifier, size: Int = 100, audioAmplitude: Double) {
106 // Tweak the multiplier to amplitude to adjust the visualizer sensitivity
107 val animatedScaling by animateFloatAsState(
108 targetValue = EaseOutExpo.transform(1 + (1.75f * audioAmplitude.toFloat())),
109 label = "AudioAnimation"
110 )
111 Box(modifier = modifier) {
112 // animated circle
113 Canvas(
114 modifier = Modifier
115 .align(Alignment.Center),
116 onDraw = {
117 drawCircle(
118 // tweak the multiplier to size to adjust the maximum size of the visualizer
119 radius = (size * animatedScaling).coerceIn(size.toFloat(), size * 1.65f),
120 alpha = .5f,
121 color = Color.White
122 )
123 }
124 )
125
126 // static circle
127 Canvas(
128 modifier = Modifier
129 .align(Alignment.Center),
130 onDraw = {
131 drawCircle(
132 radius = (size.toFloat()),
133 color = Color.White
134 )
135 }
136 )
137
138 Icon(
139 modifier = Modifier
140 .align(Alignment.Center)
141 .size((0.5 * size).dp),
142 tint = Color.Black,
143 imageVector = if (audioAmplitude != 0.0) {
144 Icons.Filled.Mic
145 } else {
146 Icons.Filled.MicOff
147 },
148 contentDescription = stringResource(id = R.string.audio_visualizer_icon)
149 )
150 }
151 }
152
153 /**
154 * An invisible box that will display a [Toast] with specifications set by a [ToastMessage].
155 *
156 * @param toastMessage the specifications for the [Toast].
157 * @param onToastShown called once the Toast has been displayed.
158 *
159 */
160 @Composable
TestableToastnull161 fun TestableToast(
162 toastMessage: ToastMessage,
163 onToastShown: () -> Unit,
164 modifier: Modifier = Modifier
165 ) {
166 Box(
167 // box seems to need to have some size to be detected by UiAutomator
168 modifier = modifier
169 .size(20.dp)
170 .testTag(toastMessage.testTag)
171 ) {
172 val context = LocalContext.current
173 LaunchedEffect(toastMessage) {
174 if (toastMessage.shouldShowToast) {
175 Toast.makeText(
176 context,
177 context.getText(toastMessage.stringResource),
178 toastMessage.toastLength
179 ).show()
180 }
181
182 onToastShown()
183 }
184 Log.d(
185 TAG,
186 "Toast Displayed with message: ${stringResource(id = toastMessage.stringResource)}"
187 )
188 }
189 }
190
191 @Composable
TestableSnackbarnull192 fun TestableSnackbar(
193 modifier: Modifier = Modifier,
194 snackbarToShow: SnackbarData,
195 snackbarHostState: SnackbarHostState,
196 onSnackbarResult: (String) -> Unit
197 ) {
198 Box(
199 // box seems to need to have some size to be detected by UiAutomator
200 modifier = modifier
201 .size(20.dp)
202 .testTag(snackbarToShow.testTag)
203 ) {
204 val context = LocalContext.current
205 LaunchedEffect(snackbarToShow) {
206 val message = context.getString(snackbarToShow.stringResource)
207 Log.d(TAG, "Snackbar Displayed with message: $message")
208 try {
209 val result =
210 snackbarHostState.showSnackbar(
211 message = message,
212 duration = snackbarToShow.duration,
213 withDismissAction = snackbarToShow.withDismissAction,
214 actionLabel = if (snackbarToShow.actionLabelRes == null) {
215 null
216 } else {
217 context.getString(snackbarToShow.actionLabelRes)
218 }
219 )
220 when (result) {
221 SnackbarResult.ActionPerformed,
222 SnackbarResult.Dismissed -> onSnackbarResult(snackbarToShow.cookie)
223 }
224 } catch (e: Exception) {
225 // This is equivalent to dismissing the snackbar
226 onSnackbarResult(snackbarToShow.cookie)
227 }
228 }
229 }
230 }
231
232 /**
233 * this is the preview surface display. This view implements gestures tap to focus, pinch to zoom,
234 * and double-tap to flip camera
235 */
236 @Composable
PreviewDisplaynull237 fun PreviewDisplay(
238 previewUiState: PreviewUiState.Ready,
239 onTapToFocus: (Display, Int, Int, Float, Float) -> Unit,
240 onFlipCamera: () -> Unit,
241 onZoomChange: (Float) -> Unit,
242 onRequestWindowColorMode: (Int) -> Unit,
243 aspectRatio: AspectRatio,
244 surfaceRequest: SurfaceRequest?,
245 modifier: Modifier = Modifier
246 ) {
247 val transformableState = rememberTransformableState(
248 onTransformation = { zoomChange, _, _ ->
249 onZoomChange(zoomChange)
250 }
251 )
252
253 val currentOnFlipCamera by rememberUpdatedState(onFlipCamera)
254
255 surfaceRequest?.let {
256 BoxWithConstraints(
257 Modifier
258 .testTag(PREVIEW_DISPLAY)
259 .fillMaxSize()
260 .background(Color.Black)
261 .pointerInput(Unit) {
262 detectTapGestures(
263 onDoubleTap = { offset ->
264 // double tap to flip camera
265 Log.d(TAG, "onDoubleTap $offset")
266 currentOnFlipCamera()
267 }
268 )
269 },
270
271 contentAlignment = Alignment.Center
272 ) {
273 val maxAspectRatio: Float = maxWidth / maxHeight
274 val aspectRatioFloat: Float = aspectRatio.ratio.toFloat()
275 val shouldUseMaxWidth = maxAspectRatio <= aspectRatioFloat
276 val width = if (shouldUseMaxWidth) maxWidth else maxHeight * aspectRatioFloat
277 val height = if (!shouldUseMaxWidth) maxHeight else maxWidth / aspectRatioFloat
278 var imageVisible by remember { mutableStateOf(true) }
279
280 val imageAlpha: Float by animateFloatAsState(
281 targetValue = if (imageVisible) 1f else 0f,
282 animationSpec = tween(
283 durationMillis = (BLINK_TIME / 2).toInt(),
284 easing = LinearEasing
285 ),
286 label = ""
287 )
288
289 LaunchedEffect(previewUiState.lastBlinkTimeStamp) {
290 if (previewUiState.lastBlinkTimeStamp != 0L) {
291 imageVisible = false
292 delay(BLINK_TIME)
293 imageVisible = true
294 }
295 }
296
297 Box(
298 modifier = Modifier
299 .width(width)
300 .height(height)
301 .transformable(state = transformableState)
302 .alpha(imageAlpha)
303 ) {
304 CameraXViewfinder(
305 modifier = Modifier.fillMaxSize(),
306 surfaceRequest = it,
307 implementationMode = when {
308 Build.VERSION.SDK_INT > 24 -> ImplementationMode.EXTERNAL
309 else -> ImplementationMode.EMBEDDED
310 },
311 onRequestWindowColorMode = onRequestWindowColorMode
312 )
313 }
314 }
315 }
316 }
317
318 @Composable
StabilizationIconnull319 fun StabilizationIcon(
320 videoStabilization: Stabilization,
321 previewStabilization: Stabilization,
322 modifier: Modifier = Modifier
323 ) {
324 if (videoStabilization == Stabilization.ON || previewStabilization == Stabilization.ON) {
325 val descriptionText = if (videoStabilization == Stabilization.ON) {
326 stringResource(id = R.string.stabilization_icon_description_preview_and_video)
327 } else {
328 // previewStabilization will not be on for high quality
329 stringResource(id = R.string.stabilization_icon_description_video_only)
330 }
331 Icon(
332 imageVector = Icons.Filled.VideoStable,
333 contentDescription = descriptionText,
334 modifier = modifier
335 )
336 }
337 }
338
339 /**
340 * A temporary button that can be added to preview for quick testing purposes
341 */
342 @Composable
TestingButtonnull343 fun TestingButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
344 SuggestionChip(
345 onClick = { onClick() },
346 modifier = modifier,
347 label = {
348 Text(text = text)
349 }
350 )
351 }
352
353 @Composable
FlipCameraButtonnull354 fun FlipCameraButton(
355 enabledCondition: Boolean,
356 onClick: () -> Unit,
357 modifier: Modifier = Modifier
358 ) {
359 IconButton(
360 modifier = modifier.size(40.dp),
361 onClick = onClick,
362 enabled = enabledCondition
363 ) {
364 Icon(
365 imageVector = Icons.Filled.FlipCameraAndroid,
366 contentDescription = stringResource(id = R.string.flip_camera_content_description),
367 modifier = Modifier.size(72.dp)
368 )
369 }
370 }
371
372 @Composable
SettingsNavButtonnull373 fun SettingsNavButton(onNavigateToSettings: () -> Unit, modifier: Modifier = Modifier) {
374 IconButton(
375 modifier = modifier,
376 onClick = onNavigateToSettings
377 ) {
378 Icon(
379 imageVector = Icons.Filled.Settings,
380 contentDescription = stringResource(R.string.settings_content_description),
381 modifier = Modifier.size(72.dp)
382 )
383 }
384 }
385
386 @Composable
ZoomScaleTextnull387 fun ZoomScaleText(zoomScale: Float, modifier: Modifier = Modifier) {
388 val contentAlpha = animateFloatAsState(
389 targetValue = 10f,
390 label = "zoomScaleAlphaAnimation",
391 animationSpec = tween()
392 )
393 Text(
394 modifier = Modifier.alpha(contentAlpha.value),
395 text = "%.1fx".format(zoomScale),
396 fontSize = 20.sp
397 )
398 }
399
400 @Composable
CaptureButtonnull401 fun CaptureButton(
402 onClick: () -> Unit,
403 onLongPress: () -> Unit,
404 onRelease: () -> Unit,
405 videoRecordingState: VideoRecordingState,
406 modifier: Modifier = Modifier
407 ) {
408 var isPressedDown by remember {
409 mutableStateOf(false)
410 }
411 val currentColor = LocalContentColor.current
412 Box(
413 modifier = modifier
414 .pointerInput(Unit) {
415 detectTapGestures(
416 onLongPress = {
417 onLongPress()
418 },
419 // TODO: @kimblebee - stopVideoRecording is being called every time the capture
420 // button is pressed -- regardless of tap or long press
421 onPress = {
422 isPressedDown = true
423 awaitRelease()
424 isPressedDown = false
425 onRelease()
426 },
427 onTap = { onClick() }
428 )
429 }
430 .size(120.dp)
431 .padding(18.dp)
432 .border(4.dp, currentColor, CircleShape)
433 ) {
434 Canvas(modifier = Modifier.size(110.dp), onDraw = {
435 drawCircle(
436 color =
437 when (videoRecordingState) {
438 VideoRecordingState.INACTIVE -> {
439 if (isPressedDown) currentColor else Color.Transparent
440 }
441
442 VideoRecordingState.ACTIVE -> Color.Red
443 }
444 )
445 })
446 }
447 }
448
449 enum class ToggleState {
450 Left,
451 Right
452 }
453
454 @Composable
ToggleButtonnull455 fun ToggleButton(
456 leftIcon: Painter,
457 rightIcon: Painter,
458 modifier: Modifier = Modifier.width(64.dp).height(32.dp),
459 initialState: ToggleState = ToggleState.Left,
460 onToggleStateChanged: (newState: ToggleState) -> Unit = {},
461 enabled: Boolean = true,
462 leftIconDescription: String = "leftIcon",
463 rightIconDescription: String = "rightIcon",
464 iconPadding: Dp = 8.dp
465 ) {
466 val backgroundColor = MaterialTheme.colorScheme.surfaceContainerHighest
467 val disableColor = MaterialTheme.colorScheme.onSurface
468 val iconSelectionColor = MaterialTheme.colorScheme.onPrimary
469 val iconUnSelectionColor = MaterialTheme.colorScheme.primary
470 val circleSelectionColor = MaterialTheme.colorScheme.primary
471 val circleColor = if (enabled) circleSelectionColor else disableColor.copy(alpha = 0.12f)
<lambda>null472 var toggleState by remember { mutableStateOf(initialState) }
473 val animatedTogglePosition by animateFloatAsState(
474 when (toggleState) {
475 ToggleState.Left -> 0f
476 ToggleState.Right -> 1f
477 },
478 label = "togglePosition"
479 )
480 val scope = rememberCoroutineScope()
481
482 Surface(
483 modifier = modifier
484 .clip(shape = RoundedCornerShape(50))
485 .then(
486 if (enabled) {
<lambda>null487 Modifier.clickable {
488 scope.launch {
489 toggleState = when (toggleState) {
490 ToggleState.Left -> ToggleState.Right
491 ToggleState.Right -> ToggleState.Left
492 }
493 onToggleStateChanged(toggleState)
494 }
495 }
496 } else {
497 Modifier
498 }
499 ),
500 color = backgroundColor
<lambda>null501 ) {
502 Box {
503 Row(
504 modifier = Modifier.matchParentSize(),
505 verticalAlignment = Alignment.CenterVertically
506 ) {
507 Box(
508 Modifier
509 .layout { measurable, constraints ->
510 val placeable = measurable.measure(constraints)
511 layout(placeable.width, placeable.height) {
512 val xPos = animatedTogglePosition *
513 (constraints.maxWidth - placeable.width)
514 placeable.placeRelative(xPos.toInt(), 0)
515 }
516 }
517 .fillMaxHeight()
518 .aspectRatio(1f)
519 .clip(RoundedCornerShape(50))
520 .background(circleColor)
521 )
522 }
523 Row(
524 modifier = Modifier.matchParentSize().then(
525 if (enabled) Modifier else Modifier.alpha(0.38f)
526 ),
527 verticalAlignment = Alignment.CenterVertically,
528 horizontalArrangement = Arrangement.SpaceBetween
529 ) {
530 Icon(
531 painter = leftIcon,
532 contentDescription = leftIconDescription,
533 modifier = Modifier.padding(iconPadding),
534 tint = if (!enabled) {
535 disableColor
536 } else if (toggleState == ToggleState.Left) {
537 iconSelectionColor
538 } else {
539 iconUnSelectionColor
540 }
541 )
542 Icon(
543 painter = rightIcon,
544 contentDescription = rightIconDescription,
545 modifier = Modifier.padding(iconPadding),
546 tint = if (!enabled) {
547 disableColor
548 } else if (toggleState == ToggleState.Right) {
549 iconSelectionColor
550 } else {
551 iconUnSelectionColor
552 }
553 )
554 }
555 }
556 }
557 }
558
559 @Preview(name = "Light Mode")
560 @Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
561 @Composable
Preview_ToggleButton_Selecting_Leftnull562 private fun Preview_ToggleButton_Selecting_Left() {
563 val initialState = ToggleState.Left
564 var toggleState by remember {
565 mutableStateOf(initialState)
566 }
567 PreviewPreviewTheme(dynamicColor = false) {
568 ToggleButton(
569 leftIcon = if (toggleState == ToggleState.Left) {
570 rememberVectorPainter(image = Icons.Filled.CameraAlt)
571 } else {
572 rememberVectorPainter(image = Icons.Outlined.CameraAlt)
573 },
574 rightIcon = if (toggleState == ToggleState.Right) {
575 rememberVectorPainter(image = Icons.Filled.Videocam)
576 } else {
577 rememberVectorPainter(image = Icons.Outlined.Videocam)
578 },
579 initialState = ToggleState.Left,
580 onToggleStateChanged = {
581 toggleState = it
582 }
583 )
584 }
585 }
586
587 @Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
588 @Composable
Preview_ToggleButton_Selecting_Rightnull589 private fun Preview_ToggleButton_Selecting_Right() {
590 PreviewPreviewTheme(dynamicColor = false) {
591 ToggleButton(
592 leftIcon = rememberVectorPainter(image = Icons.Outlined.CameraAlt),
593 rightIcon = rememberVectorPainter(image = Icons.Filled.Videocam),
594 initialState = ToggleState.Right
595 )
596 }
597 }
598
599 @Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
600 @Composable
Preview_ToggleButton_Disablednull601 private fun Preview_ToggleButton_Disabled() {
602 PreviewPreviewTheme(dynamicColor = false) {
603 ToggleButton(
604 leftIcon = rememberVectorPainter(image = Icons.Outlined.CameraAlt),
605 rightIcon = rememberVectorPainter(image = Icons.Filled.Videocam),
606 initialState = ToggleState.Right,
607 enabled = false
608 )
609 }
610 }
611