Implement correct video story sound behaviour.

This commit is contained in:
Alex Hart 2022-06-27 09:19:33 -03:00 committed by Cody Henthorne
parent 521bd2cce4
commit 1cfa5c31f2
13 changed files with 389 additions and 0 deletions

View file

@ -10,6 +10,7 @@ class VideoControlsDelegate {
private val playWhenReady: MutableMap<Uri, Boolean> = mutableMapOf()
private var player: Player? = null
private var isMuted: Boolean = true
fun getPlayerState(uri: Uri): PlayerState? {
val player: Player? = this.player
@ -41,9 +42,29 @@ class VideoControlsDelegate {
}
}
fun mute() {
isMuted = true
player?.videoPlayer?.mute()
}
fun unmute() {
isMuted = false
player?.videoPlayer?.unmute()
}
fun hasAudioStream(): Boolean {
return player?.videoPlayer?.hasAudioTrack() ?: false
}
fun attachPlayer(uri: Uri, videoPlayer: VideoPlayer?, isGif: Boolean) {
player = Player(uri, videoPlayer, isGif)
if (isMuted) {
videoPlayer?.mute()
} else {
videoPlayer?.unmute()
}
if (playWhenReady[uri] == true) {
playWhenReady[uri] = false
videoPlayer?.play()

View file

@ -0,0 +1,90 @@
package org.thoughtcrime.securesms.stories
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Rect
import android.graphics.RectF
import android.media.AudioManager
import android.util.AttributeSet
import android.view.View
import androidx.core.content.ContextCompat
import androidx.core.graphics.withClip
import androidx.media.AudioManagerCompat
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.ServiceUtil
/**
* Displays a vertical volume bar.
*/
class StoryVolumeBar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val minimum: Int
private val maximum: Int
private var level: Int
private val bounds: Rect = Rect()
private val clipBoundsF: RectF = RectF()
private val clipPath: Path = Path()
private val clipRadius = DimensionUnit.DP.toPixels(4f)
private val backgroundPaint = Paint().apply {
isAntiAlias = true
color = ContextCompat.getColor(context, R.color.transparent_black_40)
style = Paint.Style.STROKE
}
private val foregroundPaint = Paint().apply {
isAntiAlias = true
color = ContextCompat.getColor(context, R.color.core_white)
style = Paint.Style.STROKE
}
init {
val audioManager = ServiceUtil.getAudioManager(context)
if (isInEditMode) {
minimum = 0
maximum = 100
level = 50
} else {
minimum = AudioManagerCompat.getStreamMinVolume(audioManager, AudioManager.STREAM_MUSIC)
maximum = AudioManagerCompat.getStreamMaxVolume(audioManager, AudioManager.STREAM_MUSIC)
level = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
}
}
fun setLevel(level: Int) {
this.level = level
invalidate()
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
backgroundPaint.strokeWidth = (measuredWidth - paddingLeft - paddingRight).toFloat()
foregroundPaint.strokeWidth = backgroundPaint.strokeWidth
}
override fun onDraw(canvas: Canvas) {
canvas.getClipBounds(bounds)
clipBoundsF.set(bounds)
clipPath.reset()
clipPath.addRoundRect(clipBoundsF, clipRadius, clipRadius, Path.Direction.CW)
canvas.withClip(clipPath) {
canvas.drawLine(bounds.exactCenterX(), bounds.top.toFloat(), bounds.exactCenterX(), bounds.bottom.toFloat(), backgroundPaint)
val fillPercent: Float = (level - minimum) / (maximum - minimum.toFloat())
val fillHeight = bounds.height() * fillPercent
canvas.drawLine(bounds.exactCenterX(), bounds.bottom.toFloat() - fillHeight, bounds.exactCenterX(), bounds.bottom.toFloat(), foregroundPaint)
}
}
}

View file

@ -0,0 +1,33 @@
package org.thoughtcrime.securesms.stories
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.constraintlayout.widget.ConstraintLayout
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.visible
/**
* Displays a volume bar along with an indicator specifiying whether or not
* a given video contains sound.
*/
class StoryVolumeOverlayView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : ConstraintLayout(context, attrs) {
init {
inflate(context, R.layout.story_volume_overlay_view, this)
}
private val videoHasNoAudioIndicator: View = findViewById(R.id.story_no_audio_indicator)
private val volumeBar: StoryVolumeBar = findViewById(R.id.story_volume_bar)
fun setVideoHaNoAudio(videoHasNoAudio: Boolean) {
videoHasNoAudioIndicator.visible = videoHasNoAudio
}
fun setVolumeLevel(level: Int) {
volumeBar.setLevel(level)
}
}

View file

@ -0,0 +1,20 @@
package org.thoughtcrime.securesms.stories.viewer
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.util.AppForegroundObserver
/**
* Stories are to start muted, and once unmuted, remain as such until the
* user backgrounds the application.
*/
object StoryMutePolicy : AppForegroundObserver.Listener {
var isContentMuted: Boolean = true
fun initialize() {
ApplicationDependencies.getAppForegroundObserver().addListener(this)
}
override fun onBackground() {
isContentMuted = true
}
}

View file

@ -2,17 +2,26 @@ package org.thoughtcrime.securesms.stories.viewer
import android.content.Context
import android.content.Intent
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatDelegate
import androidx.media.AudioManagerCompat
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
import org.thoughtcrime.securesms.stories.StoryViewerArgs
import org.thoughtcrime.securesms.util.ServiceUtil
import kotlin.math.max
import kotlin.math.min
class StoryViewerActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner {
private val viewModel: StoryVolumeViewModel by viewModels()
override lateinit var voiceNoteMediaController: VoiceNoteMediaController
override fun attachBaseContext(newBase: Context) {
@ -21,6 +30,8 @@ class StoryViewerActivity : PassphraseRequiredActivity(), VoiceNoteMediaControll
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
StoryMutePolicy.initialize()
supportPostponeEnterTransition()
super.onCreate(savedInstanceState, ready)
@ -33,6 +44,15 @@ class StoryViewerActivity : PassphraseRequiredActivity(), VoiceNoteMediaControll
}
}
override fun onResume() {
super.onResume()
if (StoryMutePolicy.isContentMuted) {
viewModel.mute()
} else {
viewModel.unmute()
}
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
setIntent(intent)
@ -54,6 +74,25 @@ class StoryViewerActivity : PassphraseRequiredActivity(), VoiceNoteMediaControll
.commit()
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
val audioManager = ServiceUtil.getAudioManager(this)
when (keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> {
val maxVolume = AudioManagerCompat.getStreamMaxVolume(audioManager, AudioManager.STREAM_MUSIC)
StoryMutePolicy.isContentMuted = false
viewModel.onVolumeUp(min(maxVolume, audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + 1))
return true
}
KeyEvent.KEYCODE_VOLUME_DOWN -> {
val minVolume = AudioManagerCompat.getStreamMinVolume(audioManager, AudioManager.STREAM_MUSIC)
viewModel.onVolumeDown(max(minVolume, audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - 1))
return true
}
}
return super.onKeyDown(keyCode, event)
}
companion object {
private const val ARGS = "story.viewer.args"

View file

@ -0,0 +1,6 @@
package org.thoughtcrime.securesms.stories.viewer
data class StoryVolumeState(
val isMuted: Boolean = true,
val level: Int = -1
)

View file

@ -0,0 +1,28 @@
package org.thoughtcrime.securesms.stories.viewer
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.core.Flowable
import org.thoughtcrime.securesms.util.rx.RxStore
class StoryVolumeViewModel : ViewModel() {
private val store = RxStore(StoryVolumeState())
val state: Flowable<StoryVolumeState> = store.stateFlowable
val snapshot: StoryVolumeState get() = store.state
fun mute() {
store.update { it.copy(isMuted = true) }
}
fun unmute() {
store.update { it.copy(isMuted = false) }
}
fun onVolumeDown(level: Int) {
store.update { it.copy(level = level) }
}
fun onVolumeUp(level: Int) {
store.update { it.copy(isMuted = false, level = level) }
}
}

View file

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.stories.viewer.page
import android.animation.Animator
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
@ -7,6 +8,7 @@ import android.content.Context
import android.graphics.RenderEffect
import android.graphics.Shader
import android.graphics.drawable.Drawable
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
@ -54,9 +56,11 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.StoryFirstTimeNavigationView
import org.thoughtcrime.securesms.stories.StorySlateView
import org.thoughtcrime.securesms.stories.StoryVolumeOverlayView
import org.thoughtcrime.securesms.stories.dialogs.StoryContextMenu
import org.thoughtcrime.securesms.stories.viewer.StoryViewerState
import org.thoughtcrime.securesms.stories.viewer.StoryViewerViewModel
import org.thoughtcrime.securesms.stories.viewer.StoryVolumeViewModel
import org.thoughtcrime.securesms.stories.viewer.reply.StoriesSharedElementCrossFaderView
import org.thoughtcrime.securesms.stories.viewer.reply.direct.StoryDirectReplyDialogFragment
import org.thoughtcrime.securesms.stories.viewer.reply.group.StoryGroupReplyBottomSheetDialogFragment
@ -67,7 +71,9 @@ import org.thoughtcrime.securesms.stories.viewer.views.StoryViewsBottomSheetDial
import org.thoughtcrime.securesms.util.AvatarUtil
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.Debouncer
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout
@ -87,6 +93,8 @@ class StoryViewerPageFragment :
StoriesSharedElementCrossFaderView.Callback,
StoryFirstTimeNavigationView.Callback {
private val activityViewModel: StoryVolumeViewModel by viewModels(ownerProducer = { requireActivity() })
private lateinit var progressBar: SegmentedProgressBar
private lateinit var storySlate: StorySlateView
private lateinit var viewsAndReplies: MaterialButton
@ -101,6 +109,10 @@ class StoryViewerPageFragment :
private lateinit var chrome: List<View>
private var animatorSet: AnimatorSet? = null
private var volumeInAnimator: Animator? = null
private var volumeOutAnimator: Animator? = null
private var volumeDebouncer: Debouncer = Debouncer(3, TimeUnit.SECONDS)
private val viewModel: StoryViewerPageViewModel by viewModels(
factoryProducer = {
StoryViewerPageViewModel.Factory(storyRecipientId, initialStoryId, isUnviewedOnly, StoryViewerPageRepository(requireContext()))
@ -135,6 +147,12 @@ class StoryViewerPageFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
callback = requireListener()
if (activityViewModel.snapshot.isMuted) {
videoControlsDelegate.mute()
} else {
videoControlsDelegate.unmute()
}
val closeView: View = view.findViewById(R.id.close)
val senderAvatar: AvatarImageView = view.findViewById(R.id.sender_avatar)
val groupAvatar: AvatarImageView = view.findViewById(R.id.group_avatar)
@ -150,6 +168,7 @@ class StoryViewerPageFragment :
val reactionAnimationView: OnReactionSentView = view.findViewById(R.id.on_reaction_sent_view)
val storyGradientTop: View = view.findViewById(R.id.story_gradient_top)
val storyGradientBottom: View = view.findViewById(R.id.story_gradient_bottom)
val storyVolumeOverlayView: StoryVolumeOverlayView = view.findViewById(R.id.story_volume_overlay)
blurContainer = view.findViewById(R.id.story_blur_container)
storyContentContainer = view.findViewById(R.id.story_content_container)
@ -267,6 +286,29 @@ class StoryViewerPageFragment :
viewModel.setIsUserScrollingParent(isScrolling)
}
lifecycleDisposable += activityViewModel.state.distinctUntilChanged().observeOn(AndroidSchedulers.mainThread()).subscribe { volumeState ->
if (volumeState.isMuted) {
videoControlsDelegate.mute()
return@subscribe
}
if (!viewModel.hasPost() || !viewModel.getPost().content.isVideo() || volumeState.level < 0) {
return@subscribe
}
if (!volumeState.isMuted) {
videoControlsDelegate.unmute()
}
val audioManager = ServiceUtil.getAudioManager(requireContext())
if (audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) != volumeState.level) {
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, volumeState.level, 0)
storyVolumeOverlayView.setVolumeLevel(volumeState.level)
storyVolumeOverlayView.setVideoHaNoAudio(!videoControlsDelegate.hasAudioStream())
displayStoryVolumeOverlayForTimeout(storyVolumeOverlayView)
}
}
lifecycleDisposable += sharedViewModel.state.distinctUntilChanged().observeOn(AndroidSchedulers.mainThread()).subscribe { parentState ->
if (parentState.pages.size <= parentState.page) {
viewModel.setIsSelectedPage(false)
@ -421,6 +463,8 @@ class StoryViewerPageFragment :
it.cleanUp()
}
}
volumeDebouncer.clear()
}
override fun onFinishForwardAction() = Unit
@ -455,6 +499,26 @@ class StoryViewerPageFragment :
}
}
private fun displayStoryVolumeOverlayForTimeout(view: View) {
if (volumeInAnimator?.isRunning != true) {
volumeOutAnimator?.cancel()
volumeInAnimator = ObjectAnimator.ofFloat(view, View.ALPHA, 1f).apply {
duration = 200
start()
}
}
volumeDebouncer.publish {
if (volumeOutAnimator?.isRunning != true) {
volumeInAnimator?.cancel()
volumeOutAnimator = ObjectAnimator.ofFloat(view, View.ALPHA, 0f).apply {
duration = 200
start()
}
}
}
}
private fun hideChromeImmediate() {
animatorSet?.cancel()
chrome.map {

View file

@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.util;
import android.os.Handler;
import android.os.Looper;
import java.util.concurrent.TimeUnit;
/**
* A class that will throttle the number of runnables executed to be at most once every specified
* interval. However, it could be longer if events are published consistently.
@ -17,6 +19,10 @@ public class Debouncer {
private final Handler handler;
private final long threshold;
public Debouncer(long threshold, TimeUnit timeUnit) {
this(timeUnit.toMillis(threshold));
}
/**
* @param threshold Only one runnable will be executed via {@link #publish(Runnable)} every
* {@code threshold} milliseconds.

View file

@ -33,6 +33,7 @@ import com.google.android.exoplayer2.SimpleExoPlayer;
import com.google.android.exoplayer2.source.ClippingMediaSource;
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.source.TrackGroupArray;
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
import com.google.android.exoplayer2.ui.PlayerControlView;
import com.google.android.exoplayer2.ui.PlayerView;
@ -41,6 +42,7 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.mms.VideoSlide;
import org.thoughtcrime.securesms.util.MediaUtil;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
@ -136,6 +138,36 @@ public class VideoPlayer extends FrameLayout {
exoPlayer.setPlayWhenReady(autoplay);
}
public void mute() {
if (exoPlayer != null && exoPlayer.getAudioComponent() != null) {
exoPlayer.getAudioComponent().setVolume(0f);
}
}
public void unmute() {
if (exoPlayer != null && exoPlayer.getAudioComponent() != null) {
exoPlayer.getAudioComponent().setVolume(1f);
}
}
public boolean hasAudioTrack() {
if (exoPlayer != null) {
TrackGroupArray trackGroupArray = exoPlayer.getCurrentTrackGroups();
if (trackGroupArray != null) {
for (int i = 0; i < trackGroupArray.length; i++) {
for (int j = 0; j < trackGroupArray.get(i).length; j++) {
String sampleMimeType = trackGroupArray.get(i).getFormat(j).sampleMimeType;
if (MediaUtil.isAudioType(sampleMimeType)) {
return true;
}
}
}
}
}
return false;
}
public boolean isInitialized() {
return exoPlayer != null;
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M20.061,12.001L22.53,14.471L21.47,15.531L19,13.062L16.53,15.531L15.47,14.471L17.939,12.001L15.47,9.531L16.53,8.471L19,10.94L21.47,8.471L22.53,9.531L20.061,12.001ZM13.5,2.637C13.376,2.637 13.256,2.684 13.165,2.769L8,7.501H4C3.47,7.501 2.961,7.711 2.586,8.087C2.211,8.462 2,8.97 2,9.501V14.501C2,15.031 2.211,15.54 2.586,15.915C2.961,16.29 3.47,16.501 4,16.501H8L13.162,21.233C13.253,21.317 13.373,21.364 13.497,21.365C13.63,21.365 13.757,21.312 13.851,21.218C13.944,21.125 13.997,20.997 13.997,20.865V3.138C13.997,3.005 13.945,2.878 13.852,2.785C13.759,2.691 13.632,2.638 13.5,2.637V2.637ZM12.75,19.251L11.34,17.528L8.583,15.001H4C3.867,15.001 3.74,14.948 3.646,14.854C3.553,14.76 3.5,14.633 3.5,14.501V9.501C3.5,9.368 3.553,9.241 3.646,9.147C3.74,9.053 3.867,9.001 4,9.001H8.583L11.34,6.474L12.75,4.751L12.5,7.501V16.501L12.75,19.251Z"
android:fillColor="#000000"/>
</vector>

View file

@ -68,6 +68,14 @@
android:layout_height="match_parent" />
</androidx.cardview.widget.CardView>
<org.thoughtcrime.securesms.stories.StoryVolumeOverlayView
android:id="@+id/story_volume_overlay"
android:alpha="0"
android:layout_gravity="center_vertical|end"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout>
<com.google.android.material.button.MaterialButton

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/story_no_audio_indicator"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginEnd="10dp"
android:background="@color/transparent_black_40"
android:scaleType="centerInside"
android:tint="@color/core_white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/story_volume_bar"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Circle"
app:srcCompat="@drawable/ic_speaker_off_outline_24" />
<org.thoughtcrime.securesms.stories.StoryVolumeBar
android:id="@+id/story_volume_bar"
android:layout_width="6dp"
android:layout_height="160dp"
android:layout_marginEnd="12dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</merge>