archive-97f2350/app/src/main/java/uk/orllewin/sianel/data/VideoHelper.kt
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
}
}