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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user