From 376af91e88f9860bf0492fffba2bea7fcc300c68 Mon Sep 17 00:00:00 2001 From: Sylvain Berfini Date: Fri, 17 May 2024 11:20:03 +0200 Subject: [PATCH] Reworked file viewer interface, improved video/audio player --- .../ui/file_viewer/FileViewerActivity.kt | 10 +- .../ui/file_viewer/MediaViewerActivity.kt | 3 +- .../fragment/MediaViewerFragment.kt | 84 +++++++-------- .../ui/file_viewer/viewmodel/FileViewModel.kt | 11 +- .../viewmodel/MediaListViewModel.kt | 2 + .../file_viewer/viewmodel/MediaViewModel.kt | 100 +++++++++++++----- .../linphone/ui/main/chat/model/FileModel.kt | 1 + .../ui/main/help/fragment/DebugFragment.kt | 2 + .../viewmodel/RecordingsListViewModel.kt | 6 +- app/src/main/res/drawable/music_notes.xml | 9 ++ .../res/layout/file_media_viewer_activity.xml | 86 ++++++++------- .../file_media_viewer_child_fragment.xml | 74 +++++++++---- .../main/res/layout/file_viewer_activity.xml | 89 +++++++++------- 13 files changed, 304 insertions(+), 173 deletions(-) create mode 100644 app/src/main/res/drawable/music_notes.xml diff --git a/app/src/main/java/org/linphone/ui/file_viewer/FileViewerActivity.kt b/app/src/main/java/org/linphone/ui/file_viewer/FileViewerActivity.kt index 56c8c8381..85f617cb1 100644 --- a/app/src/main/java/org/linphone/ui/file_viewer/FileViewerActivity.kt +++ b/app/src/main/java/org/linphone/ui/file_viewer/FileViewerActivity.kt @@ -53,17 +53,23 @@ class FileViewerActivity : GenericActivity() { binding.viewModel = viewModel val args = intent.extras - val path = args?.getString("path") + if (args == null) { + finish() + return + } + + val path = args.getString("path") if (path.isNullOrEmpty()) { finish() return } + val timestamp = args.getLong("timestamp", -1) val preLoadedContent = args.getString("content") Log.i( "$TAG Path argument is [$path], pre loaded text content is ${if (preLoadedContent.isNullOrEmpty()) "not available" else "available, using it"}" ) - viewModel.loadFile(path, preLoadedContent) + viewModel.loadFile(path, timestamp, preLoadedContent) binding.setBackClickListener { finish() diff --git a/app/src/main/java/org/linphone/ui/file_viewer/MediaViewerActivity.kt b/app/src/main/java/org/linphone/ui/file_viewer/MediaViewerActivity.kt index ece75a774..21c8d91a2 100644 --- a/app/src/main/java/org/linphone/ui/file_viewer/MediaViewerActivity.kt +++ b/app/src/main/java/org/linphone/ui/file_viewer/MediaViewerActivity.kt @@ -42,7 +42,8 @@ class MediaViewerActivity : GenericActivity() { val list = viewModel.mediaList.value.orEmpty() if (position >= 0 && position < list.size) { val model = list[position] - viewModel.currentlyDisplayedFileName.value = "${model.fileName}\n${model.dateTime}" + viewModel.currentlyDisplayedFileName.value = model.fileName + viewModel.currentlyDisplayedFileDateTime.value = model.dateTime } } } diff --git a/app/src/main/java/org/linphone/ui/file_viewer/fragment/MediaViewerFragment.kt b/app/src/main/java/org/linphone/ui/file_viewer/fragment/MediaViewerFragment.kt index d635eee83..11ab44dd7 100644 --- a/app/src/main/java/org/linphone/ui/file_viewer/fragment/MediaViewerFragment.kt +++ b/app/src/main/java/org/linphone/ui/file_viewer/fragment/MediaViewerFragment.kt @@ -19,8 +19,11 @@ */ package org.linphone.ui.file_viewer.fragment +import android.graphics.SurfaceTexture import android.os.Bundle import android.view.LayoutInflater +import android.view.Surface +import android.view.TextureView.SurfaceTextureListener import android.view.View import android.view.ViewGroup import androidx.annotation.UiThread @@ -81,24 +84,6 @@ class MediaViewerFragment : GenericMainFragment() { Log.i("$TAG Path argument is [$path], it ${if (exists) "exists" else "doesn't exist"}") viewModel.loadFile(path) - viewModel.isVideo.observe(viewLifecycleOwner) { isVideo -> - if (isVideo) { - initVideoPlayer(path) - } - } - - viewModel.toggleVideoPlayPauseEvent.observe(viewLifecycleOwner) { - it.consume { play -> - if (play) { - Log.i("$TAG Starting video player") - binding.videoPlayer.start() - } else { - Log.i("$TAG Pausing video player") - binding.videoPlayer.pause() - } - } - } - binding.setToggleFullScreenModeClickListener { viewModel.toggleFullScreen() fullScreenChanged?.invoke(viewModel.fullScreenMode.value == true) @@ -108,40 +93,47 @@ class MediaViewerFragment : GenericMainFragment() { override fun onResume() { super.onResume() - if (viewModel.isVideo.value == true) { - Log.i("$TAG Resumed, starting video player") - binding.videoPlayer.start() - viewModel.isVideoPlaying.value = true + val textureView = binding.videoPlayer + if (textureView.isAvailable) { + Log.i("$TAG Surface created, setting display in mediaPlayer") + viewModel.mediaPlayer.setSurface((Surface(textureView.surfaceTexture))) + } else { + Log.i("$TAG Surface not available yet, setting listener") + textureView.surfaceTextureListener = object : SurfaceTextureListener { + override fun onSurfaceTextureAvailable( + surfaceTexture: SurfaceTexture, + p1: Int, + p2: Int + ) { + Log.i("$TAG Surface available, setting display in mediaPlayer") + viewModel.mediaPlayer.setSurface(Surface(surfaceTexture)) + } + + override fun onSurfaceTextureSizeChanged( + surfaceTexture: SurfaceTexture, + p1: Int, + p2: Int + ) { + } + + override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { + return true + } + + override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) { + } + } } + + viewModel.play() } override fun onPause() { - if (binding.videoPlayer.isPlaying) { - Log.i("$TAG Paused, stopping video player") - binding.videoPlayer.pause() - viewModel.isVideoPlaying.value = false - } - - if (viewModel.isAudioPlaying.value == true) { - Log.i("$TAG Paused, stopping audio player") - viewModel.pauseAudio() + if (viewModel.isMediaPlaying.value == true) { + Log.i("$TAG Paused, stopping media player") + viewModel.pause() } super.onPause() } - - override fun onDestroyView() { - binding.videoPlayer.stopPlayback() - - super.onDestroyView() - } - - private fun initVideoPlayer(path: String) { - Log.i("$TAG Creating video player for file [$path]") - binding.videoPlayer.setVideoPath(path) - binding.videoPlayer.setOnCompletionListener { - Log.i("$TAG End of file reached") - viewModel.isVideoPlaying.value = false - } - } } diff --git a/app/src/main/java/org/linphone/ui/file_viewer/viewmodel/FileViewModel.kt b/app/src/main/java/org/linphone/ui/file_viewer/viewmodel/FileViewModel.kt index 1d3d0e219..dceb90ba0 100644 --- a/app/src/main/java/org/linphone/ui/file_viewer/viewmodel/FileViewModel.kt +++ b/app/src/main/java/org/linphone/ui/file_viewer/viewmodel/FileViewModel.kt @@ -40,6 +40,7 @@ import org.linphone.core.tools.Log import org.linphone.ui.GenericViewModel import org.linphone.utils.Event import org.linphone.utils.FileUtils +import org.linphone.utils.TimestampUtils class FileViewModel @UiThread constructor() : GenericViewModel() { companion object { @@ -64,6 +65,8 @@ class FileViewModel @UiThread constructor() : GenericViewModel() { val fileReadyEvent = MutableLiveData>() + val dateTime = MutableLiveData() + val exportPlainTextFileEvent: MutableLiveData> by lazy { MutableLiveData>() } @@ -98,12 +101,18 @@ class FileViewModel @UiThread constructor() : GenericViewModel() { } @UiThread - fun loadFile(file: String, content: String? = null) { + fun loadFile(file: String, timestamp: Long, content: String? = null) { fullScreenMode.value = true val name = FileUtils.getNameFromFilePath(file) fileName.value = name + dateTime.value = TimestampUtils.toString( + timestamp, + shortDate = false, + hideYear = false + ) + if (!content.isNullOrEmpty()) { isText.value = true text.postValue(content!!) diff --git a/app/src/main/java/org/linphone/ui/file_viewer/viewmodel/MediaListViewModel.kt b/app/src/main/java/org/linphone/ui/file_viewer/viewmodel/MediaListViewModel.kt index 61ca1baa4..133f547da 100644 --- a/app/src/main/java/org/linphone/ui/file_viewer/viewmodel/MediaListViewModel.kt +++ b/app/src/main/java/org/linphone/ui/file_viewer/viewmodel/MediaListViewModel.kt @@ -39,6 +39,8 @@ class MediaListViewModel @UiThread constructor() : AbstractConversationViewModel val currentlyDisplayedFileName = MutableLiveData() + val currentlyDisplayedFileDateTime = MutableLiveData() + override fun beforeNotifyingChatRoomFound(sameOne: Boolean) { loadMediaList() } diff --git a/app/src/main/java/org/linphone/ui/file_viewer/viewmodel/MediaViewModel.kt b/app/src/main/java/org/linphone/ui/file_viewer/viewmodel/MediaViewModel.kt index 9c2286fec..0d4cd39b5 100644 --- a/app/src/main/java/org/linphone/ui/file_viewer/viewmodel/MediaViewModel.kt +++ b/app/src/main/java/org/linphone/ui/file_viewer/viewmodel/MediaViewModel.kt @@ -23,10 +23,16 @@ import android.media.AudioAttributes import android.media.MediaPlayer import androidx.annotation.UiThread import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.ticker +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.linphone.core.tools.Log import org.linphone.ui.GenericViewModel -import org.linphone.utils.Event import org.linphone.utils.FileUtils +import org.linphone.utils.TimestampUtils class MediaViewModel @UiThread constructor() : GenericViewModel() { companion object { @@ -43,24 +49,29 @@ class MediaViewModel @UiThread constructor() : GenericViewModel() { val isVideo = MutableLiveData() - val isVideoPlaying = MutableLiveData() - val isAudio = MutableLiveData() - val isAudioPlaying = MutableLiveData() + val isMediaPlaying = MutableLiveData() - val toggleVideoPlayPauseEvent: MutableLiveData> by lazy { - MutableLiveData>() - } + val duration = MutableLiveData() + + val formattedDuration = MutableLiveData() + + val position = MutableLiveData() + + lateinit var mediaPlayer: MediaPlayer private lateinit var filePath: String - private lateinit var mediaPlayer: MediaPlayer + private val tickerChannel = ticker(1000, 1000) + + private var updatePositionJob: Job? = null override fun onCleared() { if (::mediaPlayer.isInitialized) { mediaPlayer.release() } + stopUpdatePlaybackPosition() super.onCleared() } @@ -81,14 +92,13 @@ class MediaViewModel @UiThread constructor() : GenericViewModel() { } FileUtils.MimeType.Video -> { Log.d("$TAG File [$file] seems to be a video") + initMediaPlayer() isVideo.value = true - isVideoPlaying.value = false } FileUtils.MimeType.Audio -> { Log.d("$TAG File [$file] seems to be an audio file") - isAudio.value = true - initMediaPlayer() + isAudio.value = true } else -> { Log.e("$TAG Unexpected MIME type [$mime] for file at [$file]") @@ -102,35 +112,44 @@ class MediaViewModel @UiThread constructor() : GenericViewModel() { } @UiThread - fun playPauseVideo() { - val playVideo = isVideoPlaying.value == false - isVideoPlaying.value = playVideo - toggleVideoPlayPauseEvent.value = Event(playVideo) - } - - @UiThread - fun playPauseAudio() { + fun togglePlayPause() { if (::mediaPlayer.isInitialized) { if (mediaPlayer.isPlaying) { mediaPlayer.pause() - isAudioPlaying.value = false + + isMediaPlaying.value = false + stopUpdatePlaybackPosition() } else { mediaPlayer.start() - isAudioPlaying.value = true + + isMediaPlaying.value = true + startUpdatePlaybackPosition() } } } @UiThread - fun pauseAudio() { + fun play() { if (::mediaPlayer.isInitialized) { + mediaPlayer.start() + startUpdatePlaybackPosition() + isMediaPlaying.value = true + } + } + + @UiThread + fun pause() { + if (::mediaPlayer.isInitialized) { + isMediaPlaying.value = false + stopUpdatePlaybackPosition() mediaPlayer.pause() } } @UiThread private fun initMediaPlayer() { - isAudioPlaying.value = false + isMediaPlaying.value = false + mediaPlayer = MediaPlayer().apply { setAudioAttributes( AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).setUsage( @@ -140,12 +159,39 @@ class MediaViewModel @UiThread constructor() : GenericViewModel() { setDataSource(filePath) setOnCompletionListener { Log.i("$TAG Media player reached the end of file") - isAudioPlaying.postValue(false) + isMediaPlaying.postValue(false) + position.postValue(0) + stopUpdatePlaybackPosition() + + // Leave full screen when playback is done + fullScreenMode.postValue(false) } prepare() - start() - isAudioPlaying.value = true } - Log.i("$TAG Media player for file [$filePath] created") + + val durationInMillis = mediaPlayer.duration + position.value = 0 + duration.value = durationInMillis + formattedDuration.value = TimestampUtils.durationToString(durationInMillis / 1000) + Log.i("$TAG Media player for file [$filePath] created, let's start it") + } + + @UiThread + fun startUpdatePlaybackPosition() { + updatePositionJob = viewModelScope.launch { + withContext(Dispatchers.IO) { + for (tick in tickerChannel) { + if (::mediaPlayer.isInitialized && mediaPlayer.isPlaying) { + position.postValue(mediaPlayer.currentPosition) + } + } + } + } + } + + @UiThread + fun stopUpdatePlaybackPosition() { + updatePositionJob?.cancel() + updatePositionJob = null } } diff --git a/app/src/main/java/org/linphone/ui/main/chat/model/FileModel.kt b/app/src/main/java/org/linphone/ui/main/chat/model/FileModel.kt index 3b432a058..3d7ff0244 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/model/FileModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/model/FileModel.kt @@ -132,6 +132,7 @@ class FileModel @AnyThread constructor( FileUtils.deleteFile(path) } + @AnyThread private fun getDuration() { try { val retriever = MediaMetadataRetriever() diff --git a/app/src/main/java/org/linphone/ui/main/help/fragment/DebugFragment.kt b/app/src/main/java/org/linphone/ui/main/help/fragment/DebugFragment.kt index 6ea890923..aaf4192bb 100644 --- a/app/src/main/java/org/linphone/ui/main/help/fragment/DebugFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/help/fragment/DebugFragment.kt @@ -122,6 +122,8 @@ class DebugFragment : GenericMainFragment() { val bundle = Bundle() bundle.putString("path", CorePreferences.CONFIG_FILE_NAME) bundle.putString("content", content) + val nowInSeconds = System.currentTimeMillis() / 1000 + bundle.putLong("timestamp", nowInSeconds) intent.putExtras(bundle) startActivity(intent) } diff --git a/app/src/main/java/org/linphone/ui/main/recordings/viewmodel/RecordingsListViewModel.kt b/app/src/main/java/org/linphone/ui/main/recordings/viewmodel/RecordingsListViewModel.kt index 96286a39b..dcef35936 100644 --- a/app/src/main/java/org/linphone/ui/main/recordings/viewmodel/RecordingsListViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/recordings/viewmodel/RecordingsListViewModel.kt @@ -25,6 +25,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import androidx.media.AudioFocusRequestCompat import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.ticker import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -67,6 +68,7 @@ class RecordingsListViewModel @UiThread constructor() : GenericViewModel() { } private val tickerChannel = ticker(1000, 1000) + private var updatePositionJob: Job? = null init { searchBarVisible.value = false @@ -166,7 +168,7 @@ class RecordingsListViewModel @UiThread constructor() : GenericViewModel() { player?.start() model.isPlaying.postValue(true) - viewModelScope.launch { + updatePositionJob = viewModelScope.launch { withContext(Dispatchers.IO) { for (tick in tickerChannel) { coreContext.postOnCoreThread { @@ -193,6 +195,8 @@ class RecordingsListViewModel @UiThread constructor() : GenericViewModel() { player?.pause() model.isPlaying.postValue(false) + updatePositionJob?.cancel() + updatePositionJob = null } @WorkerThread diff --git a/app/src/main/res/drawable/music_notes.xml b/app/src/main/res/drawable/music_notes.xml new file mode 100644 index 000000000..4f6cbcf9c --- /dev/null +++ b/app/src/main/res/drawable/music_notes.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/file_media_viewer_activity.xml b/app/src/main/res/layout/file_media_viewer_activity.xml index b71fe337d..fe78edc71 100644 --- a/app/src/main/res/layout/file_media_viewer_activity.xml +++ b/app/src/main/res/layout/file_media_viewer_activity.xml @@ -27,7 +27,14 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:visibility="@{viewModel.fullScreenMode ? View.GONE : View.VISIBLE}" - app:constraint_referenced_ids="back, title, share, save, file_name"/> + app:constraint_referenced_ids="top_bar_background, back, file_name, share, save, date_time"/> + + + + + app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toTopOf="@id/date_time"/> + + + app:layout_constraintTop_toTopOf="parent" /> - - + app:layout_constraintTop_toTopOf="parent" /> - + android:visibility="@{viewModel.fullScreenMode ? View.GONE : viewModel.isAudio || viewModel.isVideo ? View.VISIBLE : View.GONE}" + app:constraint_referenced_ids="play_pause_audio_playback, progress, duration" /> - + app:tint="@color/white" /> + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/file_viewer_activity.xml b/app/src/main/res/layout/file_viewer_activity.xml index 918b0f8a4..d37242f85 100644 --- a/app/src/main/res/layout/file_viewer_activity.xml +++ b/app/src/main/res/layout/file_viewer_activity.xml @@ -25,7 +25,14 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:visibility="@{viewModel.fullScreenMode ? View.GONE : View.VISIBLE}" - app:constraint_referenced_ids="back, title, share, save, file_name"/> + app:constraint_referenced_ids="top_bar_background, back, file_name, share, save, date_time"/> + + @@ -75,79 +82,85 @@ + + + app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toTopOf="@id/date_time"/> + + + app:layout_constraintTop_toTopOf="parent" /> - - + app:layout_constraintTop_toTopOf="parent" />