Merge branch 'develop' into langleyd/custom_waveform
This commit is contained in:
commit
eca7d705a7
10 changed files with 562 additions and 454 deletions
|
|
@ -1,123 +0,0 @@
|
|||
/*
|
||||
* 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.voicemessages.timeline
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.CacheDirectory
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Manages the local disk cache for a voice message.
|
||||
*/
|
||||
interface VoiceMessageCache {
|
||||
|
||||
/**
|
||||
* Factory for [VoiceMessageCache].
|
||||
*/
|
||||
fun interface Factory {
|
||||
/**
|
||||
* Creates a [VoiceMessageCache] for the given Matrix Content (mxc://) URI.
|
||||
*
|
||||
* @param mxcUri the Matrix Content (mxc://) URI of the voice message.
|
||||
*/
|
||||
fun create(mxcUri: String): VoiceMessageCache
|
||||
}
|
||||
|
||||
/**
|
||||
* The file path of the voice message in the cache directory.
|
||||
* NB: This doesn't necessarily mean that the file exists.
|
||||
*
|
||||
* @return the file path of the voice message in the cache directory.
|
||||
*/
|
||||
val cachePath: String
|
||||
|
||||
/**
|
||||
* Checks if the voice message is in the cache directory.
|
||||
*
|
||||
* @return true if the voice message is in the cache directory.
|
||||
*/
|
||||
fun isInCache(): Boolean
|
||||
|
||||
/**
|
||||
* Moves the file to the voice cache directory.
|
||||
*
|
||||
* @return true if the file was successfully moved.
|
||||
*/
|
||||
fun moveToCache(file: File): Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation of [VoiceMessageCache].
|
||||
*
|
||||
* NB: All methods will throw an [IllegalStateException] if the mxcUri is invalid.
|
||||
*
|
||||
* @param cacheDir the application's cache directory.
|
||||
* @param mxcUri the Matrix Content (mxc://) URI of the voice message.
|
||||
*/
|
||||
class VoiceMessageCacheImpl @AssistedInject constructor(
|
||||
@CacheDirectory private val cacheDir: File,
|
||||
@Assisted private val mxcUri: String,
|
||||
) : VoiceMessageCache {
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
@AssistedFactory
|
||||
fun interface Factory : VoiceMessageCache.Factory {
|
||||
override fun create(mxcUri: String): VoiceMessageCacheImpl
|
||||
}
|
||||
|
||||
override val cachePath: String = "${cacheDir.path}/$CACHE_VOICE_SUBDIR/${mxcUri2FilePath(mxcUri)}"
|
||||
|
||||
override fun isInCache(): Boolean = File(cachePath).exists()
|
||||
|
||||
override fun moveToCache(file: File): Boolean {
|
||||
val dest = File(cachePath).apply { parentFile?.mkdirs() }
|
||||
return file.renameTo(dest)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subdirectory of the application's cache directory where voice messages are stored.
|
||||
*/
|
||||
private const val CACHE_VOICE_SUBDIR = "temp/voice"
|
||||
|
||||
/**
|
||||
* Regex to match a Matrix Content (mxc://) URI.
|
||||
*
|
||||
* See: https://spec.matrix.org/v1.8/client-server-api/#matrix-content-mxc-uris
|
||||
*/
|
||||
private val mxcRegex = Regex("""^mxc:\/\/([^\/]+)\/([^\/]+)$""")
|
||||
|
||||
/**
|
||||
* Sanitizes an mxcUri to be used as a relative file path.
|
||||
*
|
||||
* @param mxcUri the Matrix Content (mxc://) URI of the voice message.
|
||||
* @return the relative file path as "<server-name>/<media-id>".
|
||||
* @throws IllegalStateException if the mxcUri is invalid.
|
||||
*/
|
||||
private fun mxcUri2FilePath(mxcUri: String): String = checkNotNull(mxcRegex.matchEntire(mxcUri)) {
|
||||
"mxcUri2FilePath: Invalid mxcUri: $mxcUri"
|
||||
}.let { match ->
|
||||
buildString {
|
||||
append(match.groupValues[1])
|
||||
append("/")
|
||||
append(match.groupValues[2])
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
/*
|
||||
* 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.voicemessages.timeline
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.libraries.di.CacheDirectory
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.media.toFile
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Fetches the media file for a voice message.
|
||||
*
|
||||
* Media is downloaded from the rust sdk and stored in the application's cache directory.
|
||||
* Media files are indexed by their Matrix Content (mxc://) URI and considered immutable.
|
||||
* Whenever a given mxc is found in the cache, it is returned immediately.
|
||||
*/
|
||||
interface VoiceMessageMediaRepo {
|
||||
|
||||
/**
|
||||
* Factory for [VoiceMessageMediaRepo].
|
||||
*/
|
||||
fun interface Factory {
|
||||
/**
|
||||
* Creates a [VoiceMessageMediaRepo].
|
||||
*
|
||||
* @param mediaSource the media source of the voice message.
|
||||
* @param mimeType the mime type of the voice message.
|
||||
* @param body the body of the voice message.
|
||||
*/
|
||||
fun create(
|
||||
mediaSource: MediaSource,
|
||||
mimeType: String?,
|
||||
body: String?,
|
||||
): VoiceMessageMediaRepo
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the voice message media file.
|
||||
*
|
||||
* In case of a cache hit the file is returned immediately.
|
||||
* In case of a cache miss the file is downloaded and then returned.
|
||||
*
|
||||
* @return A [Result] holding either the media [File] from the cache directory or an [Exception].
|
||||
*/
|
||||
suspend fun getMediaFile(): Result<File>
|
||||
}
|
||||
|
||||
class DefaultVoiceMessageMediaRepo @AssistedInject constructor(
|
||||
@CacheDirectory private val cacheDir: File,
|
||||
private val matrixMediaLoader: MatrixMediaLoader,
|
||||
@Assisted private val mediaSource: MediaSource,
|
||||
@Assisted("mimeType") private val mimeType: String?,
|
||||
@Assisted("body") private val body: String?,
|
||||
) : VoiceMessageMediaRepo {
|
||||
|
||||
@ContributesBinding(RoomScope::class)
|
||||
@AssistedFactory
|
||||
fun interface Factory : VoiceMessageMediaRepo.Factory {
|
||||
override fun create(
|
||||
mediaSource: MediaSource,
|
||||
@Assisted("mimeType") mimeType: String?,
|
||||
@Assisted("body") body: String?,
|
||||
): DefaultVoiceMessageMediaRepo
|
||||
}
|
||||
|
||||
override suspend fun getMediaFile(): Result<File> = if (!isInCache()) {
|
||||
matrixMediaLoader.downloadMediaFile(
|
||||
source = mediaSource,
|
||||
mimeType = mimeType,
|
||||
body = body,
|
||||
).mapCatching {
|
||||
val dest = cachedFilePath.apply { parentFile?.mkdirs() }
|
||||
// TODO By not closing the MediaFile we're leaking the rust file handle here.
|
||||
// Not that big of a deal but better to avoid it someday.
|
||||
if (it.toFile().renameTo(dest)) {
|
||||
dest
|
||||
} else {
|
||||
error("Failed to move file to cache.")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Result.success(cachedFilePath)
|
||||
}
|
||||
|
||||
private val cachedFilePath: File = File("${cacheDir.path}/$CACHE_VOICE_SUBDIR/${mxcUri2FilePath(mediaSource.url)}")
|
||||
|
||||
private fun isInCache(): Boolean = cachedFilePath.exists()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subdirectory of the application's cache directory where voice messages are stored.
|
||||
*/
|
||||
private const val CACHE_VOICE_SUBDIR = "temp/voice"
|
||||
|
||||
/**
|
||||
* Regex to match a Matrix Content (mxc://) URI.
|
||||
*
|
||||
* See: https://spec.matrix.org/v1.8/client-server-api/#matrix-content-mxc-uris
|
||||
*/
|
||||
private val mxcRegex = Regex("""^mxc:\/\/([^\/]+)\/([^\/]+)$""")
|
||||
|
||||
/**
|
||||
* Sanitizes an mxcUri to be used as a relative file path.
|
||||
*
|
||||
* @param mxcUri the Matrix Content (mxc://) URI of the voice message.
|
||||
* @return the relative file path as "<server-name>/<media-id>".
|
||||
* @throws IllegalStateException if the mxcUri is invalid.
|
||||
*/
|
||||
private fun mxcUri2FilePath(mxcUri: String): String = checkNotNull(mxcRegex.matchEntire(mxcUri)) {
|
||||
"mxcUri2FilePath: Invalid mxcUri: $mxcUri"
|
||||
}.let { match ->
|
||||
buildString {
|
||||
append(match.groupValues[1])
|
||||
append("/")
|
||||
append(match.groupValues[2])
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import com.squareup.anvil.annotations.ContributesBinding
|
|||
import io.element.android.features.messages.impl.mediaplayer.MediaPlayer
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
|
@ -37,14 +38,20 @@ interface VoiceMessagePlayer {
|
|||
*
|
||||
* NB: Different voice messages can use the same content uri (e.g. in case of
|
||||
* a forward of a voice message),
|
||||
* therefore the media uri is not enough to uniquely identify a voice message.
|
||||
* This is why we must provide the eventId as well.
|
||||
* therefore the mxc:// uri in [mediaSource] is not enough to uniquely identify
|
||||
* a voice message. This is why we must provide the eventId as well.
|
||||
*
|
||||
* @param eventId The id of the voice message event. If null, a dummy
|
||||
* player is returned.
|
||||
* @param mediaPath The path to the voice message's media file.
|
||||
* @param eventId The eventId of the voice message event.
|
||||
* @param mediaSource The media source of the voice message.
|
||||
* @param mimeType The mime type of the voice message.
|
||||
* @param body The body of the voice message.
|
||||
*/
|
||||
fun create(eventId: EventId?, mediaPath: String): VoiceMessagePlayer
|
||||
fun create(
|
||||
eventId: EventId?,
|
||||
mediaSource: MediaSource,
|
||||
mimeType: String?,
|
||||
body: String?,
|
||||
): VoiceMessagePlayer
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -53,15 +60,14 @@ interface VoiceMessagePlayer {
|
|||
val state: Flow<State>
|
||||
|
||||
/**
|
||||
* Start playing from the beginning acquiring control of the
|
||||
* underlying [MediaPlayer].
|
||||
* Starts playing from the beginning
|
||||
* acquiring control of the underlying [MediaPlayer].
|
||||
* If already in control of the underlying [MediaPlayer], starts playing from the
|
||||
* current position.
|
||||
*
|
||||
* Will suspend whilst the media file is being downloaded.
|
||||
*/
|
||||
fun acquireControlAndPlay()
|
||||
|
||||
/**
|
||||
* Start playing from the current position.
|
||||
*/
|
||||
fun play()
|
||||
suspend fun play(): Result<Unit>
|
||||
|
||||
/**
|
||||
* Pause playback.
|
||||
|
|
@ -92,32 +98,45 @@ interface VoiceMessagePlayer {
|
|||
}
|
||||
|
||||
/**
|
||||
* An implementation of [VoiceMessagePlayer] which is backed by a [MediaPlayer]
|
||||
* usually shared among different [VoiceMessagePlayer] instances.
|
||||
*
|
||||
* @param mediaPlayer The [MediaPlayer] to use.
|
||||
* @param eventId The id of the voice message event. If null, the player will behave as no-op.
|
||||
* @param mediaPath The path to the voice message's media file.
|
||||
* An implementation of [VoiceMessagePlayer] which is backed by a
|
||||
* [VoiceMessageMediaRepo] to fetch and cache the media file and
|
||||
* which uses a global [MediaPlayer] instance to play the media.
|
||||
*/
|
||||
class VoiceMessagePlayerImpl(
|
||||
class DefaultVoiceMessagePlayer(
|
||||
private val mediaPlayer: MediaPlayer,
|
||||
voiceMessageMediaRepoFactory: VoiceMessageMediaRepo.Factory,
|
||||
private val eventId: EventId?,
|
||||
private val mediaPath: String,
|
||||
mediaSource: MediaSource,
|
||||
mimeType: String?,
|
||||
body: String?,
|
||||
) : VoiceMessagePlayer {
|
||||
|
||||
@ContributesBinding(RoomScope::class) // Scoped types can't use @AssistedInject.
|
||||
class Factory @Inject constructor(
|
||||
private val mediaPlayer: MediaPlayer,
|
||||
private val voiceMessageMediaRepoFactory: VoiceMessageMediaRepo.Factory,
|
||||
) : VoiceMessagePlayer.Factory {
|
||||
override fun create(eventId: EventId?, mediaPath: String): VoiceMessagePlayerImpl {
|
||||
return VoiceMessagePlayerImpl(
|
||||
mediaPlayer = mediaPlayer,
|
||||
eventId = eventId,
|
||||
mediaPath = mediaPath,
|
||||
)
|
||||
}
|
||||
override fun create(
|
||||
eventId: EventId?,
|
||||
mediaSource: MediaSource,
|
||||
mimeType: String?,
|
||||
body: String?,
|
||||
): DefaultVoiceMessagePlayer = DefaultVoiceMessagePlayer(
|
||||
mediaPlayer = mediaPlayer,
|
||||
voiceMessageMediaRepoFactory = voiceMessageMediaRepoFactory,
|
||||
eventId = eventId,
|
||||
mediaSource = mediaSource,
|
||||
mimeType = mimeType,
|
||||
body = body,
|
||||
)
|
||||
}
|
||||
|
||||
private val repo = voiceMessageMediaRepoFactory.create(
|
||||
mediaSource = mediaSource,
|
||||
mimeType = mimeType,
|
||||
body = body
|
||||
)
|
||||
|
||||
override val state: Flow<VoiceMessagePlayer.State> = mediaPlayer.state.map { state ->
|
||||
VoiceMessagePlayer.State(
|
||||
isPlaying = state.mediaId.isMyTrack() && state.isPlaying,
|
||||
|
|
@ -126,19 +145,20 @@ class VoiceMessagePlayerImpl(
|
|||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
override fun acquireControlAndPlay() {
|
||||
eventId?.let { eventId ->
|
||||
mediaPlayer.acquireControlAndPlay(
|
||||
uri = mediaPath,
|
||||
mediaId = eventId.value,
|
||||
mimeType = "audio/ogg" // Files in the voice cache have no extension so we need to set the mime type manually.
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun play() {
|
||||
ifInControl {
|
||||
mediaPlayer.play()
|
||||
override suspend fun play(): Result<Unit> = if (inControl()) {
|
||||
mediaPlayer.play()
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
if (eventId != null) {
|
||||
repo.getMediaFile().mapCatching { mediaFile ->
|
||||
mediaPlayer.acquireControlAndPlay(
|
||||
uri = mediaFile.path,
|
||||
mediaId = eventId.value,
|
||||
mimeType = "audio/ogg" // Files in the voice cache have no extension so we need to set the mime type manually.
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Result.failure(IllegalStateException("Cannot play a voice message with no eventId"))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -157,6 +177,8 @@ class VoiceMessagePlayerImpl(
|
|||
private fun String?.isMyTrack(): Boolean = if (eventId == null) false else this == eventId.value
|
||||
|
||||
private inline fun ifInControl(block: () -> Unit) {
|
||||
if (mediaPlayer.state.value.mediaId.isMyTrack()) block()
|
||||
if (inControl()) block()
|
||||
}
|
||||
|
||||
private fun inControl(): Boolean = mediaPlayer.state.value.mediaId.isMyTrack()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,9 +37,6 @@ import io.element.android.libraries.architecture.Async
|
|||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runUpdatingState
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.api.media.MediaFile
|
||||
import io.element.android.libraries.matrix.api.media.toFile
|
||||
import io.element.android.libraries.ui.utils.time.formatShort
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
|
@ -54,9 +51,7 @@ interface VoiceMessagePresenterModule {
|
|||
}
|
||||
|
||||
class VoiceMessagePresenter @AssistedInject constructor(
|
||||
private val mediaLoader: MatrixMediaLoader,
|
||||
voiceMessagePlayerFactory: VoiceMessagePlayer.Factory,
|
||||
voiceMessageCacheFactory: VoiceMessageCache.Factory,
|
||||
@Assisted private val content: TimelineItemVoiceContent,
|
||||
) : Presenter<VoiceMessageState> {
|
||||
|
||||
|
|
@ -65,11 +60,11 @@ class VoiceMessagePresenter @AssistedInject constructor(
|
|||
override fun create(content: TimelineItemVoiceContent): VoiceMessagePresenter
|
||||
}
|
||||
|
||||
private val voiceCache = voiceMessageCacheFactory.create(mxcUri = content.mediaSource.url)
|
||||
|
||||
private val player = voiceMessagePlayerFactory.create(
|
||||
eventId = content.eventId,
|
||||
mediaPath = voiceCache.cachePath
|
||||
mediaSource = content.mediaSource,
|
||||
mimeType = content.mimeType,
|
||||
body = content.body,
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
|
@ -78,15 +73,15 @@ class VoiceMessagePresenter @AssistedInject constructor(
|
|||
val scope = rememberCoroutineScope()
|
||||
|
||||
val playerState by player.state.collectAsState(VoiceMessagePlayer.State(isPlaying = false, isMyMedia = false, currentPosition = 0L))
|
||||
val mediaFile = remember { mutableStateOf<Async<MediaFile>>(Async.Uninitialized) }
|
||||
val play = remember { mutableStateOf<Async<Unit>>(Async.Uninitialized) }
|
||||
|
||||
val button by remember {
|
||||
derivedStateOf {
|
||||
when {
|
||||
content.eventId == null -> VoiceMessageState.Button.Disabled
|
||||
playerState.isPlaying -> VoiceMessageState.Button.Pause
|
||||
mediaFile.value is Async.Loading -> VoiceMessageState.Button.Downloading
|
||||
mediaFile.value is Async.Failure -> VoiceMessageState.Button.Retry
|
||||
play.value is Async.Loading -> VoiceMessageState.Button.Downloading
|
||||
play.value is Async.Failure -> VoiceMessageState.Button.Retry
|
||||
else -> VoiceMessageState.Button.Play
|
||||
}
|
||||
}
|
||||
|
|
@ -101,38 +96,13 @@ class VoiceMessagePresenter @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun downloadCacheAndPlay() {
|
||||
mediaFile.runUpdatingState {
|
||||
mediaLoader.downloadMediaFile(
|
||||
source = content.mediaSource,
|
||||
mimeType = content.mimeType,
|
||||
body = content.body,
|
||||
).mapCatching {
|
||||
if (voiceCache.moveToCache(it.toFile())) {
|
||||
player.acquireControlAndPlay()
|
||||
it
|
||||
} else {
|
||||
error("Failed to move file to cache.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun eventSink(event: VoiceMessageEvents) {
|
||||
when (event) {
|
||||
is VoiceMessageEvents.PlayPause -> {
|
||||
if (playerState.isMyMedia) {
|
||||
if (playerState.isPlaying) {
|
||||
player.pause()
|
||||
} else {
|
||||
player.play()
|
||||
}
|
||||
if (playerState.isPlaying) {
|
||||
player.pause()
|
||||
} else {
|
||||
if (voiceCache.isInCache()) {
|
||||
player.acquireControlAndPlay()
|
||||
} else {
|
||||
scope.launch { downloadCacheAndPlay() }
|
||||
}
|
||||
scope.launch { play.runUpdatingState { player.play() } }
|
||||
}
|
||||
}
|
||||
is VoiceMessageEvents.Seek -> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue