diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoControlsDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoControlsDelegate.kt index 8f51169253..58c14fc174 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoControlsDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/VideoControlsDelegate.kt @@ -10,6 +10,7 @@ class VideoControlsDelegate { private val playWhenReady: MutableMap = 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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryVolumeBar.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryVolumeBar.kt new file mode 100644 index 0000000000..3e9e753b90 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryVolumeBar.kt @@ -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) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryVolumeOverlayView.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryVolumeOverlayView.kt new file mode 100644 index 0000000000..13710229fa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryVolumeOverlayView.kt @@ -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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryMutePolicy.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryMutePolicy.kt new file mode 100644 index 0000000000..5cd4741ccd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryMutePolicy.kt @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerActivity.kt index 898e4bef79..fc8bc4b9ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerActivity.kt @@ -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" diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryVolumeState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryVolumeState.kt new file mode 100644 index 0000000000..d8747abcbd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryVolumeState.kt @@ -0,0 +1,6 @@ +package org.thoughtcrime.securesms.stories.viewer + +data class StoryVolumeState( + val isMuted: Boolean = true, + val level: Int = -1 +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryVolumeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryVolumeViewModel.kt new file mode 100644 index 0000000000..8cbfd370d3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryVolumeViewModel.kt @@ -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 = 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) } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt index 22fcb459f7..8016e0327d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt @@ -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 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Debouncer.java b/app/src/main/java/org/thoughtcrime/securesms/util/Debouncer.java index eabd1ff9d4..3c7bb40f38 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Debouncer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Debouncer.java @@ -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. diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java b/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java index 9a1ebbe724..6e06d0d6d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/VideoPlayer.java @@ -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; } diff --git a/app/src/main/res/drawable/ic_speaker_off_outline_24.xml b/app/src/main/res/drawable/ic_speaker_off_outline_24.xml new file mode 100644 index 0000000000..cbd03f069d --- /dev/null +++ b/app/src/main/res/drawable/ic_speaker_off_outline_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/stories_viewer_fragment_page.xml b/app/src/main/res/layout/stories_viewer_fragment_page.xml index 6b1fd03afa..0ffb8942f7 100644 --- a/app/src/main/res/layout/stories_viewer_fragment_page.xml +++ b/app/src/main/res/layout/stories_viewer_fragment_page.xml @@ -68,6 +68,14 @@ android:layout_height="match_parent" /> + + + + + + + + + + \ No newline at end of file