Implement 3_ANDROID_CAMERA: Advanced Camera features (Zoom controls, flash modes, front/back switch, and countdown timer)

This commit is contained in:
2026-07-05 12:48:21 +07:00
parent 70f8ef4da4
commit 9f9ec87d54
4 changed files with 437 additions and 5 deletions
+110
View File
@@ -0,0 +1,110 @@
# 📸 KẾ HOẠCH PHÁT TRIỂN: TÍNH NĂNG CAMERA NÂNG CAO (ANDROID)
Mục tiêu: Hoàn thiện bộ điều khiển Camera đầy đủ cho ứng dụng Photobooth bao gồm: Đổi camera, Hẹn giờ (Timer), Điều khiển Flash, và Chuyển đổi các ống kính Zoom định sẵn ($0.5x, 1x, 3x$).
---
## 🛠️ 1. Cấu Trúc File Cập Nhật Quy Trình UI (`activity_main.xml`)
Chúng ta cần bổ sung các nút bấm vào giao diện để người dùng tương tác. Cập nhật file `res/layout/activity_main.xml` với các ID trực quan.
```xml
<!-- Thêm các nút điều khiển vào phía trên kính ngắm (Top Bar) -->
<!-- 1. Nút Đổi Camera: id=@+id/btnSwitchCamera -->
<!-- 2. Nút Flash (On/Off/Auto): id=@+id/btnFlash -->
<!-- 3. Nút Hẹn giờ (Off/3s/5s/10s): id=@+id/btnTimer -->
<!-- Thêm thanh chọn Zoom vào ngay trên Control Panel -->
<!-- Gồm 3 nút tương ứng: 0.5x, 1x, 3x -->
<!-- id=@+id/btnZoom05, id=@+id/btnZoom10, id=@+id/btnZoom30 -->
```
---
## 💻 2. Kế Hoạch Hiện Thực Hóa Logic (Kotlin trong `MainActivity.kt`)
Dưới đây là kiến trúc mã nguồn và các biến trạng thái cần quản lý để thực hiện các tính năng.
### 🔹 2.1. Khai báo các biến trạng thái (State Management)
Đặt các biến này ở đầu class `MainActivity` để theo dõi cấu hình hiện tại:
```kotlin
private var lensFacing = CameraSelector.DEFAULT_FRONT_CAMERA // Mặc định camera trước
private var flashMode = ImageCapture.FLASH_MODE_OFF // Mặc định tắt flash
private var timerSeconds = 0 // Mặc định không hẹn giờ (0s)
private var cameraControl: CameraControl? = null // Dùng để điều khiển Zoom/Flash
private var cameraInfo: CameraInfo? = null // Dùng để đọc thông số phần cứng
```
### 🔹 2.2. Logic Tính Năng Chi Tiết & Giải Pháp Code
1. **Chuyển Đổi Camera (Trước ⇄ Sau):** Xử lý lật ống kính.
Khi bấm `btnSwitchCamera`, đổi giá trị `lensFacing` giữa `CameraSelector.DEFAULT_FRONT_CAMERA``DEFAULT_BACK_CAMERA`. Sau đó gọi lại hàm `startCamera()` để giải phóng session cũ và bind session mới.
2. **Điều Khiển Đèn Flash:** On / Off / Auto.
Khi khởi tạo `ImageCapture`, gán trực tiếp thuộc tính:
`imageCapture.flashMode = flashMode`.
Khi người dùng bấm nút Flash, thay đổi biến trạng thái và cập nhật icon tương ứng (⚡, ❌, Ⓐ).
3. **Cơ Chế Hẹn Giờ Chụp (Timer):** Đếm ngược trực quan.
Sử dụng `CountDownTimer` của Android. Khi bấm nút chụp:
Nếu `timerSeconds > 0`, hiển thị một `TextView` số lớn ở giữa màn hình, đếm ngược mỗi giây và phát âm thanh "Tích". Khi đếm về 0, kích hoạt hàm `takePicture()`.
4. **Hệ Thống Zoom Cố Định (0.5x, 1x, 3x):** Điều khiển Zoom kỹ thuật số/quang học.
Sau khi `bindToLifecycle`, lấy đối tượng `camera.cameraControl`.
Sử dụng lệnh: `cameraControl?.setZoomRatio(ratio)` khi người dùng bấm vào các nút zoom:
* **0.5x:** `setZoomRatio(0.5f)` *(Lưu ý: Thiết bị phải có camera góc siêu rộng phần cứng mới hoạt động thực tế, nếu không CameraX tự thu phóng số)*.
* **1x:** `setZoomRatio(1.0f)`
* **3x:** `setZoomRatio(3.0f)`
---
## 📝 3. Đoạn Code Mẫu Triển Khai Bộ Thu Phóng (Zoom) và Lật Camera
Bạn có thể dán đoạn logic này vào `MainActivity.kt` sau khi hoàn thiện phần giao diện:
```kotlin
// Hàm xử lý đổi Camera
private fun toggleCamera() {
lensFacing = if (lensFacing == CameraSelector.DEFAULT_FRONT_CAMERA) {
CameraSelector.DEFAULT_BACK_CAMERA
} else {
CameraSelector.DEFAULT_FRONT_CAMERA
}
startCamera() // Khởi động lại camera với ống kính mới
}
// Hàm xử lý Zoom mượt mà bằng CameraControl
private fun applyZoom(ratio: Float) {
cameraInfo?.zoomState?.value?.let { currentState ->
val maxZoom = currentState.maxZoomRatio
val minZoom = currentState.minZoomRatio
// Kiểm tra xem thiết bị có hỗ trợ mức zoom này không
if (ratio in minZoom..maxZoom) {
cameraControl?.setZoomRatio(ratio)
} else {
Toast.makeText(this, "Thiết bị không hỗ trợ mức zoom ${ratio}x", Toast.LENGTH_SHORT).show()
}
}
}
```
---
## 📅 4. Các Đầu Việc Cần Hoàn Thành (Checklist)
* [ ] Thiết kế các Icon cho nút bấm (Đổi camera, Flash, Hẹn giờ) và đưa vào thư mục `res/drawable`.
* [ ] Cập nhật giao diện `activity_main.xml` xếp chồng các nút điều khiển lên trên kính ngắm.
* [ ] Viết hàm `setupCameraControls()` để gán sự kiện `setOnClickListener` cho tất cả các nút bấm.
* [ ] Khởi tạo thêm `ImageCapture` bên cạnh `Preview` để sẵn sàng cho việc nhận lệnh chụp ảnh từ bộ hẹn giờ.
* [ ] Kiểm thử tính năng Zoom và lật Camera trên thiết bị thật (Khuyên dùng thiết bị thật vì Emulator không mô phỏng được camera góc rộng 0.5x hoặc tele 3x).
@@ -5,7 +5,10 @@ import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.CameraControl
import androidx.camera.core.CameraInfo
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import androidx.camera.core.Preview import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
@@ -18,12 +21,24 @@ class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding private lateinit var binding: ActivityMainBinding
private lateinit var cameraExecutor: ExecutorService private lateinit var cameraExecutor: ExecutorService
// Camera settings state
private var lensFacing = CameraSelector.DEFAULT_FRONT_CAMERA // Default front camera
private var flashMode = ImageCapture.FLASH_MODE_OFF // Default flash off
private var timerSeconds = 0 // Default no timer (0s)
private var cameraControl: CameraControl? = null // Controls zoom/flash
private var cameraInfo: CameraInfo? = null // Query capabilities
private var imageCapture: ImageCapture? = null // Capture usecase
private var isCountingDown = false // Countdown state flag
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater) binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
// Xin quyền Camera // Setup UI control listeners
setupCameraControls()
// Request camera permission
if (allPermissionsGranted()) { if (allPermissionsGranted()) {
startCamera() startCamera()
} else { } else {
@@ -33,33 +48,214 @@ class MainActivity : AppCompatActivity() {
cameraExecutor = Executors.newSingleThreadExecutor() cameraExecutor = Executors.newSingleThreadExecutor()
} }
private fun setupCameraControls() {
// Toggle Switch Camera Button
binding.btnSwitchCamera.setOnClickListener {
toggleCamera()
}
// Toggle Flash Button
binding.btnFlash.setOnClickListener {
toggleFlash()
}
// Toggle Timer Button
binding.btnTimer.setOnClickListener {
toggleTimer()
}
// Zoom 0.5x Button
binding.btnZoom05.setOnClickListener {
applyZoom(0.5f)
}
// Zoom 1x Button
binding.btnZoom10.setOnClickListener {
applyZoom(1.0f)
}
// Zoom 3x Button
binding.btnZoom30.setOnClickListener {
applyZoom(3.0f)
}
// Shutter Button
binding.btnCapture.setOnClickListener {
onCaptureClick()
}
}
private fun startCamera() { private fun startCamera() {
val cameraProviderFuture = ProcessCameraProvider.getInstance(this) val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
cameraProviderFuture.addListener({ cameraProviderFuture.addListener({
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
// Cấu hình Kính ngắm (Preview) // Preview configuration
val preview = Preview.Builder().build().also { val preview = Preview.Builder().build().also {
it.setSurfaceProvider(binding.viewFinder.surfaceProvider) it.setSurfaceProvider(binding.viewFinder.surfaceProvider)
} }
// Chọn Camera Trước (Mặc định cho Photobooth) // ImageCapture configuration
val cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA imageCapture = ImageCapture.Builder()
.setFlashMode(flashMode)
.build()
try { try {
cameraProvider.unbindAll() cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(this, cameraSelector, preview)
// Bind usecases to Lifecycle
val camera = cameraProvider.bindToLifecycle(
this, lensFacing, preview, imageCapture
)
cameraControl = camera.cameraControl
cameraInfo = camera.cameraInfo
// Reset zoom to 1.0x on start/switch
applyZoom(1.0f)
} catch (xc: Exception) { } catch (xc: Exception) {
Toast.makeText(this, "Không thể khởi động Camera", Toast.LENGTH_SHORT).show() Toast.makeText(this, "Không thể khởi động Camera", Toast.LENGTH_SHORT).show()
} }
}, ContextCompat.getMainExecutor(this)) }, ContextCompat.getMainExecutor(this))
} }
private fun toggleCamera() {
if (isCountingDown) return
lensFacing = if (lensFacing == CameraSelector.DEFAULT_FRONT_CAMERA) {
CameraSelector.DEFAULT_BACK_CAMERA
} else {
CameraSelector.DEFAULT_FRONT_CAMERA
}
binding.btnSwitchCamera.text = if (lensFacing == CameraSelector.DEFAULT_FRONT_CAMERA) "🔄 Front" else "🔄 Back"
startCamera()
}
private fun toggleFlash() {
flashMode = when (flashMode) {
ImageCapture.FLASH_MODE_OFF -> ImageCapture.FLASH_MODE_ON
ImageCapture.FLASH_MODE_ON -> ImageCapture.FLASH_MODE_AUTO
else -> ImageCapture.FLASH_MODE_OFF
}
binding.btnFlash.text = when (flashMode) {
ImageCapture.FLASH_MODE_ON -> "⚡ On"
ImageCapture.FLASH_MODE_AUTO -> "⚡ Auto"
else -> "⚡ Off"
}
imageCapture?.flashMode = flashMode
}
private fun toggleTimer() {
if (isCountingDown) return
timerSeconds = when (timerSeconds) {
0 -> 3
3 -> 5
5 -> 10
else -> 0
}
binding.btnTimer.text = when (timerSeconds) {
0 -> "⏱️ Off"
else -> "⏱️ ${timerSeconds}s"
}
}
private fun applyZoom(ratio: Float) {
cameraInfo?.zoomState?.value?.let { currentState ->
val maxZoom = currentState.maxZoomRatio
val minZoom = currentState.minZoomRatio
if (ratio in minZoom..maxZoom) {
cameraControl?.setZoomRatio(ratio)
updateZoomButtonsUI(ratio)
} else {
Toast.makeText(this, "Thiết bị không hỗ trợ mức zoom ${ratio}x", Toast.LENGTH_SHORT).show()
}
} ?: run {
// Fallback for emulator where zoomState might be null on start
cameraControl?.setZoomRatio(ratio)
updateZoomButtonsUI(ratio)
}
}
private fun updateZoomButtonsUI(ratio: Float) {
binding.btnZoom05.backgroundTintList = android.content.res.ColorStateList.valueOf(
android.graphics.Color.parseColor(if (ratio == 0.5f) "#FF9800" else "#2D2D2D")
)
binding.btnZoom10.backgroundTintList = android.content.res.ColorStateList.valueOf(
android.graphics.Color.parseColor(if (ratio == 1.0f) "#FF9800" else "#2D2D2D")
)
binding.btnZoom30.backgroundTintList = android.content.res.ColorStateList.valueOf(
android.graphics.Color.parseColor(if (ratio == 3.0f) "#FF9800" else "#2D2D2D")
)
}
private fun onCaptureClick() {
if (isCountingDown) return
if (timerSeconds > 0) {
startCountDown()
} else {
takePicture()
}
}
private fun startCountDown() {
isCountingDown = true
binding.txtTimerCountdown.visibility = android.view.View.VISIBLE
val timer = object : android.os.CountDownTimer(timerSeconds * 1000L, 1000L) {
override fun onTick(millisUntilFinished: Long) {
val secondsRemaining = (millisUntilFinished / 1000L) + 1
binding.txtTimerCountdown.text = secondsRemaining.toString()
try {
val toneG = android.media.ToneGenerator(android.media.AudioManager.STREAM_ALARM, 50)
toneG.startTone(android.media.ToneGenerator.TONE_PROP_BEEP, 100)
} catch (e: Exception) {
// Fallback if ToneGenerator fails
}
}
override fun onFinish() {
binding.txtTimerCountdown.visibility = android.view.View.GONE
isCountingDown = false
takePicture()
}
}
timer.start()
}
private fun takePicture() {
try {
val toneG = android.media.ToneGenerator(android.media.AudioManager.STREAM_ALARM, 100)
toneG.startTone(android.media.ToneGenerator.TONE_PROP_ACK, 250)
} catch (e: Exception) {
// Fallback
}
Toast.makeText(this, "📸 Tách! Chụp ảnh thành công!", Toast.LENGTH_SHORT).show()
}
private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all { private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
ContextCompat.checkSelfPermission(baseContext, it) == PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(baseContext, it) == PackageManager.PERMISSION_GRANTED
} }
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_CODE_PERMISSIONS) {
if (allPermissionsGranted()) {
startCamera()
} else {
Toast.makeText(this, "Quyền Camera bị từ chối.", Toast.LENGTH_SHORT).show()
}
}
}
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
cameraExecutor.shutdown() cameraExecutor.shutdown()
@@ -14,6 +14,132 @@
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/controlPanel" /> app:layout_constraintBottom_toTopOf="@id/controlPanel" />
<!-- Thanh công cụ trên đầu (Translucent Black Top Bar) -->
<LinearLayout
android:id="@+id/topBar"
android:layout_width="match_parent"
android:layout_height="60dp"
android:background="#80000000"
android:orientation="horizontal"
android:gravity="center"
android:paddingHorizontal="16dp"
app:layout_constraintTop_toTopOf="parent">
<!-- Nút Flash -->
<Button
android:id="@+id/btnFlash"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="4dp"
android:backgroundTint="#2D2D2D"
android:textColor="#FFFFFF"
android:text="⚡ Off"
android:textSize="12sp"
android:insetTop="0dp"
android:insetBottom="0dp" />
<!-- Nút Hẹn giờ -->
<Button
android:id="@+id/btnTimer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="4dp"
android:backgroundTint="#2D2D2D"
android:textColor="#FFFFFF"
android:text="⏱️ Off"
android:textSize="12sp"
android:insetTop="0dp"
android:insetBottom="0dp" />
<!-- Nút Đổi Camera -->
<Button
android:id="@+id/btnSwitchCamera"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="4dp"
android:backgroundTint="#2D2D2D"
android:textColor="#FFFFFF"
android:text="🔄 Front"
android:textSize="12sp"
android:insetTop="0dp"
android:insetBottom="0dp" />
</LinearLayout>
<!-- Số đếm ngược khi hẹn giờ chụp -->
<TextView
android:id="@+id/txtTimerCountdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#FFFFFF"
android:textSize="100sp"
android:textStyle="bold"
android:shadowColor="#80000000"
android:shadowDx="4"
android:shadowDy="4"
android:shadowRadius="10"
android:visibility="gone"
android:text="3"
app:layout_constraintTop_toTopOf="@id/viewFinder"
app:layout_constraintBottom_toBottomOf="@id/viewFinder"
app:layout_constraintLeft_toLeftOf="@id/viewFinder"
app:layout_constraintRight_toRightOf="@id/viewFinder" />
<!-- Thanh chọn Zoom ngay trên bảng điều khiển -->
<LinearLayout
android:id="@+id/zoomBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:background="#80000000"
android:orientation="horizontal"
android:padding="4dp"
app:layout_constraintBottom_toTopOf="@id/controlPanel"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent">
<Button
android:id="@+id/btnZoom05"
android:layout_width="50dp"
android:layout_height="36dp"
android:layout_marginHorizontal="2dp"
android:backgroundTint="#2D2D2D"
android:textColor="#FFFFFF"
android:text="0.5x"
android:textSize="10sp"
android:padding="0dp"
android:insetTop="0dp"
android:insetBottom="0dp" />
<Button
android:id="@+id/btnZoom10"
android:layout_width="50dp"
android:layout_height="36dp"
android:layout_marginHorizontal="2dp"
android:backgroundTint="#FF9800"
android:textColor="#FFFFFF"
android:text="1x"
android:textSize="10sp"
android:padding="0dp"
android:insetTop="0dp"
android:insetBottom="0dp" />
<Button
android:id="@+id/btnZoom30"
android:layout_width="50dp"
android:layout_height="36dp"
android:layout_marginHorizontal="2dp"
android:backgroundTint="#2D2D2D"
android:textColor="#FFFFFF"
android:text="3x"
android:textSize="10sp"
android:padding="0dp"
android:insetTop="0dp"
android:insetBottom="0dp" />
</LinearLayout>
<!-- Bảng điều khiển phía dưới --> <!-- Bảng điều khiển phía dưới -->
<RelativeLayout <RelativeLayout
android:id="@+id/controlPanel" android:id="@+id/controlPanel"