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" }