archive-97f2350/app/src/main/java/uk/orllewin/sianel/data/VideoHelper.kt 5.1 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134
package uk.orllewin.sianel.data

import android.content.Context
import android.graphics.Bitmap
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.provider.OpenableColumns
import java.io.File

/**
 * Media + content-resolver plumbing for picked videos: reading display info,
 * probing metadata, sampling preview frames, and staging files in the cache.
 *
 * Pure helpers over [Context] — they hold no UI/ViewModel state, so they can be
 * unit-tested and reused independently of [uk.orllewin.sianel.ui.MainViewModel].
 */
class VideoHelper(private val context: Context) {

    private val contentResolver get() = context.contentResolver

    /** Display name + size for a picked content [uri], either of which may be null. */
    fun displayInfo(uri: Uri): Pair<String?, Long?> {
        contentResolver.query(
            uri,
            arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE),
            null, null, null,
        )?.use { c ->
            if (c.moveToFirst()) {
                val name = if (!c.isNull(0)) c.getString(0) else null
                val size = if (!c.isNull(1)) c.getLong(1) else null
                return name to size
            }
        }
        return null to null
    }

    /** Duration (ms) and pixel height of [uri], either of which may be null. */
    fun probeMetadata(uri: Uri): Pair<Long?, Int?> {
        val r = MediaMetadataRetriever()
        return try {
            r.setDataSource(context, uri)
            val duration = r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
            val height = r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
            duration to height
        } catch (_: Throwable) {
            null to null
        } finally {
            runCatching { r.release() }
        }
    }

    // Timestamp (µs) of frame [index] sampled at the midpoint of its bin, so we
    // never land on a black first/last frame.
    fun frameUsec(durationMs: Long, index: Int, count: Int): Long =
        (durationMs * 1000L * (2 * index + 1)) / (2L * count)

    /** [count] evenly-spaced preview frames for the picker strip (small, square-ish). */
    fun extractFrames(uri: Uri, durationMs: Long, count: Int): List<Bitmap> {
        val r = MediaMetadataRetriever()
        return try {
            r.setDataSource(context, uri)
            (0 until count).mapNotNull { i ->
                val usec = frameUsec(durationMs, i, count)
                runCatching {
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
                        r.getScaledFrameAtTime(
                            usec,
                            MediaMetadataRetriever.OPTION_CLOSEST_SYNC,
                            THUMB_STRIP_PX, THUMB_STRIP_PX,
                        )
                    } else {
                        r.getFrameAtTime(usec, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
                    }
                }.getOrNull()
            }
        } catch (_: Throwable) {
            emptyList()
        } finally {
            runCatching { r.release() }
        }
    }

    /**
     * A single, crisp grid-sized frame at [usec], for the thumbnail uploaded with
     * the video. The picker-strip bitmap is only ~240px wide and looks soft on the
     * public grid, so we re-extract at a higher resolution here.
     */
    fun extractUploadFrame(uri: Uri, usec: Long): Bitmap? {
        val r = MediaMetadataRetriever()
        return try {
            r.setDataSource(context, uri)
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
                r.getScaledFrameAtTime(
                    usec,
                    MediaMetadataRetriever.OPTION_CLOSEST_SYNC,
                    THUMB_UPLOAD_W, THUMB_UPLOAD_H,
                )
            } else {
                r.getFrameAtTime(usec, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
            }
        } catch (_: Throwable) {
            null
        } finally {
            runCatching { r.release() }
        }
    }

    /** Copy the picked [uri] into a private cache file so the upload worker can read it. */
    fun copyToCache(uri: Uri, name: String): File {
        val out = File(context.cacheDir, "upload-${System.currentTimeMillis()}-$name")
        contentResolver.openInputStream(uri)!!.use { input ->
            out.outputStream().use { input.copyTo(it) }
        }
        return out
    }

    /** Write [bitmap] to a cache JPEG and return the file. */
    fun saveThumbnailToCache(bitmap: Bitmap): File {
        val out = File(context.cacheDir, "thumb-${System.currentTimeMillis()}.jpg")
        out.outputStream().use { stream ->
            bitmap.compress(Bitmap.CompressFormat.JPEG, THUMB_JPEG_QUALITY, stream)
        }
        return out
    }

    companion object {
        private const val THUMB_STRIP_PX = 240        // picker-strip frames (displayed small)
        private const val THUMB_UPLOAD_W = 512        // uploaded thumbnail: 16:9, ~2× grid tile
        private const val THUMB_UPLOAD_H = 288        // for retina/phone crispness
        private const val THUMB_JPEG_QUALITY = 85
    }
}