feat: unify audio and video attachment playback (#1188)

This commit is contained in:
M57 authored and GitHub committed 2026-09-18 20:20:34 +03:00
1 parent 89cd598c25
commit abce60ad7a
23 files changed
+2640 -496

No files matched your search

@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.ui.chat.MediaKind
import com.m57.hermescontrol.ui.chat.classifyMedia
import kotlinx.serialization.Serializable
/**
@@ -25,22 +27,23 @@ data class Attachment(
val gatewayUrl: String? = null,
val source: AttachmentSource = AttachmentSource.LOCAL,
) {
val mediaKind: MediaKind
get() = classifyMedia(mimeType = mimeType, name = name, uri = gatewayUrl ?: uri)
val isImage: Boolean
get() = mimeType.startsWith("image/")
get() = mediaKind == MediaKind.IMAGE
val isGif: Boolean
get() =
mimeType.equals("image/gif", ignoreCase = true) ||
fileExtension == "gif" ||
uri.contains(".gif", ignoreCase = true) ||
uri.startsWith("data:image/gif", ignoreCase = true)
val isVideo: Boolean
get() =
mimeType.startsWith("video/") ||
fileExtension in listOf("mp4", "webm", "mkv", "mov", "avi", "3gp") ||
uri.contains(".mp4", ignoreCase = true) ||
uri.contains(".webm", ignoreCase = true)
get() = mediaKind == MediaKind.VIDEO
val isAudio: Boolean
get() = mediaKind == MediaKind.AUDIO
val isGateway: Boolean
get() = source == AttachmentSource.GATEWAY
@@ -277,6 +277,7 @@ object GatewayFileClient {
coroutineContext.ensureActive()
val read = input.read(buffer)
if (read < 0) break
coroutineContext.ensureActive()
output.write(buffer, 0, read)
}
}
@@ -0,0 +1,73 @@
package com.m57.hermescontrol.data.remote
import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import com.m57.hermescontrol.data.local.AuthManager
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
@OptIn(UnstableApi::class)
object MediaDataSourceProvider {
class SameOriginAuthInterceptor(
private val baseUrlProvider: () -> String = { runCatching { AuthManager.baseUrl() }.getOrDefault("") },
private val tokenProvider: () -> String? = { runCatching { AuthManager.getToken() }.getOrNull() },
private val isGatedModeProvider: () -> Boolean = {
runCatching { AuthManager.isGatedMode() }.getOrDefault(
false,
)
},
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
// In gated mode (basic auth), the dashboard authenticates via session cookies;
// stamping an Authorization: Bearer header causes 401s (issue #470).
if (isGatedModeProvider()) {
return chain.proceed(request)
}
val url = request.url
val base = baseUrlProvider().toHttpUrlOrNull()
val isSameOrigin =
base != null &&
url.scheme.equals(base.scheme, ignoreCase = true) &&
url.host.equals(base.host, ignoreCase = true) &&
url.port == base.port
if (isSameOrigin) {
val token = tokenProvider()
if (!token.isNullOrBlank() && request.header("Authorization") == null) {
return chain.proceed(
request
.newBuilder()
.header("Authorization", "Bearer $token")
.build(),
)
}
}
return chain.proceed(request)
}
}
@Volatile
internal var testClient: OkHttpClient? = null
val mediaOkHttpClient: OkHttpClient
get() = testClient ?: defaultClient
private val defaultClient: OkHttpClient by lazy {
OkHttpProvider.base
.newBuilder()
.addInterceptor(SameOriginAuthInterceptor())
.build()
}
fun createDataSourceFactory(context: Context): DataSource.Factory {
val httpDataSourceFactory = OkHttpDataSource.Factory(mediaOkHttpClient)
return DefaultDataSource.Factory(context, httpDataSourceFactory)
}
}
@@ -593,16 +593,20 @@ internal fun InlineAttachment(
val isSaving = savingPath != null && savingPath == attachmentPath
val isOpening = openingPath != null && openingPath == attachmentPath
val clickable = Modifier.clickable { onOpen(attachment) }
if (attachment.isVideo) {
var showVideoDialog by remember { mutableStateOf(false) }
com.m57.hermescontrol.ui.chat.components.InlineVideoPlayer(
videoUri = attachment.uri,
onFullScreenClick = { showVideoDialog = true },
if (attachment.isVideo || attachment.isAudio) {
var showMediaDialog by remember(attachment.uri) { mutableStateOf(false) }
com.m57.hermescontrol.ui.chat.components.InlineMediaPlayer(
uri = attachment.gatewayUrl ?: attachment.uri,
title = attachment.name,
mimeType = attachment.mimeType,
onFullScreenClick = { showMediaDialog = true },
)
if (showVideoDialog) {
com.m57.hermescontrol.ui.chat.components.VideoViewerDialog(
videoUri = attachment.uri,
onDismissRequest = { showVideoDialog = false },
if (showMediaDialog) {
com.m57.hermescontrol.ui.chat.components.MediaViewerDialog(
mediaUri = attachment.gatewayUrl ?: attachment.uri,
title = attachment.name,
mimeType = attachment.mimeType,
onDismissRequest = { showMediaDialog = false },
)
}
} else if (attachment.isImage) {
@@ -0,0 +1,241 @@
package com.m57.hermescontrol.ui.chat
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.content.FileProvider
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.remote.GatewayFileClient
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.IOException
import java.util.UUID
enum class MediaSaveStrategy {
MEDIA_STORE,
CREATE_DOCUMENT,
}
fun mediaSaveStrategy(sdkInt: Int = Build.VERSION.SDK_INT): MediaSaveStrategy =
if (sdkInt >= Build.VERSION_CODES.Q) {
MediaSaveStrategy.MEDIA_STORE
} else {
MediaSaveStrategy.CREATE_DOCUMENT
}
object MediaExportHelper {
private const val SHARE_CACHE_TTL_MS = 24 * 60 * 60 * 1000L
fun resolveDisplayName(
displayName: String?,
mimeType: String?,
uri: String,
): String {
val fallbackCandidate = mediaNameFromPath(uri).takeIf { it.isNotBlank() && it != "file" } ?: "hermes-media"
val candidate = displayName?.takeIf { it.isNotBlank() } ?: fallbackCandidate
val leaf = candidate.split('/', '\\').lastOrNull { it.isNotBlank() } ?: "hermes-media"
val rawExt = extractFileExtension(leaf)
val hasValidExt = rawExt.isNotBlank() && rawExt.length <= 10
val finalExt =
if (hasValidExt) {
rawExt
} else {
mimeType?.let { extensionForMime(it) } ?: "bin"
}
val rawBase = if (hasValidExt) leaf.substringBeforeLast('.') else leaf
val cleanedBase =
rawBase
.replace(Regex("[^A-Za-z0-9_\\-]"), "_")
.trim('_')
.take(60)
.ifBlank { "hermes-media" }
val cleanedExt =
finalExt
.replace(Regex("[^A-Za-z0-9]"), "")
.take(10)
.ifBlank { "bin" }
return "$cleanedBase.$cleanedExt"
}
/**
* Best-effort cleanup of stale share directories older than 24 hours.
* Only touches subdirectories inside [sharedMediaDir].
*/
internal fun sweepOldShareFiles(
sharedMediaDir: File,
now: Long = System.currentTimeMillis(),
) {
val cutoff = now - SHARE_CACHE_TTL_MS
runCatching {
sharedMediaDir.listFiles()?.forEach { entry ->
if (entry.isDirectory) {
if (entry.lastModified() < cutoff) {
entry.deleteRecursively()
}
} else if (entry.isFile && entry.lastModified() < cutoff) {
entry.delete()
}
}
}
}
@RequiresApi(Build.VERSION_CODES.Q)
internal fun createMediaStoreValues(
displayName: String,
mimeType: String,
length: Long?,
): ContentValues =
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
if (length != null && length > 0) {
put(MediaStore.MediaColumns.SIZE, length)
}
put(MediaStore.MediaColumns.RELATIVE_PATH, "Download/Hermes")
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
/**
* Streams media directly to [MediaStore.Downloads] on API 29+ (Android Q+).
* Uses bounded 64 KiB chunks with periodic coroutine cancellation checks.
* Marked pending during write and un-pended on commit.
* Guarantees that any partial item is deleted if the write fails or is canceled.
*/
@RequiresApi(Build.VERSION_CODES.Q)
suspend fun saveMediaToDownloads(
context: Context,
uri: String,
fallbackMime: String = "application/octet-stream",
displayName: String? = null,
currentSdk: Int = Build.VERSION.SDK_INT,
valuesFactory: (String, String, Long?) -> ContentValues = ::createMediaStoreValues,
): String =
withContext(Dispatchers.IO) {
if (currentSdk < Build.VERSION_CODES.Q) {
throw UnsupportedOperationException("Direct MediaStore.Downloads requires API 29+")
}
MediaStreamResolver.useStream(context, uri, fallbackMime) { inputStream, streamInfo ->
val finalName = resolveDisplayName(displayName, streamInfo.mimeType, uri)
val frameworkMime = normalizeFrameworkMime(streamInfo.mimeType)
val values = valuesFactory(finalName, frameworkMime, streamInfo.length)
val resolver = context.contentResolver
val targetUri =
resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
?: throw IOException("Failed to insert into MediaStore.Downloads")
var success = false
try {
resolver.openOutputStream(targetUri)?.use { outputStream ->
GatewayFileClient.copyChunked(inputStream, outputStream)
} ?: throw IOException("Failed to open MediaStore output stream")
val publishValues = valuesFactory(finalName, frameworkMime, streamInfo.length)
publishValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
val updated = resolver.update(targetUri, publishValues, null, null)
if (updated <= 0) {
throw IOException("Failed to publish media item in MediaStore")
}
success = true
context.getString(R.string.media_player_saved)
} finally {
if (!success) {
runCatching { resolver.delete(targetUri, null, null) }
}
}
}
}
/**
* Streams media directly into a user-selected [targetUri] obtained via SAF (e.g. CreateDocument).
* Used for API 26-28 where MediaStore.Downloads is unavailable.
* Deletes partial documents if the stream write fails or is canceled.
*/
suspend fun saveMediaToUri(
context: Context,
sourceUri: String,
targetUri: Uri,
fallbackMime: String = "application/octet-stream",
): String =
withContext(Dispatchers.IO) {
val resolver = context.contentResolver
var success = false
try {
MediaStreamResolver.useStream(context, sourceUri, fallbackMime) { inputStream, _ ->
resolver.openOutputStream(targetUri)?.use { outputStream ->
GatewayFileClient.copyChunked(inputStream, outputStream)
} ?: throw IOException("Failed to open target document output stream")
}
success = true
context.getString(R.string.media_player_saved)
} finally {
if (!success) {
runCatching { DocumentsContract.deleteDocument(resolver, targetUri) }
.onFailure {
runCatching { resolver.delete(targetUri, null, null) }
}
}
}
}
/**
* Streams media into an internal cache file in `shared_media/<uuid>/<safeName>` and builds
* an ACTION_SEND Intent. Uses collision-proof session subdirectories and cleans stale entries
* older than 24 hours. Share files are preserved so receiving apps can read them.
*/
suspend fun shareMedia(
context: Context,
uri: String,
fallbackMime: String = "application/octet-stream",
displayName: String? = null,
fileUriProvider: (Context, File) -> Uri = { ctx, f ->
FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", f)
},
intentFactory: (Uri, String) -> Intent = { contentUri, mime ->
Intent(Intent.ACTION_SEND).apply {
type = mime
putExtra(Intent.EXTRA_STREAM, contentUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
},
): Intent? =
withContext(Dispatchers.IO) {
try {
MediaStreamResolver.useStream(context, uri, fallbackMime) { inputStream, streamInfo ->
val finalName = resolveDisplayName(displayName, streamInfo.mimeType, uri)
val sharedMediaDir = File(context.cacheDir, "shared_media").also { it.mkdirs() }
sweepOldShareFiles(sharedMediaDir)
val sessionDir = File(sharedMediaDir, UUID.randomUUID().toString()).also { it.mkdirs() }
val targetFile = File(sessionDir, finalName)
var success = false
try {
targetFile.outputStream().use { outputStream ->
GatewayFileClient.copyChunked(inputStream, outputStream)
}
success = true
} finally {
if (!success) {
targetFile.delete()
sessionDir.delete()
}
}
val contentUri = fileUriProvider(context, targetFile)
val normalizedMime = normalizeFrameworkMime(streamInfo.mimeType)
intentFactory(contentUri, normalizedMime)
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}
}
}
@@ -0,0 +1,240 @@
package com.m57.hermescontrol.ui.chat
import android.content.Context
import android.net.Uri
import com.m57.hermescontrol.data.remote.MediaDataSourceProvider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Request
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream
import java.util.Base64
object MediaStreamResolver {
data class StreamInfo(
val mimeType: String,
val extension: String,
val length: Long?,
)
/**
* Streams characters directly from a [CharSequence] without allocating substring copies.
* Skips whitespace to tolerate formatted Base64 data URLs.
*/
internal class CharSequenceInputStream(
private val value: CharSequence,
startIndex: Int,
) : InputStream() {
private var index = startIndex
override fun read(): Int {
while (index < value.length) {
val c = value[index++]
if (!c.isWhitespace()) {
return c.code and 0xFF
}
}
return -1
}
override fun read(
b: ByteArray,
off: Int,
len: Int,
): Int {
if (len == 0) return 0
var bytesRead = 0
while (bytesRead < len) {
val c = read()
if (c == -1) {
return if (bytesRead == 0) -1 else bytesRead
}
b[off + bytesRead] = c.toByte()
bytesRead++
}
return bytesRead
}
}
/**
* Resolves the media source identified by [uriString] and streams its content
* into [consumer]. Guarantees that all underlying streams and HTTP response bodies
* are strictly closed via [use], and that no whole-media buffering occurs.
*/
suspend fun <T> useStream(
context: Context,
uriString: String,
fallbackMime: String = "application/octet-stream",
consumer: suspend (InputStream, StreamInfo) -> T,
): T =
withContext(Dispatchers.IO) {
try {
when {
uriString.startsWith("data:", ignoreCase = true) -> {
openDataUrlStream(uriString, fallbackMime, consumer)
}
uriString.startsWith("content://", ignoreCase = true) -> {
openContentStream(context, uriString, fallbackMime, consumer)
}
uriString.startsWith("file:", ignoreCase = true) -> {
openFileUriStream(uriString, fallbackMime, consumer)
}
uriString.startsWith("/") -> {
openLocalFileStream(uriString, fallbackMime, consumer)
}
uriString.startsWith("https://", ignoreCase = true) ||
uriString.startsWith("http://", ignoreCase = true) -> {
openHttpStream(uriString, fallbackMime, consumer)
}
else -> {
throw IOException("Unsupported media source: $uriString")
}
}
} catch (e: CancellationException) {
throw e
}
}
private suspend fun <T> openDataUrlStream(
dataUrl: String,
fallbackMime: String,
consumer: suspend (InputStream, StreamInfo) -> T,
): T {
val comma = dataUrl.indexOf(',')
if (comma < 0) throw IOException("Malformed data URL")
val meta = dataUrl.substring(0, comma)
val mime =
meta
.substringAfter("data:", fallbackMime)
.substringBefore(";")
.ifBlank { fallbackMime }
if (!meta.contains(";base64", ignoreCase = true)) {
throw IOException("Only base64 data URLs are supported")
}
val rawStream = CharSequenceInputStream(dataUrl, comma + 1)
val decodingStream = Base64.getDecoder().wrap(rawStream)
return decodingStream.use { stream ->
consumer(
stream,
StreamInfo(
mimeType = mime,
extension = extensionForMime(mime),
length = null,
),
)
}
}
private suspend fun <T> openContentStream(
context: Context,
uriString: String,
fallbackMime: String,
consumer: suspend (InputStream, StreamInfo) -> T,
): T {
val uri = Uri.parse(uriString)
val resolvedMime = runCatching { context.contentResolver.getType(uri) }.getOrNull()
val mime = resolvedMime?.takeIf { it.isNotBlank() } ?: fallbackMime
val length =
runCatching {
context.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
pfd.statSize.takeIf { it >= 0 }
}
}.getOrNull()
val stream =
runCatching { context.contentResolver.openInputStream(uri) }.getOrNull()
?: throw IOException("Could not open content URI: $uriString")
return stream.use { inputStream ->
consumer(
inputStream,
StreamInfo(
mimeType = mime,
extension = extensionForMime(mime),
length = length,
),
)
}
}
private suspend fun <T> openFileUriStream(
uriString: String,
fallbackMime: String,
consumer: suspend (InputStream, StreamInfo) -> T,
): T {
val parsed = runCatching { java.net.URI(uriString).path }.getOrNull() ?: Uri.parse(uriString).path
val file = File(parsed ?: throw IOException("Invalid file URI: $uriString"))
if (!file.exists() || !file.isFile) throw FileNotFoundException("Not a valid file: ${file.path}")
val mime = mediaMimeForPath(file.name).takeUnless { it == "application/octet-stream" } ?: fallbackMime
return file.inputStream().use { inputStream ->
consumer(
inputStream,
StreamInfo(
mimeType = mime,
extension = extensionForMime(mime),
length = file.length().takeIf { it >= 0 },
),
)
}
}
private suspend fun <T> openLocalFileStream(
filePath: String,
fallbackMime: String,
consumer: suspend (InputStream, StreamInfo) -> T,
): T {
val file = File(filePath)
if (!file.exists() || !file.isFile) throw FileNotFoundException("Not a valid file: $filePath")
val mime = mediaMimeForPath(file.name).takeUnless { it == "application/octet-stream" } ?: fallbackMime
return file.inputStream().use { inputStream ->
consumer(
inputStream,
StreamInfo(
mimeType = mime,
extension = extensionForMime(mime),
length = file.length().takeIf { it >= 0 },
),
)
}
}
private suspend fun <T> openHttpStream(
url: String,
fallbackMime: String,
consumer: suspend (InputStream, StreamInfo) -> T,
): T {
val request = Request.Builder().url(url).build()
val response = MediaDataSourceProvider.mediaOkHttpClient.newCall(request).execute()
if (!response.isSuccessful) {
response.close()
throw IOException("Download failed (HTTP ${response.code})")
}
val body = response.body
return response.use { resp ->
val headerMime = resp.header("Content-Type")?.substringBefore(";")?.trim()
val mime =
when {
!headerMime.isNullOrBlank() && headerMime != "application/octet-stream" -> headerMime
fallbackMime.isNotBlank() && fallbackMime != "application/octet-stream" -> fallbackMime
else -> mediaMimeForPath(url)
}
val length = body.contentLength().takeIf { it >= 0 }
body.byteStream().use { inputStream ->
consumer(
inputStream,
StreamInfo(
mimeType = mime,
extension = extensionForMime(mime),
length = length,
),
)
}
}
}
}
@@ -4,12 +4,10 @@ import java.net.URLDecoder
import java.nio.charset.StandardCharsets
/**
* File-kind classification for gateway-hosted files, mirroring the desktop app's
* File-kind classification for media and gateway-hosted files, mirroring the desktop app's
* `MEDIA_BY_EXT` table (`apps/desktop/src/lib/media.ts`). Lets UI code decide
* how to present a downloaded file — inline image, audio player, video player,
* how to present a file — inline image, audio player, video player,
* or a generic file card — without re-deriving the mapping at every call site.
*
* Extend the table (not the call sites) when new extensions are needed.
*/
enum class MediaKind {
IMAGE,
@@ -20,6 +18,8 @@ enum class MediaKind {
private val MEDIA_BY_EXT: Map<String, Pair<MediaKind, String>> =
mapOf(
"3gp" to (MediaKind.VIDEO to "video/3gpp"),
"aac" to (MediaKind.AUDIO to "audio/aac"),
"avi" to (MediaKind.VIDEO to "video/x-msvideo"),
"bmp" to (MediaKind.IMAGE to "image/bmp"),
"csv" to (MediaKind.FILE to "text/csv"),
@@ -42,50 +42,189 @@ private val MEDIA_BY_EXT: Map<String, Pair<MediaKind, String>> =
"webp" to (MediaKind.IMAGE to "image/webp"),
)
/** Classify a path/URL by extension. Unknown extensions fall back to [MediaKind.FILE]. */
fun mediaKindForPath(path: String): MediaKind {
val ext =
path
.split('?', limit = 2)[0]
.split('.')
.lastOrNull()
/**
* Extracts the file extension from a clean path or URL, ignoring query parameters and fragments.
* Never searches queries for extensions to avoid false positives.
*/
fun extractFileExtension(pathOrUrl: String): String {
val clean =
pathOrUrl
.substringBefore('#')
.substringBefore('?')
.trim()
val lastSegment = clean.substringAfterLast('/').substringAfterLast('\\')
val ext = lastSegment.substringAfterLast('.', "")
return if (ext == lastSegment) "" else ext.lowercase()
}
/**
* Extracts the file extension for a media source, handling gateway URLs where the path
* is encoded inside the `path=` query parameter.
*/
fun extractExtensionFromMediaSource(source: String): String {
val sourcePath = source.substringBefore('?').substringBefore('#')
val gatewayPath =
if (sourcePath.endsWith("/api/files/download") || sourcePath.endsWith("/api/files/stream")) {
gatewayPathFromUrl(source)
} else {
null
}
gatewayPath?.let { gwPath ->
val ext = extractFileExtension(gwPath)
if (ext.isNotBlank()) return ext
}
return extractFileExtension(source)
}
/**
* Robust classification of a media item by concrete MIME type first, then by file extension
* from [name] or [uri]. Concrete MIME types take precedence (e.g. "audio/mp4" is recognized as AUDIO).
*/
fun classifyMedia(
mimeType: String? = null,
name: String? = null,
uri: String? = null,
): MediaKind {
val cleanMime =
mimeType
?.substringBefore(';')
?.trim()
?.lowercase()
.orEmpty()
return MEDIA_BY_EXT[ext]?.first ?: MediaKind.FILE
if (cleanMime.isNotBlank() && cleanMime != "application/octet-stream" && cleanMime != "*/*") {
if (cleanMime.startsWith("audio/") || cleanMime == "application/ogg" || cleanMime == "application/x-flac") {
return MediaKind.AUDIO
}
if (cleanMime.startsWith("video/")) {
return MediaKind.VIDEO
}
if (cleanMime.startsWith("image/")) {
return MediaKind.IMAGE
}
return MediaKind.FILE
}
if (uri?.startsWith("data:", ignoreCase = true) == true) {
return classifyMedia(mimeType = uri.substringAfter(':').substringBefore(';').substringBefore(','))
}
val extFromName = name?.let { extractFileExtension(it) }.orEmpty()
if (extFromName.isNotBlank()) {
MEDIA_BY_EXT[extFromName]?.first?.let { return it }
}
val extFromUri = uri?.let { extractExtensionFromMediaSource(it) }.orEmpty()
if (extFromUri.isNotBlank()) {
MEDIA_BY_EXT[extFromUri]?.first?.let { return it }
}
return MediaKind.FILE
}
/** Classify a path/URL by extension. Unknown extensions fall back to [MediaKind.FILE]. */
fun mediaKindForPath(path: String): MediaKind = classifyMedia(uri = path)
/** Best-guess MIME type for a path/URL by extension. Falls back to octet-stream. */
fun mediaMimeForPath(path: String): String {
val ext =
path
.split('?', limit = 2)[0]
.split('.')
.lastOrNull()
?.lowercase()
.orEmpty()
val ext = extractExtensionFromMediaSource(path)
return MEDIA_BY_EXT[ext]?.second ?: "application/octet-stream"
}
/** Decoded gateway file path carried in a `/api/files/download` URL. */
/**
* Normalizes a MIME type string for Android framework boundaries (MediaStore, Intent, SAF),
* stripping parameters such as codecs (e.g. `audio/ogg; codecs=opus` -> `audio/ogg`).
*/
fun normalizeFrameworkMime(mime: String?): String =
mime
?.substringBefore(';')
?.trim()
?.takeIf { it.isNotBlank() }
?: "application/octet-stream"
/** Returns the canonical file extension for a given MIME type. */
fun extensionForMime(mime: String): String {
val clean = mime.substringBefore(';').trim().lowercase()
for ((ext, pair) in MEDIA_BY_EXT) {
if (pair.second
.substringBefore(';')
.trim()
.lowercase() == clean
) {
return ext
}
}
return when (clean) {
"image/jpeg", "image/jpg" -> {
"jpg"
}
"video/mp4" -> {
"mp4"
}
"video/x-matroska" -> {
"mkv"
}
"video/quicktime" -> {
"mov"
}
"audio/mpeg", "audio/mp3" -> {
"mp3"
}
"audio/mp4", "audio/x-m4a" -> {
"m4a"
}
"audio/wav", "audio/x-wav", "audio/wave" -> {
"wav"
}
"audio/flac", "application/x-flac" -> {
"flac"
}
"audio/ogg", "application/ogg" -> {
"ogg"
}
"audio/aac" -> {
"aac"
}
else -> {
when {
clean.startsWith("video/") -> "mp4"
clean.startsWith("audio/") -> "mp3"
clean.startsWith("image/") -> "img"
else -> "bin"
}
}
}
}
/** Decoded gateway file path carried in a `/api/files/download` or `/api/files/stream` URL. */
fun gatewayPathFromUrl(url: String): String? =
Regex("""[?&]path=([^&]+)""", RegexOption.IGNORE_CASE).find(url)?.groupValues?.get(1)?.let {
runCatching { URLDecoder.decode(it, StandardCharsets.UTF_8.name()) }.getOrDefault(it)
}
/** Trailing filename from a path or URL.
*
/**
* Trailing filename from a path or URL.
* Handles both a bare gateway path (`/tmp/report.pdf`) and a full
* `/api/files/download?path=<enc>` URL, where the real filename lives in the
* percent-encoded `path=` query parameter. */
* percent-encoded `path=` query parameter.
*/
fun mediaNameFromPath(path: String): String {
gatewayPathFromUrl(path)?.split('/', '\\')?.lastOrNull { it.isNotBlank() }?.let { return it }
val cleanPath = path.split('?', limit = 2)[0]
val cleanPath = path.substringBefore('#').substringBefore('?')
return runCatching {
java.net
.URI(cleanPath)
.path
.split('/')
.lastOrNull { it.isNotBlank() }
?.split('/')
?.lastOrNull { it.isNotBlank() }
}.getOrNull()
?: cleanPath.split('/', '\\').lastOrNull { it.isNotBlank() }
?: path
@@ -0,0 +1,211 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Audiotrack
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.MediaKind
import com.m57.hermescontrol.ui.chat.classifyMedia
import com.m57.hermescontrol.ui.chat.mediaNameFromPath
/**
* Shared inline media preview card for audio and video in chat bubbles and markdown blocks.
* Renders a compact file-style card for audio and a 16:9 preview for video.
*/
@Composable
fun InlineMediaPlayer(
uri: String,
modifier: Modifier = Modifier,
title: String? = null,
mimeType: String? = null,
onFullScreenClick: () -> Unit = {},
) {
val mediaKind =
remember(uri, mimeType, title) {
classifyMedia(mimeType = mimeType, name = title, uri = uri)
}
val displayName =
remember(uri, title) {
title?.takeIf { it.isNotBlank() } ?: mediaNameFromPath(uri)
}
if (mediaKind == MediaKind.AUDIO) {
InlineAudioPreview(
name = displayName,
modifier = modifier,
onClick = onFullScreenClick,
)
} else {
InlineVideoPreview(
modifier = modifier,
onClick = onFullScreenClick,
)
}
}
@Composable
private fun InlineAudioPreview(
name: String,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier =
modifier
.fillMaxWidth()
.clickable { onClick() }
.testTag("inline_audio_player"),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
modifier =
Modifier
.size(42.dp)
.testTag("audio_play_overlay"),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.Audiotrack,
contentDescription = stringResource(R.string.media_player_audio),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(24.dp),
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Column(
modifier = Modifier.weight(1f),
) {
Text(
text = name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringResource(R.string.media_player_audio).uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 10.sp,
)
}
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(36.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = stringResource(R.string.media_player_play_audio),
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(20.dp),
)
}
}
}
}
}
@Composable
private fun InlineVideoPreview(
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Box(
modifier =
modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(14.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.clickable { onClick() }
.testTag("inline_video_player"),
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.65f),
modifier =
Modifier
.align(Alignment.Center)
.size(56.dp)
.testTag("video_play_overlay"),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = stringResource(R.string.media_player_play_video),
tint = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier.size(36.dp),
)
}
}
Box(
modifier =
Modifier
.align(Alignment.BottomEnd)
.padding(8.dp)
.background(
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f),
shape = RoundedCornerShape(16.dp),
).padding(horizontal = 8.dp, vertical = 4.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = stringResource(R.string.media_player_video),
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
Text(
text = " " + stringResource(R.string.media_player_video).uppercase(),
color = MaterialTheme.colorScheme.inverseOnSurface,
fontSize = 10.sp,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
@@ -1,34 +1,10 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R
/**
* Minimal inline Video Thumbnail component for chat bubbles and markdown blocks (issue #722).
* Shows a video preview card with a play button badge that launches the full-screen video viewer.
* Backwards-compatible wrapper delegating to [InlineMediaPlayer].
*/
@Composable
fun InlineVideoPlayer(
@@ -36,65 +12,11 @@ fun InlineVideoPlayer(
modifier: Modifier = Modifier,
onFullScreenClick: () -> Unit = {},
) {
Box(
modifier =
modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(14.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.clickable { onFullScreenClick() }
.testTag("inline_video_player"),
) {
// Play icon badge in center
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.65f),
modifier =
Modifier
.align(Alignment.Center)
.size(56.dp)
.testTag("video_play_overlay"),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = stringResource(R.string.video_play),
tint = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier.size(36.dp),
)
}
}
// VIDEO label badge in bottom-right corner
Box(
modifier =
Modifier
.align(Alignment.BottomEnd)
.padding(8.dp)
.background(
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f),
shape = RoundedCornerShape(16.dp),
).padding(horizontal = 8.dp, vertical = 4.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Video",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
Text(
text = " VIDEO",
color = MaterialTheme.colorScheme.inverseOnSurface,
fontSize = 10.sp,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
InlineMediaPlayer(
uri = videoUri,
modifier = modifier,
onFullScreenClick = onFullScreenClick,
)
}
/** Formats milliseconds into `M:SS` or `H:MM:SS` */
@@ -0,0 +1,714 @@
package com.m57.hermescontrol.ui.chat.components
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Audiotrack
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Replay
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.remote.MediaDataSourceProvider
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.MediaExportHelper
import com.m57.hermescontrol.ui.chat.MediaKind
import com.m57.hermescontrol.ui.chat.MediaSaveStrategy
import com.m57.hermescontrol.ui.chat.acceptedSaveDestination
import com.m57.hermescontrol.ui.chat.classifyMedia
import com.m57.hermescontrol.ui.chat.mediaNameFromPath
import com.m57.hermescontrol.ui.chat.mediaSaveStrategy
import com.m57.hermescontrol.ui.chat.normalizeFrameworkMime
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Unified Media Viewer Dialog for both Audio and Video, backed by AndroidX Media3 ExoPlayer.
*/
@OptIn(UnstableApi::class)
@Composable
fun MediaViewerDialog(
mediaUri: String,
onDismissRequest: () -> Unit,
title: String? = null,
mimeType: String? = null,
) {
key(mediaUri, mimeType) {
MediaViewerContent(mediaUri, onDismissRequest, title, mimeType)
}
}
@OptIn(UnstableApi::class)
@Composable
private fun MediaViewerContent(
mediaUri: String,
onDismissRequest: () -> Unit,
title: String?,
mimeType: String?,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope()
val mediaKind =
remember(mediaUri, mimeType, title) {
classifyMedia(mimeType = mimeType, name = title, uri = mediaUri)
}
val isAudio = mediaKind == MediaKind.AUDIO
val displayName =
remember(mediaUri, title) {
title?.takeIf { it.isNotBlank() } ?: mediaNameFromPath(mediaUri)
}
var isPlaying by remember { mutableStateOf(false) }
var isBuffering by remember { mutableStateOf(true) }
var isEnded by remember { mutableStateOf(false) }
var hasError by remember { mutableStateOf(false) }
var currentPositionMs by remember { mutableLongStateOf(0L) }
var durationMs by remember { mutableLongStateOf(0L) }
var isDragging by remember { mutableStateOf(false) }
var dragPositionMs by remember { mutableLongStateOf(0L) }
var showControls by remember { mutableStateOf(true) }
var isBusy by remember { mutableStateOf(false) }
val statusColors = LocalHermesStatusColors.current
val saveFailed = stringResource(R.string.media_player_save_failed)
val shareFailed = stringResource(R.string.media_player_share_failed)
val shareTitle = stringResource(R.string.media_player_share_title)
val player =
remember(context, mediaUri) {
val dataSourceFactory = MediaDataSourceProvider.createDataSourceFactory(context)
val mediaSourceFactory = DefaultMediaSourceFactory(context).setDataSourceFactory(dataSourceFactory)
val audioAttributes =
AudioAttributes
.Builder()
.setContentType(if (isAudio) C.AUDIO_CONTENT_TYPE_MUSIC else C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
.build()
ExoPlayer
.Builder(context)
.setRenderersFactory(
// Emulator guard: the goldfish "hardware" codecs crash on queueInputBuffer
// ("Decoder failed: c2.goldfish.h264.decoder"); deprioritize them so the
// reliable software codecs are selected instead. Real devices never ship
// goldfish codecs, so this has no effect on phones.
androidx.media3.exoplayer
.DefaultRenderersFactory(context)
.setEnableDecoderFallback(true)
.setMediaCodecSelector { mimeType, requiresSecureDecoder, requiresTunnelingDecoder ->
androidx.media3.exoplayer.mediacodec.MediaCodecSelector.DEFAULT
.getDecoderInfos(mimeType, requiresSecureDecoder, requiresTunnelingDecoder)
.sortedBy { it.name.contains("goldfish", ignoreCase = true) }
},
).setAudioAttributes(audioAttributes, true)
.setHandleAudioBecomingNoisy(true)
.setMediaSourceFactory(mediaSourceFactory)
.build()
.apply {
setMediaItem(MediaItem.fromUri(Uri.parse(mediaUri)))
}
}
DisposableEffect(player) {
val listener =
object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
when (state) {
Player.STATE_BUFFERING -> {
isBuffering = true
}
Player.STATE_READY -> {
isBuffering = false
hasError = false
durationMs = player.duration.coerceAtLeast(0L)
}
Player.STATE_ENDED -> {
isBuffering = false
isPlaying = false
isEnded = true
currentPositionMs = durationMs
}
Player.STATE_IDLE -> {
isBuffering = false
}
}
}
override fun onIsPlayingChanged(isPlayingNow: Boolean) {
isPlaying = isPlayingNow
if (isPlayingNow) {
isEnded = false
}
}
override fun onPlayerError(error: PlaybackException) {
isBuffering = false
hasError = true
}
}
player.addListener(listener)
player.prepare()
player.playWhenReady = lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
onDispose {
player.removeListener(listener)
player.release()
}
}
DisposableEffect(lifecycleOwner, player) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) {
player.pause()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
LaunchedEffect(player, isPlaying, isDragging) {
while (isPlaying && !isDragging) {
currentPositionMs = player.currentPosition.coerceAtLeast(0L)
if (durationMs <= 0L && player.duration > 0L) {
durationMs = player.duration
}
delay(200)
}
}
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
ExternalActivityLifecycleGuard.launchExternalActivity(
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
releaseConnectionLease = HermesWsClient::releaseExternalActivityConnectionLease,
prepareForBackground = { NotificationHelper.start(context) },
cleanupAfterLaunchFailure = { NotificationHelper.stop(context) },
launch = launch,
)
}
val saveDocumentLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
) { result ->
try {
ExternalActivityLifecycleGuard.externalActivityReturned()
val destination = acceptedSaveDestination(result.resultCode, result.data?.data)
if (destination != null) {
scope.launch {
try {
val msg =
MediaExportHelper.saveMediaToUri(
context = context,
sourceUri = mediaUri,
targetUri = destination,
fallbackMime =
mimeType?.takeUnless { it == "application/octet-stream" }
?: com.m57.hermescontrol.ui.chat
.mediaMimeForPath(displayName),
)
withContext(Dispatchers.Main) {
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show()
}
} finally {
isBusy = false
}
}
} else {
isBusy = false
}
} catch (e: Throwable) {
isBusy = false
if (e is CancellationException) throw e
}
}
val onSave: () -> Unit = {
if (!isBusy) {
isBusy = true
when (mediaSaveStrategy()) {
MediaSaveStrategy.MEDIA_STORE -> {
scope.launch {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val msg =
MediaExportHelper.saveMediaToDownloads(
context = context,
uri = mediaUri,
fallbackMime =
mimeType?.takeUnless { it == "application/octet-stream" }
?: com.m57.hermescontrol.ui.chat
.mediaMimeForPath(displayName),
displayName = displayName,
)
withContext(Dispatchers.Main) {
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
}
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show()
}
} finally {
isBusy = false
}
}
}
MediaSaveStrategy.CREATE_DOCUMENT -> {
val suggestedName =
MediaExportHelper.resolveDisplayName(
displayName = displayName,
mimeType = mimeType,
uri = mediaUri,
)
val rawMime =
mimeType?.takeUnless { it == "application/octet-stream" }
?: com.m57.hermescontrol.ui.chat
.mediaMimeForPath(displayName)
val normalizedMime = normalizeFrameworkMime(rawMime)
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = normalizedMime
putExtra(Intent.EXTRA_TITLE, suggestedName)
}
try {
launchExternalActivity {
saveDocumentLauncher.launch(intent)
}
} catch (_: Exception) {
isBusy = false
scope.launch(Dispatchers.Main) {
Toast.makeText(context, saveFailed, Toast.LENGTH_SHORT).show()
}
}
}
}
}
}
val onShare: () -> Unit = {
if (!isBusy) {
isBusy = true
scope.launch {
try {
val intent =
MediaExportHelper.shareMedia(
context = context,
uri = mediaUri,
fallbackMime =
mimeType?.takeUnless { it == "application/octet-stream" }
?: com.m57.hermescontrol.ui.chat
.mediaMimeForPath(displayName),
displayName = displayName,
)
withContext(Dispatchers.Main) {
if (intent != null) {
val chooser = Intent.createChooser(intent, shareTitle)
try {
launchExternalActivity {
context.startActivity(chooser)
}
} catch (_: Exception) {
Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show()
}
} else {
Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show()
}
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(context, shareFailed, Toast.LENGTH_SHORT).show()
}
} finally {
isBusy = false
}
}
}
}
Dialog(
onDismissRequest = onDismissRequest,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
dismissOnBackPress = true,
dismissOnClickOutside = true,
),
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.testTag("media_viewer_dialog"),
) {
if (!isAudio) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
this.player = player
useController = false
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
}
},
modifier = Modifier.fillMaxSize(),
)
// Keep the background gesture out of the controls' ancestor chain.
Box(
Modifier.fillMaxSize().clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {
if (isPlaying && !isDragging) showControls = !showControls
},
)
} else if (!isBuffering && !hasError) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(bottom = 180.dp, start = 24.dp, end = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(100.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.Audiotrack,
contentDescription = stringResource(R.string.media_player_audio),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp),
)
}
}
Spacer(modifier = Modifier.size(16.dp))
Text(
text = displayName,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
// Top Bar Controls
AnimatedVisibility(
visible = showControls || isAudio || !isPlaying || isDragging,
enter = fadeIn(),
exit = fadeOut(),
modifier =
Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.5f))
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = onDismissRequest,
modifier = Modifier.testTag("media_close_button"),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.media_player_close),
tint = MaterialTheme.colorScheme.onSurface,
)
}
Text(
text = displayName,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp),
)
IconButton(
onClick = onSave,
enabled = !isBusy,
modifier = Modifier.testTag("media_save_button"),
) {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = stringResource(R.string.media_player_save),
tint = MaterialTheme.colorScheme.onSurface,
)
}
IconButton(
onClick = onShare,
enabled = !isBusy,
modifier = Modifier.testTag("media_share_button"),
) {
Icon(
imageVector = Icons.Filled.Share,
contentDescription = stringResource(R.string.media_player_share),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
// Center play/pause/loading/error
if (isBuffering) {
CircularProgressIndicator(
modifier =
Modifier
.align(Alignment.Center)
.size(48.dp)
.testTag("media_buffering_indicator"),
color = MaterialTheme.colorScheme.primary,
)
} else if (hasError) {
Column(
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.media_player_load_failed, ""),
style = MaterialTheme.typography.bodyMedium,
color = statusColors.error,
modifier = Modifier.padding(bottom = 12.dp),
)
IconButton(
onClick = {
hasError = false
isBuffering = true
player.prepare()
player.play()
},
modifier = Modifier.size(56.dp).testTag("media_retry_button"),
) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = stringResource(R.string.media_player_retry),
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(36.dp),
)
}
}
} else {
AnimatedVisibility(
visible = showControls || isAudio || !isPlaying || isDragging,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.Center),
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.65f),
modifier =
Modifier
.size(64.dp)
.testTag("media_play_pause_button")
.clickable {
if (isEnded) {
player.seekTo(0)
player.play()
isEnded = false
} else if (isPlaying) {
player.pause()
} else {
player.play()
}
},
) {
Box(contentAlignment = Alignment.Center) {
val icon =
when {
isEnded -> Icons.Filled.Replay
isPlaying -> Icons.Filled.Pause
else -> Icons.Filled.PlayArrow
}
val desc =
when {
isEnded -> stringResource(R.string.media_player_replay)
isPlaying -> stringResource(R.string.media_player_pause)
else -> stringResource(R.string.media_player_play)
}
Icon(
imageVector = icon,
contentDescription = desc,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(40.dp),
)
}
}
}
}
// Bottom Bar Controls
AnimatedVisibility(
visible = showControls || isAudio || !isPlaying || isDragging,
enter = fadeIn(),
exit = fadeOut(),
modifier =
Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.fillMaxWidth(),
) {
val displayPosition = if (isDragging) dragPositionMs else currentPositionMs
Column(
modifier =
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.5f))
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = formatMediaDuration(displayPosition),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.width(48.dp),
)
Slider(
value = displayPosition.coerceIn(0L, durationMs.coerceAtLeast(1L)).toFloat(),
onValueChange = {
isDragging = true
dragPositionMs = it.toLong()
},
onValueChangeFinished = {
player.seekTo(dragPositionMs)
currentPositionMs = dragPositionMs
isEnded = false
isDragging = false
},
enabled = durationMs > 0 && !hasError && player.isCurrentMediaItemSeekable,
valueRange = 0f..durationMs.coerceAtLeast(1L).toFloat(),
modifier = Modifier.weight(1f).testTag("media_seek_slider"),
colors =
SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary,
inactiveTrackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
),
)
Text(
text = formatMediaDuration(durationMs),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.width(48.dp),
)
}
}
}
}
}
}
@@ -1,382 +1,17 @@
package com.m57.hermescontrol.ui.chat.components
import android.net.Uri
import android.widget.Toast
import android.widget.VideoView
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.ImageBytesResolver
import com.m57.hermescontrol.ui.chat.MediaImageStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Fullscreen Video Viewer modal dialog with Save & Share (issue #722).
* Backwards-compatible wrapper delegating to [MediaViewerDialog].
*/
@Composable
fun VideoViewerDialog(
videoUri: String,
onDismissRequest: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var isPlaying by remember { mutableStateOf(true) }
var currentPositionMs by remember { mutableLongStateOf(0L) }
var durationMs by remember { mutableLongStateOf(0L) }
var videoViewRef by remember { mutableStateOf<VideoView?>(null) }
var showControls by remember { mutableStateOf(true) }
var isBusy by remember { mutableStateOf(false) }
val savedMsg = stringResource(R.string.image_viewer_saved)
val saveFailedMsg = stringResource(R.string.image_viewer_save_failed)
val loadFailedFmt = stringResource(R.string.image_viewer_load_failed)
val shareTitle = stringResource(R.string.image_viewer_share_title)
val onSave: () -> Unit = {
if (!isBusy) {
isBusy = true
scope.launch(Dispatchers.IO) {
val resolved = ImageBytesResolver.resolve(context, videoUri, "video/mp4")
val result =
when (resolved) {
is ImageBytesResolver.Result.Bytes -> {
val uri =
MediaImageStore.saveToDownloads(
context,
resolved.bytes,
"hermes-video.${resolved.extension}",
resolved.mimeType,
)
if (uri != null) savedMsg else saveFailedMsg
}
is ImageBytesResolver.Result.Error -> {
String.format(loadFailedFmt, resolved.message)
}
}
withContext(Dispatchers.Main) {
isBusy = false
Toast.makeText(context, result, Toast.LENGTH_SHORT).show()
}
}
}
}
val onShare: () -> Unit = {
if (!isBusy) {
isBusy = true
scope.launch(Dispatchers.IO) {
val resolved = ImageBytesResolver.resolve(context, videoUri, "video/mp4")
val intent =
when (resolved) {
is ImageBytesResolver.Result.Bytes -> {
MediaImageStore.buildShareIntent(
context,
resolved.bytes,
"hermes-video.${resolved.extension}",
resolved.mimeType,
)
}
is ImageBytesResolver.Result.Error -> {
null
}
}
withContext(Dispatchers.Main) {
isBusy = false
if (intent != null) {
context.startActivity(android.content.Intent.createChooser(intent, shareTitle))
} else {
Toast.makeText(context, saveFailedMsg, Toast.LENGTH_SHORT).show()
}
}
}
}
}
LaunchedEffect(isPlaying) {
while (isPlaying) {
videoViewRef?.let { view ->
if (view.isPlaying) {
currentPositionMs = view.currentPosition.toLong()
if (durationMs <= 0 && view.duration > 0) {
durationMs = view.duration.toLong()
}
}
}
delay(250)
}
}
DisposableEffect(videoUri) {
onDispose {
videoViewRef?.stopPlayback()
}
}
Dialog(
MediaViewerDialog(
mediaUri = videoUri,
onDismissRequest = onDismissRequest,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
dismissOnBackPress = true,
dismissOnClickOutside = true,
),
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim)
.clickable { showControls = !showControls }
.testTag("video_viewer_dialog"),
) {
AndroidView(
factory = { ctx ->
VideoView(ctx).apply {
val uri = Uri.parse(videoUri)
val headers = mutableMapOf<String, String>()
val cookieVal =
com.m57.hermescontrol.data.remote.CookieManager
.getSessionCookie()
if (!cookieVal.isNullOrBlank()) {
headers["Cookie"] = "hermes_session_at=$cookieVal"
}
val token =
com.m57.hermescontrol.data.local.AuthManager
.getToken()
if (!token.isNullOrBlank()) {
headers["Authorization"] = "Bearer $token"
}
setVideoURI(uri, headers)
setOnPreparedListener { mp ->
durationMs = mp.duration.toLong()
start()
isPlaying = true
}
setOnCompletionListener {
isPlaying = false
currentPositionMs = durationMs
}
}
},
update = { view ->
videoViewRef = view
},
modifier = Modifier.fillMaxSize(),
)
// Center overlay play button when paused
if (!isPlaying) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.65f),
modifier =
Modifier
.align(Alignment.Center)
.size(64.dp)
.clickable {
videoViewRef?.let { view ->
view.start()
isPlaying = true
}
},
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = stringResource(R.string.video_play),
tint = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier.size(40.dp),
)
}
}
}
// Top Bar with Back/Close, Save, Share
AnimatedVisibility(
visible = showControls,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.TopCenter),
) {
Surface(
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.70f),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = onDismissRequest,
modifier = Modifier.testTag("close_video_dialog_button"),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Close",
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
}
Spacer(modifier = Modifier.weight(1f))
if (isBusy) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp).padding(horizontal = 8.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.inverseOnSurface,
)
} else {
IconButton(onClick = onSave, enabled = !isBusy) {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = "Save",
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
}
IconButton(onClick = onShare, enabled = !isBusy) {
Icon(
imageVector = Icons.Filled.Share,
contentDescription = "Share",
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
}
}
}
}
}
// Floating Bottom Controls Bar
AnimatedVisibility(
visible = showControls || !isPlaying,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.BottomCenter).padding(12.dp),
) {
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.80f),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = {
videoViewRef?.let { view ->
if (isPlaying) {
view.pause()
isPlaying = false
} else {
view.start()
isPlaying = true
}
}
},
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
}
Spacer(modifier = Modifier.width(4.dp))
Text(
text = formatMediaDuration(currentPositionMs),
color = MaterialTheme.colorScheme.inverseOnSurface,
fontSize = 12.sp,
)
Slider(
value = if (durationMs > 0) currentPositionMs.toFloat() / durationMs.toFloat() else 0f,
onValueChange = { fraction ->
val targetMs = (fraction * durationMs).toLong()
currentPositionMs = targetMs
videoViewRef?.seekTo(targetMs.toInt())
},
modifier =
Modifier
.weight(1f)
.padding(horizontal = 8.dp),
colors =
SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.inverseOnSurface,
activeTrackColor = MaterialTheme.colorScheme.primary,
inactiveTrackColor = MaterialTheme.colorScheme.inverseOnSurface.copy(alpha = 0.3f),
),
)
Text(
text = formatMediaDuration(durationMs),
color = MaterialTheme.colorScheme.inverseOnSurface,
fontSize = 12.sp,
)
}
}
}
}
}
)
}