diff --git a/app/src/main/java/org/mozilla/fenix/ext/Session.kt b/app/src/main/java/org/mozilla/fenix/ext/Session.kt index a6b744cf1378..977a88283687 100644 --- a/app/src/main/java/org/mozilla/fenix/ext/Session.kt +++ b/app/src/main/java/org/mozilla/fenix/ext/Session.kt @@ -6,13 +6,16 @@ package org.mozilla.fenix.ext import android.content.Context import mozilla.components.browser.session.Session +import mozilla.components.feature.media.state.MediaState import org.mozilla.fenix.home.sessioncontrol.Tab -fun Session.toTab(context: Context, selected: Boolean? = null): Tab { +fun Session.toTab(context: Context, selected: Boolean? = null, mediaState: MediaState? = null): Tab { return Tab( this.id, this.url, this.url.urlToTrimmedHost(context), this.title, - selected) + selected, + mediaState + ) } diff --git a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt index ea4ae9da972a..28a66d7cb493 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt @@ -44,6 +44,10 @@ import mozilla.components.concept.sync.AccountObserver import mozilla.components.concept.sync.AuthType import mozilla.components.concept.sync.OAuthAccount import mozilla.components.concept.sync.Profile +import mozilla.components.feature.media.ext.pauseIfPlaying +import mozilla.components.feature.media.ext.playIfPaused +import mozilla.components.feature.media.state.MediaState +import mozilla.components.feature.media.state.MediaStateMachine import mozilla.components.feature.tab.collections.TabCollection import org.jetbrains.anko.constraint.layout.ConstraintSetBuilder.Side.BOTTOM import org.jetbrains.anko.constraint.layout.ConstraintSetBuilder.Side.END @@ -137,6 +141,7 @@ class HomeFragment : Fragment(), AccountObserver { val sessionObserver = BrowserSessionsObserver(sessionManager, singleSessionObserver) { emitSessionChanges() } + lifecycle.addObserver(sessionObserver) if (!onboarding.userHasBeenOnboarded()) { @@ -353,6 +358,12 @@ class HomeFragment : Fragment(), AccountObserver { share(session.url) } } + is TabAction.PauseMedia -> { + MediaStateMachine.state.pauseIfPlaying() + } + is TabAction.PlayMedia -> { + MediaStateMachine.state.playIfPaused() + } is TabAction.CloseAll -> { if (pendingSessionDeletion?.deletionJob == null) { removeAllTabsWithUndo( @@ -875,7 +886,17 @@ class HomeFragment : Fragment(), AccountObserver { private fun List.toTabs(): List { val selected = sessionManager.selectedSession - return this.map { it.toTab(requireContext(), it == selected) } + val mediaStateSession = MediaStateMachine.state.getSession() + + return this.map { + val mediaState = if (mediaStateSession?.id == it.id) { + MediaStateMachine.state + } else { + null + } + + it.toTab(requireContext(), it == selected, mediaState) + } } companion object { @@ -884,13 +905,19 @@ class HomeFragment : Fragment(), AccountObserver { private const val ANIM_ON_SCREEN_DELAY = 200L private const val FADE_ANIM_DURATION = 150L private const val ANIM_SNACKBAR_DELAY = 100L - private const val ACCESSIBILITY_FOCUS_DELAY = 2000L - private const val TELEMETRY_HOME_IDENITIFIER = "home" private const val SHARED_TRANSITION_MS = 200L private const val TAB_ITEM_TRANSITION_NAME = "tab_item" } } +fun MediaState.getSession(): Session? { + return when (this) { + is MediaState.Playing -> session + is MediaState.Paused -> session + else -> null + } +} + /** * Wrapper around sessions manager to observe changes in sessions. * Similar to [mozilla.components.browser.session.utils.AllSessionsObserver] but ignores CustomTab sessions. @@ -913,6 +940,7 @@ private class BrowserSessionsObserver( */ @OnLifecycleEvent(Lifecycle.Event.ON_START) fun onStart() { + MediaStateMachine.register(managerObserver) manager.register(managerObserver) subscribeToAll() } @@ -922,6 +950,7 @@ private class BrowserSessionsObserver( */ @OnLifecycleEvent(Lifecycle.Event.ON_STOP) fun onStop() { + MediaStateMachine.unregister(managerObserver) manager.unregister(managerObserver) unsubscribeFromAll() } @@ -942,7 +971,11 @@ private class BrowserSessionsObserver( session.unregister(observer) } - private val managerObserver = object : SessionManager.Observer { + private val managerObserver = object : SessionManager.Observer, MediaStateMachine.Observer { + override fun onStateChanged(state: MediaState) { + onChanged() + } + override fun onSessionAdded(session: Session) { subscribeTo(session) onChanged() diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt index 8c11adad2f39..03c41f2c5fb1 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt @@ -6,6 +6,7 @@ package org.mozilla.fenix.home.sessioncontrol import android.content.Context import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import androidx.annotation.DrawableRes import androidx.annotation.LayoutRes @@ -14,6 +15,8 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import io.reactivex.Observer +import kotlinx.android.synthetic.main.tab_list_row.* +import mozilla.components.feature.media.state.MediaState import org.mozilla.fenix.home.sessioncontrol.viewholders.CollectionHeaderViewHolder import org.mozilla.fenix.home.sessioncontrol.viewholders.CollectionViewHolder import org.mozilla.fenix.home.sessioncontrol.viewholders.NoContentMessageViewHolder @@ -37,7 +40,28 @@ sealed class AdapterItem(@LayoutRes val viewType: Int) { data class TabHeader(val isPrivate: Boolean, val hasTabs: Boolean) : AdapterItem(TabHeaderViewHolder.LAYOUT_ID) data class TabItem(val tab: Tab) : AdapterItem(TabViewHolder.LAYOUT_ID) { override fun sameAs(other: AdapterItem) = other is TabItem && tab.sessionId == other.tab.sessionId + + // Tell the adapter exactly what values have changed so it only has to draw those + override fun getChangePayload(newItem: AdapterItem): Any? { + (newItem as TabItem).let { + val shouldUpdateUrl = newItem.tab.url != this.tab.url + val shouldUpdateHostname = newItem.tab.hostname != this.tab.hostname + val shouldUpdateTitle = newItem.tab.title != this.tab.title + val shouldUpdateSelected = newItem.tab.selected != this.tab.selected + val shouldUpdateMediaState = newItem.tab.mediaState != this.tab.mediaState + + return AdapterItemDiffCallback.TabChangePayload( + tab = newItem.tab, + shouldUpdateUrl = shouldUpdateUrl, + shouldUpdateHostname = shouldUpdateHostname, + shouldUpdateTitle = shouldUpdateTitle, + shouldUpdateSelected = shouldUpdateSelected, + shouldUpdateMediaState = shouldUpdateMediaState + ) + } + } } + object SaveTabGroup : AdapterItem(SaveTabGroupViewHolder.LAYOUT_ID) object PrivateBrowsingDescription : AdapterItem(PrivateBrowsingDescriptionViewHolder.LAYOUT_ID) @@ -85,6 +109,11 @@ sealed class AdapterItem(@LayoutRes val viewType: Int) { * True if this item represents the same value as other. Used by [AdapterItemDiffCallback]. */ open fun sameAs(other: AdapterItem) = this::class == other::class + + /** + * Returns a payload if there's been a change, or null if not + */ + open fun getChangePayload(newItem: AdapterItem): Any? = null } class AdapterItemDiffCallback : DiffUtil.ItemCallback() { @@ -92,6 +121,19 @@ class AdapterItemDiffCallback : DiffUtil.ItemCallback() { @Suppress("DiffUtilEquals") override fun areContentsTheSame(oldItem: AdapterItem, newItem: AdapterItem) = oldItem == newItem + + override fun getChangePayload(oldItem: AdapterItem, newItem: AdapterItem): Any? { + return oldItem.getChangePayload(newItem) ?: return super.getChangePayload(oldItem, newItem) + } + + data class TabChangePayload( + val tab: Tab, + val shouldUpdateUrl: Boolean, + val shouldUpdateHostname: Boolean, + val shouldUpdateTitle: Boolean, + val shouldUpdateSelected: Boolean, + val shouldUpdateMediaState: Boolean + ) } class SessionControlAdapter( @@ -133,9 +175,9 @@ class SessionControlAdapter( val tabHeader = item as AdapterItem.TabHeader holder.bind(tabHeader.isPrivate, tabHeader.hasTabs) } - is TabViewHolder -> holder.bindSession( - (item as AdapterItem.TabItem).tab - ) + is TabViewHolder -> { + holder.bindSession((item as AdapterItem.TabItem).tab) + } is NoContentMessageViewHolder -> { val (icon, header, description) = item as AdapterItem.NoContentMessage holder.bind(icon, header, description) @@ -152,13 +194,36 @@ class SessionControlAdapter( (item as AdapterItem.OnboardingSectionHeader).labelBuilder ) is OnboardingManualSignInViewHolder -> holder.bind() - is OnboardingAutomaticSignInViewHolder -> holder.bind( - ( - ( - item as AdapterItem.OnboardingAutomaticSignIn - ).state as OnboardingState.SignedOutCanAutoSignIn - ).withAccount + is OnboardingAutomaticSignInViewHolder -> holder.bind(( + (item as AdapterItem.OnboardingAutomaticSignIn).state + as OnboardingState.SignedOutCanAutoSignIn).withAccount ) } } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + payloads: MutableList + ) { + if (payloads.isEmpty()) { + onBindViewHolder(holder, position) + return + } + + (payloads[0] as AdapterItemDiffCallback.TabChangePayload).let { + (holder as TabViewHolder).updateTab(it.tab) + + // Always set the visibility to GONE to avoid the play button sticking around from previous draws + holder.play_pause_button.visibility = View.GONE + + if (it.shouldUpdateHostname) { holder.updateHostname(it.tab.hostname) } + if (it.shouldUpdateTitle) { holder.updateTitle(it.tab.title) } + if (it.shouldUpdateUrl) { holder.updateFavIcon(it.tab.url) } + if (it.shouldUpdateSelected) { holder.updateSelected(it.tab.selected ?: false) } + if (it.shouldUpdateMediaState) { + holder.updatePlayPauseButton(it.tab.mediaState ?: MediaState.None) + } + } + } } diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlComponent.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlComponent.kt index 5b796a581ae8..070c9c69bef5 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlComponent.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlComponent.kt @@ -5,13 +5,12 @@ package org.mozilla.fenix.home.sessioncontrol import android.content.Context -import android.os.Parcelable import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import io.reactivex.Observer -import kotlinx.android.parcel.Parcelize import mozilla.components.browser.session.Session +import mozilla.components.feature.media.state.MediaState import mozilla.components.service.fxa.sharing.ShareableAccount import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.ext.components @@ -46,14 +45,14 @@ class SessionControlComponent( } } -@Parcelize data class Tab( val sessionId: String, val url: String, val hostname: String, val title: String, - val selected: Boolean? = null -) : Parcelable + val selected: Boolean? = null, + var mediaState: MediaState? = null +) fun List.toSessionBundle(context: Context): MutableList { val sessionBundle = mutableListOf() @@ -62,7 +61,6 @@ fun List.toSessionBundle(context: Context): MutableList { sessionBundle.add(session) } } - return sessionBundle } @@ -108,6 +106,8 @@ sealed class TabAction : Action { data class Select(val tabView: View, val sessionId: String) : TabAction() data class Close(val sessionId: String) : TabAction() data class Share(val sessionId: String) : TabAction() + data class PauseMedia(val sessionId: String) : TabAction() + data class PlayMedia(val sessionId: String) : TabAction() object PrivateBrowsingLearnMore : TabAction() } diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/TabViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/TabViewHolder.kt index a46b5c247b34..0de310e455d7 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/TabViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/TabViewHolder.kt @@ -14,10 +14,12 @@ import kotlinx.android.extensions.LayoutContainer import kotlinx.android.synthetic.main.tab_list_row.* import mozilla.components.browser.menu.BrowserMenuBuilder import mozilla.components.browser.menu.item.SimpleBrowserMenuItem +import mozilla.components.feature.media.state.MediaState import mozilla.components.support.ktx.android.util.dpToFloat import org.mozilla.fenix.R import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.increaseTapArea import org.mozilla.fenix.ext.loadIntoView import org.mozilla.fenix.home.sessioncontrol.SessionControlAction import org.mozilla.fenix.home.sessioncontrol.Tab @@ -31,7 +33,7 @@ class TabViewHolder( ) : RecyclerView.ViewHolder(view), LayoutContainer { - var tab: Tab? = null + internal var tab: Tab? = null private var tabMenu: TabItemMenu init { @@ -52,9 +54,21 @@ class TabViewHolder( true } - close_tab_button?.run { - setOnClickListener { - actionEmitter.onNext(TabAction.Close(tab?.sessionId!!)) + close_tab_button.setOnClickListener { + actionEmitter.onNext(TabAction.Close(tab?.sessionId!!)) + } + + play_pause_button.increaseTapArea(PLAY_PAUSE_BUTTON_EXTRA_DPS) + + play_pause_button.setOnClickListener { + when (tab?.mediaState) { + is MediaState.Playing -> { + actionEmitter.onNext(TabAction.PauseMedia(tab?.sessionId!!)) + } + + is MediaState.Paused -> { + actionEmitter.onNext(TabAction.PlayMedia(tab?.sessionId!!)) + } } } @@ -72,27 +86,55 @@ class TabViewHolder( } } - fun bindSession(tab: Tab) { - this.tab = tab - updateTabUI(tab) - item_tab.transitionName = "$TAB_ITEM_TRANSITION_NAME${tab.sessionId}" + internal fun bindSession(tab: Tab) { + updateTab(tab) + updateTitle(tab.title) + updateHostname(tab.hostname) + updateFavIcon(tab.url) updateSelected(tab.selected ?: false) + updatePlayPauseButton(tab.mediaState ?: MediaState.None) + item_tab.transitionName = "$TAB_ITEM_TRANSITION_NAME${tab.sessionId}" + } + + internal fun updatePlayPauseButton(mediaState: MediaState) { + with(play_pause_button) { + visibility = if (mediaState is MediaState.Playing || mediaState is MediaState.Paused) { + View.VISIBLE + } else { + View.GONE + } + + if (mediaState is MediaState.Playing) { + setImageDrawable(context.getDrawable(R.drawable.pause_with_background)) + } else { + setImageDrawable(context.getDrawable(R.drawable.play_with_background)) + } + } + } + + internal fun updateTab(tab: Tab) { + this.tab = tab + } + internal fun updateTitle(text: String) { + tab_title.text = text + } + + internal fun updateHostname(text: String) { + hostname.text = text } - private fun updateTabUI(tab: Tab) { - hostname.text = tab.hostname - tab_title.text = tab.title - favicon_image.context.components.core.icons.loadIntoView(favicon_image, tab.url) + internal fun updateFavIcon(url: String) { + favicon_image.context.components.core.icons.loadIntoView(favicon_image, url) } - fun updateSelected(selected: Boolean) { + internal fun updateSelected(selected: Boolean) { selected_border.visibility = if (selected) View.VISIBLE else View.GONE } companion object { private const val TAB_ITEM_TRANSITION_NAME = "tab_item" + private const val PLAY_PAUSE_BUTTON_EXTRA_DPS = 24 const val LAYOUT_ID = R.layout.tab_list_row - const val buttonIncreaseDps = 12 const val favIconBorderRadiusInPx = 4 } } diff --git a/app/src/main/res/drawable/pause_with_background.xml b/app/src/main/res/drawable/pause_with_background.xml new file mode 100644 index 000000000000..3df510e230b0 --- /dev/null +++ b/app/src/main/res/drawable/pause_with_background.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/play_with_background.xml b/app/src/main/res/drawable/play_with_background.xml new file mode 100644 index 000000000000..db7c29db6f93 --- /dev/null +++ b/app/src/main/res/drawable/play_with_background.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/tab_list_row.xml b/app/src/main/res/layout/tab_list_row.xml index a144b0bd81ab..89d0940dbe0b 100644 --- a/app/src/main/res/layout/tab_list_row.xml +++ b/app/src/main/res/layout/tab_list_row.xml @@ -24,8 +24,8 @@ + + + android:id="@+id/selected_border" + android:layout_width="0dp" + android:layout_height="0dp" + android:background="@drawable/session_border" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0.0" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintVertical_bias="0.0" /> diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 6f165bd22d23..d48e3684ff1c 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -1,5 +1,4 @@ import org.gradle.api.Project -import java.lang.Math.pow import java.lang.RuntimeException import java.text.SimpleDateFormat @@ -118,8 +117,7 @@ object Config { var version = 0x78200000 // 1111000001000000000000000000000 // We reserve 1 "middle" high order bit for the future, and 3 low order bits // for architecture and APK splits. - version = version or (base shl 3) - + version = version or (base shl 3) // 'x' bit is 1 for x86/x86-64 architectures if (abi == "x86_64" || abi == "x86") { diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index f9f966c59fd0..c44365b13c8b 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -35,14 +35,14 @@ object Versions { const val androidx_work = "2.0.1" const val google_material = "1.1.0-alpha07" - const val mozilla_android_components = "12.0.0-SNAPSHOT" + const val mozilla_android_components = "13.0.0-SNAPSHOT" // Note that android-components also depends on application-services, // and in fact is our main source of appservices-related functionality. // The version number below tracks the application-services version // that we depend on directly for the fenix-megazord (and for it's // forUnitTest variant), and it's important that it be kept in // sync with the version used by android-components above. - const val mozilla_appservices = "0.38.1" + const val mozilla_appservices = "0.38.2" const val autodispose = "1.1.0" const val adjust = "4.11.4"