From b708f44670dd0b32c248939e95831c0962df7794 Mon Sep 17 00:00:00 2001 From: b-dev-mobile Date: Mon, 13 Jul 2026 05:53:18 +0000 Subject: [PATCH] =?UTF-8?q?feat(files):=20=D0=BD=D0=B0=D1=82=D0=B8=D0=B2?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BC=D0=B5=D0=B4=D0=B8=D0=B0-=D1=81?= =?UTF-8?q?=D0=BB=D0=BE=D0=B9=20=E2=80=94=20PDF=20(PdfRenderer),=20=D0=B2?= =?UTF-8?q?=D0=B8=D0=B4=D0=B5=D0=BE/=D0=B0=D1=83=D0=B4=D0=B8=D0=BE=20(Medi?= =?UTF-8?q?a3/ExoPlayer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Открытие популярных медиа нативно (директива владельца): OpenableFiles детектит image/video/audio/pdf; openItem скачивает в кэш (WebDAV Basic-auth) → нативный вьюер. PdfViewerActivity (PdfRenderer→страницы в LazyColumn), MediaPlayerActivity (ExoPlayer PlayerView, видео+аудио). +media3-exoplayer/ui, coil-gif в каталоге. --- feature/files/build.gradle | 3 + feature/files/src/main/AndroidManifest.xml | 9 ++ .../f7cloud/feature/files/FileOpenAction.kt | 3 + .../f7cloud/feature/files/FilesScreen.kt | 8 + .../f7cloud/feature/files/FilesViewModel.kt | 21 +++ .../feature/files/MediaPlayerActivity.kt | 121 ++++++++++++++ .../f7cloud/feature/files/OpenableFiles.kt | 29 +++- .../feature/files/PdfViewerActivity.kt | 149 ++++++++++++++++++ gradle/libs.versions.toml | 4 + 9 files changed, 341 insertions(+), 6 deletions(-) create mode 100644 feature/files/src/main/java/ru/forbion/f7cloud/feature/files/MediaPlayerActivity.kt create mode 100644 feature/files/src/main/java/ru/forbion/f7cloud/feature/files/PdfViewerActivity.kt diff --git a/feature/files/build.gradle b/feature/files/build.gradle index 06ccafe..63d6f9f 100644 --- a/feature/files/build.gradle +++ b/feature/files/build.gradle @@ -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 } diff --git a/feature/files/src/main/AndroidManifest.xml b/feature/files/src/main/AndroidManifest.xml index a300260..9ef2b5f 100644 --- a/feature/files/src/main/AndroidManifest.xml +++ b/feature/files/src/main/AndroidManifest.xml @@ -6,6 +6,15 @@ android:name=".ImageViewerActivity" android:exported="false" android:theme="@android:style/Theme.Material.Light.NoActionBar" /> + + { + 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() diff --git a/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesViewModel.kt b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesViewModel.kt index 6d61ddd..4d62634 100644 --- a/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesViewModel.kt +++ b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/FilesViewModel.kt @@ -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) diff --git a/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/MediaPlayerActivity.kt b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/MediaPlayerActivity.kt new file mode 100644 index 0000000..bb47f75 --- /dev/null +++ b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/MediaPlayerActivity.kt @@ -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), + ) + } +} diff --git a/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/OpenableFiles.kt b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/OpenableFiles.kt index 498c12d..3f4ab5a 100644 --- a/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/OpenableFiles.kt +++ b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/OpenableFiles.kt @@ -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 } } diff --git a/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/PdfViewerActivity.kt b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/PdfViewerActivity.kt new file mode 100644 index 0000000..5fa35d1 --- /dev/null +++ b/feature/files/src/main/java/ru/forbion/f7cloud/feature/files/PdfViewerActivity.kt @@ -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(), 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 { + val out = mutableListOf() + 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 +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f9237b4..5f9e27a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }