diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt index 5f9717e53d..142fb57102 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt @@ -20,36 +20,15 @@ import android.annotation.SuppressLint import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout 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.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -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.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.media3.common.MediaItem @@ -58,22 +37,18 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import io.element.android.features.messages.impl.media.local.exoplayer.ExoPlayerWrapper -import io.element.android.features.messages.impl.media.local.pdf.ParcelFileDescriptorFactory -import io.element.android.features.messages.impl.media.local.pdf.PdfPage -import io.element.android.features.messages.impl.media.local.pdf.PdfRendererManager +import io.element.android.features.messages.impl.media.local.pdf.PdfViewer +import io.element.android.features.messages.impl.media.local.pdf.rememberPdfViewerState import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo import io.element.android.libraries.designsystem.R import io.element.android.libraries.designsystem.utils.OnLifecycleEvent -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.ZoomableState import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage import me.saket.telephoto.zoomable.rememberZoomableImageState import me.saket.telephoto.zoomable.rememberZoomableState -import me.saket.telephoto.zoomable.zoomable @SuppressLint("UnsafeOptInUsageError") @Composable @@ -84,7 +59,7 @@ fun LocalMediaView( onReady: () -> Unit = {}, ) { val zoomableState = rememberZoomableState( - zoomSpec = ZoomSpec(maxZoomFactor = 3f) + zoomSpec = ZoomSpec(maxZoomFactor = 5f) ) when { mimeType.isMimeTypeImage() -> MediaImageView( @@ -98,14 +73,12 @@ fun LocalMediaView( onReady = onReady, modifier = modifier ) - mimeType == MimeTypes.Pdf -> { - MediaPDFView( - localMedia = localMedia, - zoomableState = zoomableState, - onReady = onReady, - modifier = modifier - ) - } + mimeType == MimeTypes.Pdf -> MediaPDFView( + localMedia = localMedia, + zoomableState = zoomableState, + onReady = onReady, + modifier = modifier + ) else -> Unit } } @@ -195,7 +168,6 @@ fun MediaVideoView( } } -@UnstableApi @Composable fun MediaPDFView( localMedia: LocalMedia?, @@ -203,89 +175,14 @@ fun MediaPDFView( onReady: () -> Unit, modifier: Modifier = Modifier, ) { - BoxWithConstraints( - modifier = modifier.zoomable(zoomableState), - contentAlignment = Alignment.Center - ) { - val maxWidth = this.maxWidth.dpToPx() - val lazyListState = rememberLazyListState() - val context = LocalContext.current - val coroutineScope = rememberCoroutineScope() - var pdfRendererManager by remember { - mutableStateOf(null) - } - DisposableEffect(localMedia) { - ParcelFileDescriptorFactory(context).create(localMedia?.model) - .onSuccess { - pdfRendererManager = PdfRendererManager(it, maxWidth, coroutineScope).apply { - open() - } - onReady() - } - onDispose { - pdfRendererManager?.close() - } - } - pdfRendererManager?.run { - val pdfPages = pdfPages.collectAsState().value - PdfPagesView(pdfPages.toImmutableList(), lazyListState) + val pdfViewerState = rememberPdfViewerState( + model = localMedia?.model, + zoomableState = zoomableState + ) + LaunchedEffect(pdfViewerState.isLoaded) { + if (pdfViewerState.isLoaded) { + onReady() } } + PdfViewer(pdfViewerState = pdfViewerState, modifier = modifier) } - -@Composable -private fun PdfPagesView( - pdfPages: ImmutableList, - lazyListState: LazyListState, - modifier: Modifier = Modifier, -) { - LazyColumn( - modifier = modifier.fillMaxSize(), - state = lazyListState, - verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically) - - ) { - items(pdfPages.size) { index -> - val pdfPage = pdfPages[index] - PdfPageView(pdfPage) - } - } -} - -@Composable -private fun PdfPageView( - pdfPage: PdfPage, - modifier: Modifier = Modifier, -) { - val pdfPageState by pdfPage.stateFlow.collectAsState() - DisposableEffect(pdfPage) { - pdfPage.load() - onDispose { - pdfPage.close() - } - } - when (val state = pdfPageState) { - is PdfPage.State.Loaded -> { - Image( - bitmap = state.bitmap.asImageBitmap(), - contentDescription = "Page ${pdfPage.pageIndex}", - contentScale = ContentScale.FillWidth, - modifier = modifier.fillMaxWidth() - ) - } - is PdfPage.State.Loading -> { - Box( - modifier = modifier - .fillMaxWidth() - .height(state.height.pxToDp()) - .background(color = Color.White) - ) - } - } -} - -@Composable -private fun Int.pxToDp() = with(LocalDensity.current) { this@pxToDp.toDp() } - -@Composable -private fun Dp.dpToPx() = with(LocalDensity.current) { this@dpToPx.roundToPx() } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfViewer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfViewer.kt new file mode 100644 index 0000000000..839bf9e622 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfViewer.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.media.local.pdf + +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.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import me.saket.telephoto.zoomable.zoomable + +@Composable +fun PdfViewer( + pdfViewerState: PdfViewerState, + modifier: Modifier = Modifier, +) { + BoxWithConstraints( + modifier = modifier.zoomable(pdfViewerState.zoomableState), + contentAlignment = Alignment.Center + ) { + val maxWidthInPx = maxWidth.dpToPx() + DisposableEffect(pdfViewerState) { + pdfViewerState.openForWidth(maxWidthInPx) + onDispose { + pdfViewerState.close() + } + } + val pdfPages = pdfViewerState.getPages() + PdfPagesView(pdfPages.toImmutableList(), pdfViewerState.lazyListState) + } +} + +@Composable +private fun PdfPagesView( + pdfPages: ImmutableList, + lazyListState: LazyListState, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically) + + ) { + items(pdfPages.size) { index -> + val pdfPage = pdfPages[index] + PdfPageView(pdfPage) + } + } +} + +@Composable +private fun PdfPageView( + pdfPage: PdfPage, + modifier: Modifier = Modifier, +) { + val pdfPageState by pdfPage.stateFlow.collectAsState() + DisposableEffect(pdfPage) { + pdfPage.load() + onDispose { + pdfPage.close() + } + } + when (val state = pdfPageState) { + is PdfPage.State.Loaded -> { + Image( + bitmap = state.bitmap.asImageBitmap(), + contentDescription = "Page ${pdfPage.pageIndex}", + contentScale = ContentScale.FillWidth, + modifier = modifier.fillMaxWidth() + ) + } + is PdfPage.State.Loading -> { + Box( + modifier = modifier + .fillMaxWidth() + .height(state.height.pxToDp()) + .background(color = Color.White) + ) + } + } +} + +@Composable +private fun Int.pxToDp() = with(LocalDensity.current) { this@pxToDp.toDp() } + +@Composable +private fun Dp.dpToPx() = with(LocalDensity.current) { this@dpToPx.roundToPx() } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfViewerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfViewerState.kt new file mode 100644 index 0000000000..f64374d478 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfViewerState.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.media.local.pdf + +import android.content.Context +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import kotlinx.coroutines.CoroutineScope +import me.saket.telephoto.zoomable.ZoomableState +import me.saket.telephoto.zoomable.rememberZoomableState + +@Stable +class PdfViewerState( + private val model: Any?, + private val coroutineScope: CoroutineScope, + private val context: Context, + val zoomableState: ZoomableState, + val lazyListState: LazyListState, +) { + + var isLoaded by mutableStateOf(false) + private var pdfRendererManager by mutableStateOf(null) + + @Composable + fun getPages(): List{ + return pdfRendererManager?.run { + pdfPages.collectAsState().value + }?: emptyList() + } + + fun openForWidth(maxWidth: Int) { + ParcelFileDescriptorFactory(context).create(model) + .onSuccess { + pdfRendererManager = PdfRendererManager(it, maxWidth, coroutineScope).apply { + open() + } + isLoaded = true + } + } + + fun close() { + pdfRendererManager?.close() + isLoaded = false + } +} + +@Composable +fun rememberPdfViewerState( + model: Any?, + zoomableState: ZoomableState = rememberZoomableState(), + lazyListState: LazyListState = rememberLazyListState(), + context: Context = LocalContext.current, + coroutineScope: CoroutineScope = rememberCoroutineScope(), +): PdfViewerState { + return remember(model) { + PdfViewerState( + model = model, + coroutineScope = coroutineScope, + context = context, + zoomableState = zoomableState, + lazyListState = lazyListState + ) + } +}