From 9f9ec87d54ca5f8afa300332c62e842b3c207939 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Sun, 5 Jul 2026 12:48:21 +0700 Subject: [PATCH] Implement 3_ANDROID_CAMERA: Advanced Camera features (Zoom controls, flash modes, front/back switch, and countdown timer) --- ...ECT_IMPLEMENT.md => 1_PROJECT_IMPLEMENT.md | 0 3_ANDROID_CAMERA.md | 110 ++++++++++ .../java/com/photobooth/app/MainActivity.kt | 206 +++++++++++++++++- .../app/src/main/res/layout/activity_main.xml | 126 +++++++++++ 4 files changed, 437 insertions(+), 5 deletions(-) rename PROJECT_IMPLEMENT.md => 1_PROJECT_IMPLEMENT.md (100%) create mode 100644 3_ANDROID_CAMERA.md diff --git a/PROJECT_IMPLEMENT.md b/1_PROJECT_IMPLEMENT.md similarity index 100% rename from PROJECT_IMPLEMENT.md rename to 1_PROJECT_IMPLEMENT.md diff --git a/3_ANDROID_CAMERA.md b/3_ANDROID_CAMERA.md new file mode 100644 index 0000000..e149e71 --- /dev/null +++ b/3_ANDROID_CAMERA.md @@ -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 + + + + + + + + + +``` + +--- + +## 💻 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` và `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). diff --git a/android-project/app/src/main/java/com/photobooth/app/MainActivity.kt b/android-project/app/src/main/java/com/photobooth/app/MainActivity.kt index d8496f4..92c3e37 100644 --- a/android-project/app/src/main/java/com/photobooth/app/MainActivity.kt +++ b/android-project/app/src/main/java/com/photobooth/app/MainActivity.kt @@ -5,7 +5,10 @@ import android.content.pm.PackageManager import android.os.Bundle import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import androidx.camera.core.CameraControl +import androidx.camera.core.CameraInfo import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider import androidx.core.app.ActivityCompat @@ -18,12 +21,24 @@ class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding 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?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) - // Xin quyền Camera + // Setup UI control listeners + setupCameraControls() + + // Request camera permission if (allPermissionsGranted()) { startCamera() } else { @@ -33,33 +48,214 @@ class MainActivity : AppCompatActivity() { 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() { val cameraProviderFuture = ProcessCameraProvider.getInstance(this) cameraProviderFuture.addListener({ val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() - // Cấu hình Kính ngắm (Preview) + // Preview configuration val preview = Preview.Builder().build().also { it.setSurfaceProvider(binding.viewFinder.surfaceProvider) } - // Chọn Camera Trước (Mặc định cho Photobooth) - val cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA + // ImageCapture configuration + imageCapture = ImageCapture.Builder() + .setFlashMode(flashMode) + .build() try { 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) { Toast.makeText(this, "Không thể khởi động Camera", Toast.LENGTH_SHORT).show() } }, 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 { ContextCompat.checkSelfPermission(baseContext, it) == PackageManager.PERMISSION_GRANTED } + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + 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() { super.onDestroy() cameraExecutor.shutdown() diff --git a/android-project/app/src/main/res/layout/activity_main.xml b/android-project/app/src/main/res/layout/activity_main.xml index 6e7632a..3c60924 100644 --- a/android-project/app/src/main/res/layout/activity_main.xml +++ b/android-project/app/src/main/res/layout/activity_main.xml @@ -14,6 +14,132 @@ app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toTopOf="@id/controlPanel" /> + + + + +