diff --git a/README.md b/README.md index 3e086fb..e967f93 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,10 @@ it can go to Drive, email, or the Files app. - **Shelf** (`MainActivity`) — one row per book, plus a `+` to create one. - **Capture** (`CaptureActivity`) — CameraX preview, flash toggle, shutter, and - a jump to the book. + a jump to the book. Every accepted shot is confirmed with a short shutter + click (a bundled sample played through `SoundPool`), so a batch of spreads can + be shot by feel. A tap that the camera refuses — still busy, not ready — + stays silent, so the click always means "taken". - **Book** (`BookActivity`) — the pages read so far, the log, **Export text**, **Capture**, and **Delete book**. diff --git a/app/src/main/java/com/bm/pagescribe/CaptureActivity.kt b/app/src/main/java/com/bm/pagescribe/CaptureActivity.kt index a9ea695..08e6aaf 100644 --- a/app/src/main/java/com/bm/pagescribe/CaptureActivity.kt +++ b/app/src/main/java/com/bm/pagescribe/CaptureActivity.kt @@ -26,6 +26,7 @@ import androidx.core.content.ContextCompat import com.bm.pagescribe.book.Book import com.bm.pagescribe.book.BookRepository import com.bm.pagescribe.ocr.WorkScheduler +import com.bm.pagescribe.util.ClickSound import com.bm.pagescribe.util.EdgeToEdge import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.button.MaterialButton @@ -44,6 +45,7 @@ class CaptureActivity : AppCompatActivity() { private lateinit var shutter: ImageButton private lateinit var flashButton: MaterialButton private lateinit var permissionPanel: View + private var clickSound: ClickSound? = null private var imageCapture: ImageCapture? = null private var book: Book? = null @@ -95,6 +97,7 @@ class CaptureActivity : AppCompatActivity() { return } supportActionBar?.title = book?.title + clickSound = ClickSound(this) shutter.setOnClickListener { shoot() } flashButton.setOnClickListener { toggleFlash() } @@ -126,6 +129,12 @@ class CaptureActivity : AppCompatActivity() { handler.removeCallbacks(poll) } + override fun onDestroy() { + clickSound?.release() + clickSound = null + super.onDestroy() + } + private fun hasCameraPermission(): Boolean = ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED @@ -174,6 +183,9 @@ class CaptureActivity : AppCompatActivity() { val target = File(current.queueDir, "page-${System.currentTimeMillis()}.jpg") val options = ImageCapture.OutputFileOptions.Builder(target).build() + // Confirmation for the tap: only once the shot is really under way, so a + // click never plays for a tap that was refused. + clickSound?.play() capturing = true shutter.isEnabled = false diff --git a/app/src/main/java/com/bm/pagescribe/util/ClickSound.kt b/app/src/main/java/com/bm/pagescribe/util/ClickSound.kt new file mode 100644 index 0000000..530b63e --- /dev/null +++ b/app/src/main/java/com/bm/pagescribe/util/ClickSound.kt @@ -0,0 +1,72 @@ +package com.bm.pagescribe.util + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.SoundPool +import android.media.ToneGenerator +import com.bm.pagescribe.R + +/** + * The short click the Camera button makes when a shot is taken. + * + * The sound is a bundled shutter sample played through [SoundPool], which is + * built for short UI sounds and keeps the latency low enough that the click + * lands with the tap. [SoundPool.load] is asynchronous, so a tap that beats + * the sample into memory falls back to a system tone rather than going silent. + * + * Call [release] when the owning activity is destroyed. + */ +class ClickSound(context: Context) { + + private val appContext = context.applicationContext + private val pool: SoundPool = SoundPool.Builder() + .setMaxStreams(1) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + ) + .build() + + private var sampleId = 0 + private var sampleReady = false + private var tone: ToneGenerator? = null + private var released = false + + init { + pool.setOnLoadCompleteListener { _, _, status -> sampleReady = status == 0 } + sampleId = pool.load(appContext, R.raw.shutter_click, 1) + } + + /** Play the click. Safe to call on every tap. */ + fun play() { + if (released) return + if (sampleReady && sampleId != 0) { + pool.play(sampleId, VOLUME, VOLUME, 1, 0, 1f) + return + } + // The sample has not finished loading yet; a system tone still confirms + // the tap. (ToneGenerator can fail on some devices, hence runCatching.) + runCatching { + val generator = tone ?: ToneGenerator(AudioManager.STREAM_SYSTEM, TONE_VOLUME) + .also { tone = it } + generator.startTone(ToneGenerator.TONE_PROP_ACK, TONE_MS) + } + } + + fun release() { + if (released) return + released = true + pool.release() + runCatching { tone?.release() } + tone = null + } + + private companion object { + const val VOLUME = 1f + const val TONE_VOLUME = 60 + const val TONE_MS = 80 + } +} diff --git a/app/src/main/res/raw/shutter_click.wav b/app/src/main/res/raw/shutter_click.wav new file mode 100644 index 0000000..36c6137 Binary files /dev/null and b/app/src/main/res/raw/shutter_click.wav differ