Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 7dffd377 authored by Robert Snoeberger's avatar Robert Snoeberger
Browse files

Add seek bar to QS Media Player

Bug: 150724977
Test: manual - play music and look for seek bar in QS media player
Test: maunal - play podcast and check that track position can be changed
Test: manual - play IHeartRadio and check that seek bar is gone
Test: adding SeekBarObserverTest and SeekBarViewModelTest
Change-Id: I98f32b939f2310e9eb492165f1fddfd7dee65a90
parent ef7b5342
Loading
Loading
Loading
Loading
+41 −0
Original line number Diff line number Diff line
@@ -136,6 +136,47 @@
            </LinearLayout>
        </LinearLayout>

        <!-- Seek Bar -->
        <SeekBar
            android:id="@+id/media_progress_bar"
            android:clickable="true"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:maxHeight="3dp"
            android:paddingTop="24dp"
            android:paddingBottom="24dp"
            android:layout_marginBottom="-24dp"
            android:layout_marginTop="-24dp"
            android:splitTrack="false"
        />

        <FrameLayout
            android:id="@+id/notification_media_progress_time"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            >
            <!-- width is set to "match_parent" to avoid extra layout calls -->
            <TextView
                android:id="@+id/media_elapsed_time"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_alignParentLeft="true"
                android:fontFamily="@*android:string/config_bodyFontFamily"
                android:textSize="14sp"
                android:gravity="left"
            />
            <TextView
                android:id="@+id/media_total_time"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:fontFamily="@*android:string/config_bodyFontFamily"
                android:layout_alignParentRight="true"
                android:textSize="14sp"
                android:gravity="right"
            />
        </FrameLayout>

        <!-- Controls -->
        <LinearLayout
            android:id="@+id/media_actions"
+86 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2020 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.media

import android.content.res.ColorStateList
import android.text.format.DateUtils
import android.view.View
import android.widget.SeekBar
import android.widget.TextView
import androidx.annotation.UiThread
import androidx.lifecycle.Observer

import com.android.systemui.R

/**
 * Observer for changes from SeekBarViewModel.
 *
 * <p>Updates the seek bar views in response to changes to the model.
 */
class SeekBarObserver(view: View) : Observer<SeekBarViewModel.Progress> {

    private val seekBarView: SeekBar
    private val elapsedTimeView: TextView
    private val totalTimeView: TextView

    init {
        seekBarView = view.findViewById(R.id.media_progress_bar)
        elapsedTimeView = view.findViewById(R.id.media_elapsed_time)
        totalTimeView = view.findViewById(R.id.media_total_time)
    }

    /** Updates seek bar views when the data model changes. */
    @UiThread
    override fun onChanged(data: SeekBarViewModel.Progress) {
        if (data.enabled && seekBarView.visibility == View.GONE) {
            seekBarView.visibility = View.VISIBLE
            elapsedTimeView.visibility = View.VISIBLE
            totalTimeView.visibility = View.VISIBLE
        } else if (!data.enabled && seekBarView.visibility == View.VISIBLE) {
            seekBarView.visibility = View.GONE
            elapsedTimeView.visibility = View.GONE
            totalTimeView.visibility = View.GONE
            return
        }

        // TODO: update the style of the disabled progress bar
        seekBarView.setEnabled(data.seekAvailable)

        data.color?.let {
            var tintList = ColorStateList.valueOf(it)
            seekBarView.setThumbTintList(tintList)
            tintList = tintList.withAlpha(192) // 75%
            seekBarView.setProgressTintList(tintList)
            tintList = tintList.withAlpha(128) // 50%
            seekBarView.setProgressBackgroundTintList(tintList)
            elapsedTimeView.setTextColor(it)
            totalTimeView.setTextColor(it)
        }

        data.elapsedTime?.let {
            seekBarView.setProgress(it)
            elapsedTimeView.setText(DateUtils.formatElapsedTime(
                    it / DateUtils.SECOND_IN_MILLIS))
        }

        data.duration?.let {
            seekBarView.setMax(it)
            totalTimeView.setText(DateUtils.formatElapsedTime(
                    it / DateUtils.SECOND_IN_MILLIS))
        }
    }
}
+152 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2020 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.media

import android.media.MediaMetadata
import android.media.session.MediaController
import android.media.session.PlaybackState
import android.view.MotionEvent
import android.view.View
import android.widget.SeekBar
import androidx.annotation.AnyThread
import androidx.annotation.WorkerThread
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.LiveData

import com.android.systemui.util.concurrency.DelayableExecutor

private const val POSITION_UPDATE_INTERVAL_MILLIS = 100L

/** ViewModel for seek bar in QS media player. */
class SeekBarViewModel(val bgExecutor: DelayableExecutor) {

    private val _progress = MutableLiveData<Progress>().apply {
        postValue(Progress(false, false, null, null, null))
    }
    val progress: LiveData<Progress>
        get() = _progress
    private var controller: MediaController? = null
    private var playbackState: PlaybackState? = null

    /** Listening state (QS open or closed) is used to control polling of progress. */
    var listening = true
        set(value) {
            if (value) {
                checkPlaybackPosition()
            }
        }

    /**
     * Handle request to change the current position in the media track.
     * @param position Place to seek to in the track.
     */
    @WorkerThread
    fun onSeek(position: Long) {
        controller?.transportControls?.seekTo(position)
    }

    /**
     * Updates media information.
     * @param mediaController controller for media session
     * @param color foreground color for UI elements
     */
    @WorkerThread
    fun updateController(mediaController: MediaController?, color: Int) {
        controller = mediaController
        playbackState = controller?.playbackState
        val mediaMetadata = controller?.metadata
        val seekAvailable = ((playbackState?.actions ?: 0L) and PlaybackState.ACTION_SEEK_TO) != 0L
        val position = playbackState?.position?.toInt()
        val duration = mediaMetadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.toInt()
        val enabled = if (duration != null && duration <= 0) false else true
        _progress.postValue(Progress(enabled, seekAvailable, position, duration, color))
        if (shouldPollPlaybackPosition()) {
            checkPlaybackPosition()
        }
    }

    @AnyThread
    private fun checkPlaybackPosition(): Runnable = bgExecutor.executeDelayed({
        val currentPosition = controller?.playbackState?.position?.toInt()
        if (currentPosition != null && _progress.value!!.elapsedTime != currentPosition) {
            _progress.postValue(_progress.value!!.copy(elapsedTime = currentPosition))
        }
        if (shouldPollPlaybackPosition()) {
            checkPlaybackPosition()
        }
    }, POSITION_UPDATE_INTERVAL_MILLIS)

    @WorkerThread
    private fun shouldPollPlaybackPosition(): Boolean {
        val state = playbackState?.state
        val moving = if (state == null) false else
                state == PlaybackState.STATE_PLAYING ||
                state == PlaybackState.STATE_BUFFERING ||
                state == PlaybackState.STATE_FAST_FORWARDING ||
                state == PlaybackState.STATE_REWINDING
        return moving && listening
    }

    /** Gets a listener to attach to the seek bar to handle seeking. */
    val seekBarListener: SeekBar.OnSeekBarChangeListener
        get() {
            return SeekBarChangeListener(this, bgExecutor)
        }

    /** Gets a listener to attach to the seek bar to disable touch intercepting. */
    val seekBarTouchListener: View.OnTouchListener
        get() {
            return SeekBarTouchListener()
        }

    private class SeekBarChangeListener(
        val viewModel: SeekBarViewModel,
        val bgExecutor: DelayableExecutor
    ) : SeekBar.OnSeekBarChangeListener {
        override fun onProgressChanged(bar: SeekBar, progress: Int, fromUser: Boolean) {
            if (fromUser) {
                bgExecutor.execute {
                    viewModel.onSeek(progress.toLong())
                }
            }
        }
        override fun onStartTrackingTouch(bar: SeekBar) {
        }
        override fun onStopTrackingTouch(bar: SeekBar) {
            val pos = bar.progress.toLong()
            bgExecutor.execute {
                viewModel.onSeek(pos)
            }
        }
    }

    private class SeekBarTouchListener : View.OnTouchListener {
        override fun onTouch(view: View, event: MotionEvent): Boolean {
            view.parent.requestDisallowInterceptTouchEvent(true)
            return view.onTouchEvent(event)
        }
    }

    /** State seen by seek bar UI. */
    data class Progress(
        val enabled: Boolean,
        val seekAvailable: Boolean,
        val elapsedTime: Int?,
        val duration: Int?,
        val color: Int?
    )
}
+38 −1
Original line number Diff line number Diff line
@@ -16,11 +16,14 @@

package com.android.systemui.qs;

import static com.android.systemui.util.SysuiLifecycle.viewAttachLifecycle;

import android.app.Notification;
import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.Icon;
import android.media.session.MediaController;
import android.media.session.MediaSession;
import android.util.Log;
import android.view.View;
@@ -28,12 +31,16 @@ import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.SeekBar;
import android.widget.TextView;

import com.android.settingslib.media.MediaDevice;
import com.android.systemui.R;
import com.android.systemui.media.MediaControlPanel;
import com.android.systemui.media.SeekBarObserver;
import com.android.systemui.media.SeekBarViewModel;
import com.android.systemui.statusbar.NotificationMediaManager;
import com.android.systemui.util.concurrency.DelayableExecutor;

import java.util.concurrent.Executor;

@@ -54,6 +61,9 @@ public class QSMediaPlayer extends MediaControlPanel {
    };

    private final QSPanel mParent;
    private final DelayableExecutor mBackgroundExecutor;
    private final SeekBarViewModel mSeekBarViewModel;
    private final SeekBarObserver mSeekBarObserver;

    /**
     * Initialize quick shade version of player
@@ -64,10 +74,20 @@ public class QSMediaPlayer extends MediaControlPanel {
     * @param backgroundExecutor
     */
    public QSMediaPlayer(Context context, ViewGroup parent, NotificationMediaManager manager,
            Executor foregroundExecutor, Executor backgroundExecutor) {
            Executor foregroundExecutor, DelayableExecutor backgroundExecutor) {
        super(context, parent, manager, R.layout.qs_media_panel, QS_ACTION_IDS, foregroundExecutor,
                backgroundExecutor);
        mParent = (QSPanel) parent;
        mBackgroundExecutor = backgroundExecutor;
        mSeekBarViewModel = new SeekBarViewModel(backgroundExecutor);
        mSeekBarObserver = new SeekBarObserver(getView());
        // Can't use the viewAttachLifecycle of media player because remove/add is used to adjust
        // priority of players. As soon as it is removed, the lifecycle will end and the seek bar
        // will stop updating. So, use the lifecycle of the parent instead.
        mSeekBarViewModel.getProgress().observe(viewAttachLifecycle(parent), mSeekBarObserver);
        SeekBar bar = getView().findViewById(R.id.media_progress_bar);
        bar.setOnSeekBarChangeListener(mSeekBarViewModel.getSeekBarListener());
        bar.setOnTouchListener(mSeekBarViewModel.getSeekBarTouchListener());
    }

    /**
@@ -115,6 +135,11 @@ public class QSMediaPlayer extends MediaControlPanel {
            thisBtn.setVisibility(View.GONE);
        }

        // Seek Bar
        final MediaController controller = new MediaController(getContext(), token);
        mBackgroundExecutor.execute(
                () -> mSeekBarViewModel.updateController(controller, iconColor));

        // Set up long press menu
        View guts = mMediaNotifView.findViewById(R.id.media_guts);
        View options = mMediaNotifView.findViewById(R.id.qs_media_controls_options);
@@ -155,4 +180,16 @@ public class QSMediaPlayer extends MediaControlPanel {
            return true; // consumed click
        });
    }

    /**
     * Sets the listening state of the player.
     *
     * Should be set to true when the QS panel is open. Otherwise, false. This is a signal to avoid
     * unnecessary work when the QS panel is closed.
     *
     * @param listening True when player should be active. Otherwise, false.
     */
    public void setListening(boolean listening) {
        mSeekBarViewModel.setListening(listening);
    }
}
+7 −3
Original line number Diff line number Diff line
@@ -69,6 +69,7 @@ import com.android.systemui.statusbar.policy.BrightnessMirrorController;
import com.android.systemui.statusbar.policy.BrightnessMirrorController.BrightnessMirrorListener;
import com.android.systemui.tuner.TunerService;
import com.android.systemui.tuner.TunerService.Tunable;
import com.android.systemui.util.concurrency.DelayableExecutor;

import java.io.FileDescriptor;
import java.io.PrintWriter;
@@ -103,7 +104,7 @@ public class QSPanel extends LinearLayout implements Tunable, Callback, Brightne
    private final NotificationMediaManager mNotificationMediaManager;
    private final LocalBluetoothManager mLocalBluetoothManager;
    private final Executor mForegroundExecutor;
    private final Executor mBackgroundExecutor;
    private final DelayableExecutor mBackgroundExecutor;
    private LocalMediaManager mLocalMediaManager;
    private MediaDevice mDevice;
    private boolean mUpdateCarousel = false;
@@ -163,7 +164,7 @@ public class QSPanel extends LinearLayout implements Tunable, Callback, Brightne
            QSLogger qsLogger,
            NotificationMediaManager notificationMediaManager,
            @Main Executor foregroundExecutor,
            @Background Executor backgroundExecutor,
            @Background DelayableExecutor backgroundExecutor,
            @Nullable LocalBluetoothManager localBluetoothManager
    ) {
        super(context, attrs);
@@ -275,7 +276,7 @@ public class QSPanel extends LinearLayout implements Tunable, Callback, Brightne
            Log.d(TAG, "creating new player");
            player = new QSMediaPlayer(mContext, this, mNotificationMediaManager,
                    mForegroundExecutor, mBackgroundExecutor);

            player.setListening(mListening);
            if (player.isPlaying()) {
                mMediaCarousel.addView(player.getView(), 0, lp); // add in front
            } else {
@@ -574,6 +575,9 @@ public class QSPanel extends LinearLayout implements Tunable, Callback, Brightne
        if (mListening) {
            refreshAllTiles();
        }
        for (QSMediaPlayer player : mMediaPlayers) {
            player.setListening(mListening);
        }
    }

    private String getTilesSpecs() {
Loading