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 a6b744cf1..977a88283 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 4cd71f06c..d07262c86 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt @@ -48,6 +48,11 @@ 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.getSession +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 @@ -144,6 +149,7 @@ class HomeFragment : Fragment(), AccountObserver { val sessionObserver = BrowserSessionsObserver(sessionManager, singleSessionObserver) { emitSessionChanges() } + lifecycle.addObserver(sessionObserver) if (!onboarding.userHasBeenOnboarded()) { @@ -372,6 +378,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( @@ -926,7 +938,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 { @@ -935,8 +957,6 @@ 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" private const val CFR_WIDTH_DIVIDER = 1.7 @@ -966,6 +986,7 @@ private class BrowserSessionsObserver( */ @OnLifecycleEvent(Lifecycle.Event.ON_START) fun onStart() { + MediaStateMachine.register(managerObserver) manager.register(managerObserver) subscribeToAll() } @@ -975,6 +996,7 @@ private class BrowserSessionsObserver( */ @OnLifecycleEvent(Lifecycle.Event.ON_STOP) fun onStop() { + MediaStateMachine.unregister(managerObserver) manager.unregister(managerObserver) unsubscribeFromAll() } @@ -995,7 +1017,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 8c11adad2..03c41f2c5 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 5b796a581..070c9c69b 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 a46b5c247..8292411f3 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,59 @@ 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}" } - 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 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) { + play_pause_button.contentDescription = + context.getString(R.string.mozac_feature_media_notification_action_pause) + setImageDrawable(context.getDrawable(R.drawable.pause_with_background)) + } else { + play_pause_button.contentDescription = + context.getString(R.string.mozac_feature_media_notification_action_play) + setImageDrawable(context.getDrawable(R.drawable.play_with_background)) + } + } } - fun updateSelected(selected: Boolean) { + 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 + } + + internal fun updateFavIcon(url: String) { + favicon_image.context.components.core.icons.loadIntoView(favicon_image, url) + } + + 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 000000000..3df510e23 --- /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 000000000..db7c29db6 --- /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 a144b0bd8..5d171ede7 100644 --- a/app/src/main/res/layout/tab_list_row.xml +++ b/app/src/main/res/layout/tab_list_row.xml @@ -35,6 +35,21 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + + 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" />