feat(files): нативный медиа-слой — PDF (PdfRenderer), видео/аудио (Media3/ExoPlayer)

Открытие популярных медиа нативно (директива владельца): OpenableFiles детектит
image/video/audio/pdf; openItem скачивает в кэш (WebDAV Basic-auth) → нативный вьюер.
PdfViewerActivity (PdfRenderer→страницы в LazyColumn), MediaPlayerActivity (ExoPlayer
PlayerView, видео+аудио). +media3-exoplayer/ui, coil-gif в каталоге.
This commit is contained in:
b-dev-mobile
2026-07-13 05:53:18 +00:00
parent 81ce1096f7
commit b708f44670
9 changed files with 341 additions and 6 deletions
+3
View File
@@ -43,4 +43,7 @@ dependencies {
implementation libs.documentfile
implementation libs.coil.compose
implementation libs.coil.svg
implementation libs.coil.gif
implementation libs.media3.exoplayer
implementation libs.media3.ui
}
@@ -6,6 +6,15 @@
android:name=".ImageViewerActivity"
android:exported="false"
android:theme="@android:style/Theme.Material.Light.NoActionBar" />
<activity
android:name=".MediaPlayerActivity"
android:exported="false"
android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@android:style/Theme.Material.NoActionBar" />
<activity
android:name=".PdfViewerActivity"
android:exported="false"
android:theme="@android:style/Theme.Material.NoActionBar" />
<provider
android:name=".F7FileProvider"
@@ -2,5 +2,8 @@ package ru.forbion.f7cloud.feature.files
sealed class FileOpenAction {
data class Image(val path: String, val title: String) : FileOpenAction()
data class Pdf(val path: String, val title: String) : FileOpenAction()
/** Видео/аудио — нативный плеер Media3 (isVideo=false → аудио-режим). */
data class Media(val path: String, val title: String, val isVideo: Boolean) : FileOpenAction()
data class External(val path: String, val mimeType: String, val title: String) : FileOpenAction()
}
@@ -144,6 +144,14 @@ fun FilesScreen(
context.startActivity(ImageViewerActivity.intent(context, action.path, action.title))
vm.clearOpenAction()
}
is FileOpenAction.Pdf -> {
context.startActivity(PdfViewerActivity.intent(context, action.path, action.title))
vm.clearOpenAction()
}
is FileOpenAction.Media -> {
context.startActivity(MediaPlayerActivity.intent(context, action.path, action.title, action.isVideo))
vm.clearOpenAction()
}
is FileOpenAction.External -> {
LocalFileOpener.openExternal(context, File(action.path), action.mimeType)
vm.clearOpenAction()
@@ -491,6 +491,9 @@ class FilesViewModel(
WebOpenableFiles.isWebOpenable(item.name) -> openWebFile(session, item)
ArchiveFiles.isArchive(item.name) -> openArchiveFile(session, item)
OpenableFiles.isImage(item.name) -> openImageFile(session, item)
OpenableFiles.isPdf(item.name) -> openMediaFile(session, item) { p -> FileOpenAction.Pdf(p, item.name) }
OpenableFiles.isVideo(item.name) -> openMediaFile(session, item) { p -> FileOpenAction.Media(p, item.name, isVideo = true) }
OpenableFiles.isAudio(item.name) -> openMediaFile(session, item) { p -> FileOpenAction.Media(p, item.name, isVideo = false) }
else -> _state.value = _state.value.copy(
error = "Этот тип файла пока не поддерживается",
)
@@ -573,6 +576,24 @@ class FilesViewModel(
}
}
/** Медиа (PDF/видео/аудио) — скачиваем в кэш (WebDAV Basic-auth) и открываем нативным вьюером. */
private fun openMediaFile(session: AuthSession, item: FileItem, toAction: (String) -> FileOpenAction) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(openingFile = item.name, error = null)
runCatching { downloadRepository.download(session, item.relativePath, item.name) }
.onSuccess { file ->
_state.value = _state.value.copy(openingFile = null, openAction = toAction(file.absolutePath))
}
.onFailure { t ->
_state.value = _state.value.copy(
openingFile = null,
error = t.message ?: "Не удалось открыть файл",
unauthorized = t is UnauthorizedException,
)
}
}
}
private fun openImageFile(session: AuthSession, item: FileItem) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(openingFile = item.name, error = null)
@@ -0,0 +1,121 @@
package ru.forbion.f7cloud.feature.files
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import ru.forbion.f7cloud.core.designsystem.F7Theme
import java.io.File
/** Нативный проигрыватель видео/аудио (Media3/ExoPlayer). Файл уже скачан в кэш. */
class MediaPlayerActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val path = intent.getStringExtra(EXTRA_PATH).orEmpty()
val title = intent.getStringExtra(EXTRA_TITLE).orEmpty()
val isVideo = intent.getBooleanExtra(EXTRA_IS_VIDEO, true)
if (path.isBlank()) {
finish()
return
}
setContent {
F7Theme {
MediaPlayerScreen(path = path, title = title, isVideo = isVideo, onClose = { finish() })
}
}
}
companion object {
private const val EXTRA_PATH = "path"
private const val EXTRA_TITLE = "title"
private const val EXTRA_IS_VIDEO = "is_video"
fun intent(context: Context, path: String, title: String, isVideo: Boolean): Intent =
Intent(context, MediaPlayerActivity::class.java).apply {
putExtra(EXTRA_PATH, path)
putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_IS_VIDEO, isVideo)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MediaPlayerScreen(
path: String,
title: String,
isVideo: Boolean,
onClose: () -> Unit,
) {
val context = LocalContext.current
val player = remember {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(Uri.fromFile(File(path))))
prepare()
playWhenReady = true
}
}
DisposableEffect(Unit) {
onDispose { player.release() }
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
this.player = player
useController = true
setShowNextButton(false)
setShowPreviousButton(false)
// Аудио: PlayerView без видео-поверхности показывает только контролы.
}
},
modifier = Modifier.fillMaxSize(),
)
TopAppBar(
title = {
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.White,
style = MaterialTheme.typography.titleMedium,
)
},
navigationIcon = {
IconButton(onClick = onClose) {
Text("", color = Color.White, modifier = Modifier.padding(8.dp))
}
},
modifier = Modifier.align(Alignment.TopCenter),
)
}
}
@@ -4,23 +4,40 @@ import java.util.Locale
enum class OpenableKind {
IMAGE,
VIDEO,
AUDIO,
PDF,
}
object OpenableFiles {
private val IMAGE_EXT = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "heic", "heif")
private val IMAGE_EXT = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "heic", "heif", "svg")
private val VIDEO_EXT = setOf("mp4", "webm", "mkv", "mov", "3gp", "m4v", "avi", "ts")
private val AUDIO_EXT = setOf("mp3", "aac", "ogg", "oga", "opus", "wav", "flac", "m4a", "weba")
private val PDF_EXT = setOf("pdf")
fun kind(name: String): OpenableKind? {
val ext = name.lowercase(Locale.ROOT).substringAfterLast('.', missingDelimiterValue = "")
return if (ext in IMAGE_EXT) OpenableKind.IMAGE else null
return when (ext) {
in IMAGE_EXT -> OpenableKind.IMAGE
in VIDEO_EXT -> OpenableKind.VIDEO
in AUDIO_EXT -> OpenableKind.AUDIO
in PDF_EXT -> OpenableKind.PDF
else -> null
}
}
fun isOpenable(name: String): Boolean = kind(name) != null || ArchiveFiles.isArchive(name)
fun isImage(name: String): Boolean = kind(name) == OpenableKind.IMAGE
fun isVideo(name: String): Boolean = kind(name) == OpenableKind.VIDEO
fun isAudio(name: String): Boolean = kind(name) == OpenableKind.AUDIO
fun isPdf(name: String): Boolean = kind(name) == OpenableKind.PDF
fun hint(name: String): String? = when {
isImage(name) -> "Просмотр"
ArchiveFiles.isArchive(name) -> "Открыть с помощью…"
else -> null
fun hint(name: String): String? = when (kind(name)) {
OpenableKind.IMAGE -> "Просмотр"
OpenableKind.VIDEO -> "Видео"
OpenableKind.AUDIO -> "Аудио"
OpenableKind.PDF -> "PDF"
null -> if (ArchiveFiles.isArchive(name)) "Открыть с помощью…" else null
}
}
@@ -0,0 +1,149 @@
package ru.forbion.f7cloud.feature.files
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color as AndroidColor
import android.graphics.pdf.PdfRenderer
import android.os.Bundle
import android.os.ParcelFileDescriptor
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.forbion.f7cloud.core.designsystem.F7Theme
import java.io.File
/** Нативный просмотр PDF через android.graphics.pdf.PdfRenderer (без внешних зависимостей). */
class PdfViewerActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val path = intent.getStringExtra(EXTRA_PATH).orEmpty()
val title = intent.getStringExtra(EXTRA_TITLE).orEmpty()
if (path.isBlank() || !File(path).exists()) {
finish()
return
}
setContent {
F7Theme {
PdfViewerScreen(path = path, title = title, onClose = { finish() })
}
}
}
companion object {
private const val EXTRA_PATH = "path"
private const val EXTRA_TITLE = "title"
fun intent(context: Context, path: String, title: String): Intent =
Intent(context, PdfViewerActivity::class.java).apply {
putExtra(EXTRA_PATH, path)
putExtra(EXTRA_TITLE, title)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PdfViewerScreen(
path: String,
title: String,
onClose: () -> Unit,
) {
val pages by produceState(initialValue = emptyList<Bitmap>(), path) {
value = withContext(Dispatchers.IO) { renderPdf(File(path)) }
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF303030)),
) {
if (pages.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center), color = Color.White)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = 64.dp, bottom = 16.dp, start = 8.dp, end = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(pages) { bmp ->
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxWidth().background(Color.White),
contentScale = ContentScale.FillWidth,
)
}
}
}
TopAppBar(
title = {
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.White,
style = MaterialTheme.typography.titleMedium,
)
},
navigationIcon = {
IconButton(onClick = onClose) {
Text("", color = Color.White, modifier = Modifier.padding(8.dp))
}
},
modifier = Modifier.align(Alignment.TopCenter),
)
}
}
/** Рендер всех страниц PDF в bitmap'ы (ширина ~1080px, высота по соотношению сторон). */
private fun renderPdf(file: File): List<Bitmap> {
val out = mutableListOf<Bitmap>()
runCatching {
ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd ->
PdfRenderer(pfd).use { renderer ->
val targetWidth = 1080
for (i in 0 until renderer.pageCount) {
renderer.openPage(i).use { page ->
val scale = targetWidth.toFloat() / page.width
val height = (page.height * scale).toInt().coerceAtLeast(1)
val bmp = Bitmap.createBitmap(targetWidth, height, Bitmap.Config.ARGB_8888)
bmp.eraseColor(AndroidColor.WHITE)
page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
out += bmp
}
}
}
}
}
return out
}
+4
View File
@@ -7,6 +7,7 @@ composeBom = "2025.02.00"
coreKtx = "1.15.0" # был дрейф 1.13.1 / 1.15.0
lifecycle = "2.8.7" # был дрейф 2.8.1 / 2.8.7
coil = "2.7.0" # был дрейф 2.6.0 / 2.7.0
media3 = "1.4.1" # нативный медиа-слой (видео/аудио) — ExoPlayer
coroutines = "1.10.1" # был дрейф 1.8.1 / 1.10.1
navigationCompose = "2.8.9"
room = "2.7.2"
@@ -38,6 +39,9 @@ lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-ru
lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
coil-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" }
coil-gif = { group = "io.coil-kt", name = "coil-gif", version.ref = "coil" }
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutines" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }