Commit 892e1de5 authored by MozLando's avatar MozLando
Browse files

Merge #6432

6432: Do not pause or display media notification when all media is muted. r=pocmo a=everett1992

Firefox preview will display a notification and request audio focus even
when the playing media is a muted gif, webm or other 'lite' media.

My goal is to stop requesting audio focus when the media is not playing
audio.
 - I only considered muted media. I ignored media with volume == 0.0,
 and media with audioTrackCount == 0. I'm not _sure_ that
 audioTrackCount 0 means the media is not playing sound. A similar
 commit could track and check those values.
 - I added the check for muted audio in MediaAggregateUpdaterTest
 because I am nnoyed by the notifications firefox creates if I leave a
 page with a webm in an open tab. I think aggregating player state to
 NONE will disable the notification. People won't be able to play/pause
 muted media from a notification, but I don't expect that's a common
 usecase. The check can be moved to MediaStateDelegate to avoid chaning
 the notification behavior.

Fixes [mozilla-mobile/fenix # 6146](https://github.com/mozilla-mobile/fenix/issues/6146

)
Co-authored-by: default avatarSebastian Kaspari <s.kaspari@gmail.com>
parents bdbb0cc4 4ba8caf1
......@@ -17,6 +17,7 @@ import org.mozilla.geckoview.MediaElement.MEDIA_STATE_SEEKING
import org.mozilla.geckoview.MediaElement.MEDIA_STATE_STALLED
import org.mozilla.geckoview.MediaElement.MEDIA_STATE_SUSPEND
import org.mozilla.geckoview.MediaElement.MEDIA_STATE_WAITING
import kotlin.properties.Delegates
/**
* [Media] (`concept-engine`) implementation for GeckoView.
......@@ -26,8 +27,15 @@ internal class GeckoMedia(
) : Media() {
override val controller: Controller = GeckoMediaController(mediaElement)
override var metadata: Metadata = Metadata()
internal set
override var metadata: Metadata by Delegates.observable(Metadata()) {
_, old, new -> notifyObservers(old, new) { onMetadataChanged(this@GeckoMedia, new) }
}
internal set
override var volume: Volume by Delegates.observable(Volume()) {
_, old, new -> notifyObservers(old, new) { onVolumeChanged(this@GeckoMedia, new) }
}
internal set
init {
mediaElement.delegate = MediaDelegate(this)
......@@ -70,9 +78,12 @@ private class MediaDelegate(
media.metadata = Media.Metadata(metaData.duration)
}
override fun onVolumeChange(mediaElement: MediaElement, volume: Double, muted: Boolean) {
media.volume = Media.Volume(muted)
}
override fun onReadyStateChange(mediaElement: MediaElement, readyState: Int) = Unit
override fun onLoadProgress(mediaElement: MediaElement, progressInfo: MediaElement.LoadProgressInfo) = Unit
override fun onVolumeChange(mediaElement: MediaElement, volume: Double, muted: Boolean) = Unit
override fun onTimeChange(mediaElement: MediaElement, time: Double) = Unit
override fun onPlaybackRateChange(mediaElement: MediaElement, rate: Double) = Unit
override fun onFullscreenChange(mediaElement: MediaElement, fullscreen: Boolean) = Unit
......
......@@ -122,6 +122,52 @@ class GeckoMediaTest {
delegate.onMetadataChange(mediaElement, MockedGeckoMetadata(duration = -1.0))
assertEquals(-1.0, media.metadata.duration, 0.0001)
}
@Test
fun `GeckoMedia exposes Volume`() {
val mediaElement: MediaElement = mock()
val media = GeckoMedia(mediaElement)
val captor = argumentCaptor<MediaElement.Delegate>()
verify(mediaElement).delegate = captor.capture()
assertEquals(media.volume.muted, false)
val delegate = captor.value
delegate.onVolumeChange(mediaElement, 1.0, true)
assertEquals(true, media.volume.muted)
delegate.onVolumeChange(mediaElement, 1.0, false)
assertEquals(false, media.volume.muted)
}
@Test
fun `GeckoMedia notifies observer when metadata changes`() {
val media = GeckoMedia(mock())
val observer: Media.Observer = mock()
media.register(observer)
val metadata: Media.Metadata = Media.Metadata(duration = 42.0)
media.metadata = metadata
verify(observer).onMetadataChanged(media, metadata)
}
@Test
fun `GeckoMedia notifies observer when volume changes`() {
val media = GeckoMedia(mock())
val observer: Media.Observer = mock()
media.register(observer)
val volume: Media.Volume = Media.Volume(muted = true)
media.volume = volume
verify(observer).onVolumeChanged(media, volume)
}
}
private class MockedGeckoMetadata(
......
......@@ -17,6 +17,7 @@ import org.mozilla.geckoview.MediaElement.MEDIA_STATE_SEEKING
import org.mozilla.geckoview.MediaElement.MEDIA_STATE_STALLED
import org.mozilla.geckoview.MediaElement.MEDIA_STATE_SUSPEND
import org.mozilla.geckoview.MediaElement.MEDIA_STATE_WAITING
import kotlin.properties.Delegates
/**
* [Media] (`concept-engine`) implementation for GeckoView.
......@@ -26,8 +27,15 @@ internal class GeckoMedia(
) : Media() {
override val controller: Controller = GeckoMediaController(mediaElement)
override var metadata: Metadata = Metadata()
internal set
override var metadata: Metadata by Delegates.observable(Metadata()) {
_, old, new -> notifyObservers(old, new) { onMetadataChanged(this@GeckoMedia, new) }
}
internal set
override var volume: Volume by Delegates.observable(Volume()) {
_, old, new -> notifyObservers(old, new) { onVolumeChanged(this@GeckoMedia, new) }
}
internal set
init {
mediaElement.delegate = MediaDelegate(this)
......@@ -70,9 +78,12 @@ private class MediaDelegate(
media.metadata = Media.Metadata(metaData.duration)
}
override fun onVolumeChange(mediaElement: MediaElement, volume: Double, muted: Boolean) {
media.volume = Media.Volume(muted)
}
override fun onReadyStateChange(mediaElement: MediaElement, readyState: Int) = Unit
override fun onLoadProgress(mediaElement: MediaElement, progressInfo: MediaElement.LoadProgressInfo) = Unit
override fun onVolumeChange(mediaElement: MediaElement, volume: Double, muted: Boolean) = Unit
override fun onTimeChange(mediaElement: MediaElement, time: Double) = Unit
override fun onPlaybackRateChange(mediaElement: MediaElement, rate: Double) = Unit
override fun onFullscreenChange(mediaElement: MediaElement, fullscreen: Boolean) = Unit
......
......@@ -122,6 +122,32 @@ class GeckoMediaTest {
delegate.onMetadataChange(mediaElement, MockedGeckoMetadata(duration = -1.0))
assertEquals(-1.0, media.metadata.duration, 0.0001)
}
@Test
fun `GeckoMedia notifies observer when metadata changes`() {
val media = GeckoMedia(mock())
val observer: Media.Observer = mock()
media.register(observer)
val metadata: Media.Metadata = Media.Metadata(duration = 42.0)
media.metadata = metadata
verify(observer).onMetadataChanged(media, metadata)
}
@Test
fun `GeckoMedia notifies observer when volume changes`() {
val media = GeckoMedia(mock())
val observer: Media.Observer = mock()
media.register(observer)
val volume: Media.Volume = Media.Volume(muted = true)
media.volume = volume
verify(observer).onVolumeChanged(media, volume)
}
}
private class MockedGeckoMetadata(
......
......@@ -17,6 +17,7 @@ import org.mozilla.geckoview.MediaElement.MEDIA_STATE_SEEKING
import org.mozilla.geckoview.MediaElement.MEDIA_STATE_STALLED
import org.mozilla.geckoview.MediaElement.MEDIA_STATE_SUSPEND
import org.mozilla.geckoview.MediaElement.MEDIA_STATE_WAITING
import kotlin.properties.Delegates
/**
* [Media] (`concept-engine`) implementation for GeckoView.
......@@ -26,8 +27,15 @@ internal class GeckoMedia(
) : Media() {
override val controller: Controller = GeckoMediaController(mediaElement)
override var metadata: Metadata = Metadata()
internal set
override var metadata: Metadata by Delegates.observable(Metadata()) {
_, old, new -> notifyObservers(old, new) { onMetadataChanged(this@GeckoMedia, new) }
}
internal set
override var volume: Volume by Delegates.observable(Volume()) {
_, old, new -> notifyObservers(old, new) { onVolumeChanged(this@GeckoMedia, new) }
}
internal set
init {
mediaElement.delegate = MediaDelegate(this)
......@@ -70,9 +78,12 @@ private class MediaDelegate(
media.metadata = Media.Metadata(metaData.duration)
}
override fun onVolumeChange(mediaElement: MediaElement, volume: Double, muted: Boolean) {
media.volume = Media.Volume(muted)
}
override fun onReadyStateChange(mediaElement: MediaElement, readyState: Int) = Unit
override fun onLoadProgress(mediaElement: MediaElement, progressInfo: MediaElement.LoadProgressInfo) = Unit
override fun onVolumeChange(mediaElement: MediaElement, volume: Double, muted: Boolean) = Unit
override fun onTimeChange(mediaElement: MediaElement, time: Double) = Unit
override fun onPlaybackRateChange(mediaElement: MediaElement, rate: Double) = Unit
override fun onFullscreenChange(mediaElement: MediaElement, fullscreen: Boolean) = Unit
......
......@@ -122,6 +122,32 @@ class GeckoMediaTest {
delegate.onMetadataChange(mediaElement, MockedGeckoMetadata(duration = -1.0))
assertEquals(-1.0, media.metadata.duration, 0.0001)
}
@Test
fun `GeckoMedia notifies observer when metadata changes`() {
val media = GeckoMedia(mock())
val observer: Media.Observer = mock()
media.register(observer)
val metadata: Media.Metadata = Media.Metadata(duration = 42.0)
media.metadata = metadata
verify(observer).onMetadataChanged(media, metadata)
}
@Test
fun `GeckoMedia notifies observer when volume changes`() {
val media = GeckoMedia(mock())
val observer: Media.Observer = mock()
media.register(observer)
val volume: Media.Volume = Media.Volume(muted = true)
media.volume = volume
verify(observer).onVolumeChanged(media, volume)
}
}
private class MockedGeckoMetadata(
......
......@@ -41,4 +41,12 @@ internal class MediaObserver(
metadata
))
}
override fun onVolumeChanged(media: Media, volume: Media.Volume) {
store.dispatch(MediaAction.UpdateMediaVolumeAction(
tabId,
element.id,
volume
))
}
}
......@@ -11,5 +11,6 @@ internal fun Media.toElement() = MediaState.Element(
state = state,
playbackState = playbackState,
controller = controller,
metadata = metadata
metadata = metadata,
volume = volume
)
......@@ -496,6 +496,7 @@ class EngineObserverTest {
val media1: Media = spy(object : Media() {
override val controller: Controller = mock()
override val metadata: Metadata = mock()
override val volume: Volume = mock()
})
observer.onMediaAdded(media1)
......@@ -507,6 +508,7 @@ class EngineObserverTest {
val media2: Media = spy(object : Media() {
override val controller: Controller = mock()
override val metadata: Metadata = mock()
override val volume: Volume = mock()
})
observer.onMediaAdded(media2)
......@@ -518,6 +520,7 @@ class EngineObserverTest {
val media3: Media = spy(object : Media() {
override val controller: Controller = mock()
override val metadata: Metadata = mock()
override val volume: Volume = mock()
})
observer.onMediaAdded(media3)
......@@ -541,12 +544,14 @@ class EngineObserverTest {
val media1: Media = spy(object : Media() {
override val controller: Controller = mock()
override val metadata: Metadata = mock()
override val volume: Volume = mock()
})
observer.onMediaAdded(media1)
val media2: Media = spy(object : Media() {
override val controller: Controller = mock()
override val metadata: Metadata = mock()
override val volume: Volume = mock()
})
observer.onMediaAdded(media2)
......
......@@ -458,6 +458,16 @@ sealed class MediaAction : BrowserAction() {
val metadata: Media.Metadata
) : MediaAction()
/**
* Updates the [Media.Volume] for the [MediaState.Element] with id [mediaId] owned by the tab
* with id [tabId].
*/
data class UpdateMediaVolumeAction(
val tabId: String,
val mediaId: String,
val volume: Media.Volume
) : MediaAction()
/**
* Updates [MediaState.Aggregate] in the [MediaState].
*/
......
......@@ -44,6 +44,10 @@ internal object MediaReducer {
it.copy(metadata = action.metadata)
}
is MediaAction.UpdateMediaVolumeAction -> state.updateMediaElement(action.tabId, action.mediaId) {
it.copy(volume = action.volume)
}
is MediaAction.UpdateMediaAggregateAction -> state.copy(
media = state.media.copy(aggregate = action.aggregate)
)
......
......@@ -34,16 +34,18 @@ data class MediaState(
* @property id Unique ID for this media element.
* @property state The current simplified [State] of this media element (derived from [playbackState]
* events).
* @property playbackState The current [PlaybackState] of this media element.
* @property controller The [Controller] for controlling playback of this media element.
* @property metadata The [Metadata] for this media element.
* @property playbackState The current [Media.PlaybackState] of this media element.
* @property controller The [Media.Controller] for controlling playback of this media element.
* @property metadata The [Media.Metadata] for this media element.
* @property volume The [Media.Volume] for this media element.
*/
data class Element(
val id: String = UUID.randomUUID().toString(),
val state: Media.State,
val playbackState: PlaybackState,
val controller: Controller,
val metadata: Media.Metadata
val metadata: Media.Metadata,
val volume: Media.Volume
)
/**
......
......@@ -372,6 +372,54 @@ class MediaActionTest {
assertEquals(-1.0, store.state.media.elements["other-tab"]?.getOrNull(0)?.metadata?.duration)
}
@Test
fun `UpdateMediaVolumeAction - Updates media volume of element`() {
val element1 = createMockMediaElement()
val element2 = createMockMediaElement()
val element3 = createMockMediaElement()
val store = BrowserStore(BrowserState(
tabs = listOf(
createTab("https://www.mozilla.org", id = "test-tab"),
createTab("https://www.firefox.com", id = "other-tab")
),
media = MediaState(
elements = mapOf(
"test-tab" to listOf(element1, element2),
"other-tab" to listOf(element3)
)
)
))
assertEquals(false, store.state.media.elements["test-tab"]?.getOrNull(0)?.volume?.muted)
assertEquals(false, store.state.media.elements["test-tab"]?.getOrNull(1)?.volume?.muted)
assertEquals(false, store.state.media.elements["other-tab"]?.getOrNull(0)?.volume?.muted)
store.dispatch(MediaAction.UpdateMediaVolumeAction(
tabId = "test-tab",
mediaId = element1.id,
volume = Media.Volume(
muted = true
)
)).joinBlocking()
assertEquals(true, store.state.media.elements["test-tab"]?.getOrNull(0)?.volume?.muted)
assertEquals(false, store.state.media.elements["test-tab"]?.getOrNull(1)?.volume?.muted)
assertEquals(false, store.state.media.elements["other-tab"]?.getOrNull(0)?.volume?.muted)
store.dispatch(MediaAction.UpdateMediaVolumeAction(
tabId = "test-tab",
mediaId = element2.id,
volume = Media.Volume(
muted = true
)
)).joinBlocking()
assertEquals(true, store.state.media.elements["test-tab"]?.getOrNull(0)?.volume?.muted)
assertEquals(true, store.state.media.elements["test-tab"]?.getOrNull(1)?.volume?.muted)
assertEquals(false, store.state.media.elements["other-tab"]?.getOrNull(0)?.volume?.muted)
}
@Test
fun `UpdateMediaAggregateAction - Updates aggregate`() {
val store = BrowserStore(BrowserState())
......@@ -422,6 +470,7 @@ private fun createMockMediaElement(): MediaState.Element {
state = Media.State.PLAYING,
playbackState = Media.PlaybackState.PLAYING,
controller = mock(),
metadata = Media.Metadata()
metadata = Media.Metadata(),
volume = Media.Volume()
)
}
......@@ -42,13 +42,23 @@ abstract class Media(
*/
abstract val metadata: Metadata
/**
* The [Volume] for this media element.
*/
abstract val volume: Volume
/**
* Interface to be implemented by classes that want to observe a media element.
*/
interface Observer {
/** Notify the observer that media state changed. */
fun onStateChanged(media: Media, state: State) = Unit
/** Notify the observer that media playback state changed. */
fun onPlaybackStateChanged(media: Media, playbackState: PlaybackState) = Unit
/** Notify the observer that media metadata changed. */
fun onMetadataChanged(media: Media, metadata: Metadata) = Unit
/** Notify the observer that media volume changed. */
fun onVolumeChanged(media: Media, volume: Volume) = Unit
}
/**
......@@ -172,6 +182,15 @@ abstract class Media(
EMPTIED,
}
/**
* Volume associated with [Media].
*
* @property muted Indicates if the media is muted.
*/
data class Volume(
val muted: Boolean = false
)
/**
* Metadata associated with [Media].
*
......@@ -184,7 +203,7 @@ abstract class Media(
/**
* Helper method to notify observers.
*/
private fun notifyObservers(old: Any, new: Any, block: Observer.() -> Unit) {
protected fun notifyObservers(old: Any, new: Any, block: Observer.() -> Unit) {
if (old != new) {
notifyObservers(block)
}
......
......@@ -106,4 +106,5 @@ class MediaTest {
private class FakeMedia : Media() {
override val controller: Controller = mock()
override val metadata: Metadata = mock()
override val volume: Volume = mock()
}
......@@ -2,6 +2,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
@file:Suppress("TooManyFunctions")
package mozilla.components.feature.media.ext
import android.support.v4.media.session.PlaybackStateCompat
......@@ -58,6 +59,13 @@ internal fun List<MediaState.Element>.hasMediaWithSufficientLongDuration(): Bool
return false
}
/**
* Does this list contain [Media] that has audible audio?
*/
internal fun List<MediaState.Element>.hasMediaWithAudibleAudio(): Boolean {
return any { !it.volume.muted }
}
/**
* Get the list of paused [Media] for the tab with the provided [tabId].
*/
......
......@@ -89,6 +89,7 @@ class MediaMiddleware(
is MediaAction.AddMediaAction,
is MediaAction.RemoveMediaAction,
is MediaAction.RemoveTabMediaAction,
is MediaAction.UpdateMediaVolumeAction,
is MediaAction.UpdateMediaStateAction -> {
mediaAggregateUpdate.process(store)
}
......
......@@ -18,6 +18,7 @@ import mozilla.components.feature.media.ext.findPlayingSession
import mozilla.components.feature.media.ext.getPausedMedia
import mozilla.components.feature.media.ext.getPlayingMediaIdsForTab
import mozilla.components.feature.media.ext.hasMediaWithSufficientLongDuration
import mozilla.components.feature.media.ext.hasMediaWithAudibleAudio
import mozilla.components.lib.state.MiddlewareStore
import mozilla.components.support.base.coroutines.Dispatchers
......@@ -62,9 +63,9 @@ internal class MediaAggregateUpdater(
if (playingSession != null) {
val (session, media) = playingSession
// We only switch to playing state if there's media playing that has a sufficient long
// duration. Otherwise we let just Gecko play it and do not request audio focus or show
// duration and audio. Otherwise we let just Gecko play it and do not request audio focus or show
// a media notification. This will let us ignore short audio effects (Beeep!).
return if (media.hasMediaWithSufficientLongDuration()) {
return if (media.hasMediaWithSufficientLongDuration() && media.hasMediaWithAudibleAudio()) {
MediaState.Aggregate(MediaState.State.PLAYING, session, media.map { it.id })
} else {
MediaState.Aggregate(MediaState.State.NONE)
......
......@@ -12,13 +12,15 @@ import java.util.UUID
fun createMockMediaElement(
id: String = UUID.randomUUID().toString(),
state: Media.State = Media.State.PLAYING,
metadata: Media.Metadata = Media.Metadata()
metadata: Media.Metadata = Media.Metadata(),
volume: Media.Volume = Media.Volume()
): MediaState.Element {
return MediaState.Element(
id = id,
state = state,
playbackState = Media.PlaybackState.PLAYING,
controller = mock(),
metadata = metadata
metadata = metadata,
volume = volume
)
}