feat: unify audio and video attachment playback (#1188)
This commit is contained in:
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user