Поиск по этому блогу

вторник, 2 июля 2019 г.

AutoPlayRecyclerView для проигрывания видео в списке автоматически

Очень давно я ничего не писал себе в блог, но настоло время вспомнить старые добрые времена и снова заняться этим грязным делом. Наверняка многие сталкивались с такой штукой как автоплей в разного рода приложениях, когда ты доходишь до видео в фиде, и оно начинает автоматически играть. Такую штуку можно сделать разными способами, есть готовые библиотеки которые уже реализовали эти решения, есть либы которые имеют баги, ну или в моем случае мне нужно было реализовать не обычный плеер, из-за этого решения которые я находил не подходили просто мне. 


В случае с этой статьей, я просто приведу пример своего кастомного RecyclerView который я использовал для проигрывания видео в списке. Но так же хочу привести примеры уже готовых решений, таких на пример как Toro Player, который реализован на основе ExoPlayer'a и который выполняет свою работу просто отлично, но к сожалению я не смог найти ему применения у себя в проекте. Но сразу скажу что я советую его всем кто хочет сделать у себя автоплей, стандартный с одним плеером в айтеме — это то что вам нужно. 

image

Мой же пример ориентирован больше на людей которые любят собственные решения и которые хотят понимать что происходит у них в приложении и как это все работает. Опять же, сразу скажу что вариант который я использую в этой статье не мое личное решение, я его нашел на просторах Github'a, и при чем не у одного человека, по этому я не могу утверждать кому оно пренадлежит, но к сожалению оно было изобретено до меня и я могу только им пользоваться. :) 

Теперь после такого долгого вступления можно и начать кодить. Для начала нужно создать пустой проект в который мы подключим наши библиотеки. Писать я нынче буду на котлине, он более так сказать модный щас, да и в принципе с ним получается на порядок меньше кода, по этому будем ориентироваться на него.

app/build.gradle
android {
    ...

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
}

dependencies {
    implementation 'androidx.appcompat:appcompat:1.0.2'

    implementation 'io.reactivex.rxjava2:rxjava:2.1.13'
    implementation 'io.reactivex.rxjava2:rxandroid:2.0.2'

    implementation 'androidx.recyclerview:recyclerview:1.0.0'

    implementation "com.google.android.exoplayer:exoplayer-core:2.9.0"
    implementation "com.google.android.exoplayer:exoplayer-hls:2.9.0"
    implementation "com.google.android.exoplayer:exoplayer-ui:2.9.0"
}

Зачем нам нужна поддержка java 8? Для того что бы мы могли использовать лямбды в нашем коде, так и красивее и выглядит более понятно без простыней реализаций интерфейсов.

По порядку нафига нам столько библиотек:
— androidx — подключается автоматически и нужен для подключения всех сдк которые нужны для работы с андроид.
— rxjava2 и rxandroid — нужны для того что бы мы могли использовать наш AutoPlayRecyclerView который написан с помощью RxJava
— exoplayer — нужен для работы с видео, с его помощью мы будем проигрывать HLS видео в нашем приложении.
— recyclerview — нужен нам для того что бы мы могли наследоваться от него и создать наш собственный RecyclerView с помощью которого будем проигрывать видео.

Далее нам нужно создать вспомогательные классы для нашего AutoPlayRecyclerView, у нас их будет 2, это ViewHolder и LinearLayoutManager.

AutoPlayLinearLayoutManager.kt
import android.content.Context
import android.util.Log
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView

class AutoPlayLinearLayoutManager(context: Context, orientation: Int, reverseLayout: Boolean) :
    LinearLayoutManager(context, orientation, reverseLayout) {

    private val isScrollEnabled = true

    init {
        initialPrefetchItemCount = 10
    }

    override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State) {
        try {
            super.onLayoutChildren(recycler, state)
        } catch (e: IndexOutOfBoundsException) {
            Log.e("Error", "IndexOutOfBoundsException in RecyclerView happens")
        }
    }

    override fun canScrollVertically(): Boolean {
        return isScrollEnabled
    }
}

Этот класс нам нужен для нашего кастомного RecyclerView для того что бы ограничить количество активных айтемов на экране что бы не засрать память телефона и тот ушел бы в OutOfMemory, у нас наш RecyclerView будет recyclable. То есть айтемы за пределами экрана будут удаляться и реюзаться.

VideoHolder.kt
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import dajver.com.videorecyclerview.adapter.holder.ViewHolder

abstract class VideoHolder(itemView: View, mOnPlayerVisibleListener: ViewHolder.OnPlayerVisibleListener) : RecyclerView.ViewHolder(itemView) {

    abstract val videoLayout: View

    abstract fun playVideo()

    abstract fun stopVideo()
}

Тут мы создали отдельный холдер который у нас будет иметь три абстрактных метода через которые мы будем управлять нашим холдером в котором будут наши видео и который будет наследовать этот холдер.

AutoPlayVideoRecyclerView.java
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.util.AttributeSet;
import android.util.DisplayMetrics;
import android.view.View;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import dajver.com.videorecyclerview.player.views.holder.VideoHolder;
import dajver.com.videorecyclerview.player.views.manager.AutoPlayLinearLayoutManager;
import io.reactivex.Observable;
import io.reactivex.ObservableSource;
import io.reactivex.Observer;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.annotations.NonNull;
import io.reactivex.disposables.Disposable;
import io.reactivex.functions.Function;
import io.reactivex.schedulers.Schedulers;
import io.reactivex.subjects.PublishSubject;

import java.util.concurrent.TimeUnit;

public class AutoPlayVideoRecyclerView extends RecyclerView {

    private PublishSubject<Integer> subject;

    private VideoHolder handingVideoHolder;
    private int handingPosition = 0;
    private int newPosition = -1;

    private int heightScreen;

    public AutoPlayVideoRecyclerView(Context context) {
        super(context);
        initView(context);
    }

    public AutoPlayVideoRecyclerView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        initView(context);
    }

    public AutoPlayVideoRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        initView(context);
    }

    private void initView(Context context) {
        setLayoutManager(new AutoPlayLinearLayoutManager(getContext(), RecyclerView.VERTICAL, false));
        getRecycledViewPool().setMaxRecycledViews(0, 5);

        heightScreen = getHeightScreen((Activity) context);
        subject = createSubject();
        addOnScrollListener(new OnScrollListener() {

            @Override
            public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
                super.onScrolled(recyclerView, dx, dy);
                checkPositionHandingViewHolder();
                subject.onNext(dy);
            }
        });
    }

    private void checkPositionHandingViewHolder() {
        if (handingVideoHolder == null) return;
        Observable.just(handingVideoHolder)
                .map(this::getPercentViewHolderInScreen)
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(new Observer<Float>() {
                    @Override
                    public void onSubscribe(@NonNull Disposable d) {
                    }

                    @Override
                    public void onNext(@NonNull Float aFloat) {
                        if (aFloat < 50 && handingVideoHolder != null) {
                            handingVideoHolder.stopVideo();
                            handingVideoHolder = null;
                            handingPosition = -1;
                        }
                    }

                    @Override
                    public void onError(@NonNull Throwable e) {
                    }

                    @Override
                    public void onComplete() {
                    }
                });
    }

    private int getHeightScreen(Activity context) {
        DisplayMetrics displayMetrics = new DisplayMetrics();
        context.getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
        return displayMetrics.heightPixels;
    }

    @SuppressLint("CheckResult")
    private PublishSubject<Integer> createSubject() {
        subject = PublishSubject.create();
        subject.debounce(300, TimeUnit.MILLISECONDS)
                .filter(value -> true)
                .switchMap((Function<Integer, ObservableSource<Integer>>) Observable::just)
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .doOnNext(integer -> {
                    playVideo(integer);
                })
                .subscribe();
        return subject;
    }

    private void playVideo(float value) {
        Observable.just(value)
                .map(aFloat -> {
                    VideoHolder videoHolder = getViewHolderCenterScreen();
                    if (videoHolder == null) return null;
                    if (videoHolder.equals(handingVideoHolder) && handingPosition == newPosition)
                        return null;
                    handingPosition = newPosition;
                    return videoHolder;
                })
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(new Observer<VideoHolder>() {
                    @Override
                    public void onSubscribe(@NonNull Disposable d) {
                    }

                    @Override
                    public void onNext(@NonNull VideoHolder videoHolder) {
                        if (handingVideoHolder != null)
                            handingVideoHolder.stopVideo();

                        videoHolder.playVideo();

                        handingVideoHolder = videoHolder;
                    }

                    @Override
                    public void onError(@NonNull Throwable e) {
                    }

                    @Override
                    public void onComplete() {
                    }
                });
    }

    private VideoHolder getViewHolderCenterScreen() {
        int[] limitPosition = getLimitPositionInScreen();
        int min = limitPosition[0];
        int max = limitPosition[1];

        VideoHolder viewHolderMax = null;
        float percentMax = 0;

        for (int i = min; i <= max; i++) {
            ViewHolder viewHolder = findViewHolderForAdapterPosition(i);
            if (!(viewHolder instanceof VideoHolder)) continue;
            float percentViewHolder = getPercentViewHolderInScreen((VideoHolder) viewHolder);
            if (percentViewHolder > percentMax && percentViewHolder >= 50) {
                percentMax = percentViewHolder;
                viewHolderMax = (VideoHolder) viewHolder;
                newPosition = i;
            }
        }
        return viewHolderMax;
    }

    private float getPercentViewHolderInScreen(VideoHolder viewHolder) {
        if (viewHolder == null) return 0;
        View view = viewHolder.getVideoLayout();

        int[] location = new int[2];
        view.getLocationOnScreen(location);
        int viewHeight = view.getHeight();
        int viewFromY = location[1];
        int viewToY = location[1] + viewHeight;

        if (viewFromY >= 0 && viewToY <= heightScreen) return 100;
        if (viewFromY < 0 && viewToY > heightScreen) return 100;
        if (viewFromY < 0 && viewToY <= heightScreen)
            return ((float) (viewToY - (-viewFromY)) / viewHeight) * 100;
        if (viewFromY >= 0 && viewToY > heightScreen)
            return ((float) (heightScreen - viewFromY) / viewHeight) * 100;
        return 0;
    }

    private int[] getLimitPositionInScreen() {
        int findFirstVisibleItemPosition = ((LinearLayoutManager) getLayoutManager()).findFirstVisibleItemPosition();
        int findFirstCompletelyVisibleItemPosition = ((LinearLayoutManager) getLayoutManager()).findFirstCompletelyVisibleItemPosition();
        int findLastVisibleItemPosition = ((LinearLayoutManager) getLayoutManager()).findLastVisibleItemPosition();
        int findLastCompletelyVisibleItemPosition = ((LinearLayoutManager) getLayoutManager()).findLastCompletelyVisibleItemPosition();

        int min = Math.min(Math.max(findFirstVisibleItemPosition, findFirstCompletelyVisibleItemPosition),
                Math.min(findLastVisibleItemPosition, findLastCompletelyVisibleItemPosition));
        int max = Math.max(Math.min(findFirstVisibleItemPosition, findFirstCompletelyVisibleItemPosition),
                Math.max(findLastVisibleItemPosition, findLastCompletelyVisibleItemPosition));
        return new int[]{min, max};
    }
}

Оооо, с чего бы начать, даже не знаю, у насв этом классе надо выделить пару важных методов:
— initView — логично что в этом методе мы инициализируем наш RecyclerView, задаем LayoutManager, говорим что нам нужно хранить максимум 5 айтемов в нашем списке, получаем высоту экрана для расчета какое видео нам нужно включать, и инициализируем ScrollListener что бы далее получать позицию на экране и включать или выключать видео.
— checkPositionHandingViewHolder — расчитывает эту позицию и по текущему VideoHolder'у он стопает видео если он за пределами зоны видимости.
— getHeightScreen — получает высоту экрана.
— createSubject — с задержкой в 300 милисекунд определяет местоположение центра экрана и включает видео которое находится в этом положении. Задержка сделанна для того что бы не нужно было играть каждое видео которое попадает в фокус, а только то на котором пользователь остановился и желает посмотреть.
— playVideo — проигрывает текущее видео, которое оказалось в фокусе у нашего createSubject().
getViewHolderCenterScreen — очевидно что расчитывает центр экрана и находит на нем VideoHolder который нужно проиграть.
— getPercentViewHolderInScreen — расчитывает насколько точно этот VideoHolder нужно играть, вохможно он не настолько в центре экрана как допустим VideoHolder перед ним или после…
— getLimitPositionInScreen — расчитывает лимиты с верху и снизу экрана, собственно он нам говорит что VideoHolder внизу экрана присутствует или нет, и так же про верхнюю часть экрана. Это offset который нужен что бы у нас играли видео не только в центре экрана но и снизу и сверху, на пример когда по центру у нас не 100% от getPercentViewHolderInScreen().

Вот такая сложная фигня этот класс, его единственного я не смог переписать на котлин, в нем просто творится адская магия, но сам факт, эта магия работает и прям очень даже не плохо, главное не тупить :) В целом этот класс у нас готов, дальше нам нужно создать плеер который мы будем использовать для отображения видео.

Для начала нам нужно создать bootstrap который будет хранить в себе все нужные параметры и настройки. Потом создадим все нужные листенеры, енамы и фабрики. А в конце уже будем создавать собственно сам плеер.

VideoPlayerQuality.kt
enum class VideoPlayerQuality {
    LOWEST, ADAPTIVE
}

Нам нужен этот енам для того что бы разделить качество видосов на низкое и высокое, потому что HLS это видео поток в котором можно регулировать качество видосов, высокое качество нужно для широкоформатного стриминга, на пример в фулл скрине, или там стрим с телефона на телевизор по chromecast'у, а в списке мы будем использовать LOWEST формат для экономии памяти и батареи.

StartupTrackSelectionFactory.kt
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.source.TrackGroup
import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection
import com.google.android.exoplayer2.trackselection.TrackSelection
import com.google.android.exoplayer2.upstream.BandwidthMeter
import com.google.android.exoplayer2.util.Clock

class StartupTrackSelectionFactory(private val bandwidthMeter: BandwidthMeter) : TrackSelection.Factory {

    override fun createTrackSelection(
        group: TrackGroup,
        bandwidthMeter: BandwidthMeter,
        vararg tracks: Int
    ): TrackSelection {
        val adaptiveTrackSelection = AdaptiveTrackSelection(
            group,
            tracks,
            bandwidthMeter,
            AdaptiveTrackSelection.DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS.toLong(),
            AdaptiveTrackSelection.DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS.toLong(),
            AdaptiveTrackSelection.DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS.toLong(),
            AdaptiveTrackSelection.DEFAULT_BANDWIDTH_FRACTION,
            AdaptiveTrackSelection.DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE,
            AdaptiveTrackSelection.DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS,
            Clock.DEFAULT
        )

        var lowestBitrate = Integer.MAX_VALUE
        var lowestBitrateTrackIndex = C.INDEX_UNSET
        for (i in tracks.indices) {
            val format = group.getFormat(tracks[i])
            if (format.bitrate < lowestBitrate) {
                lowestBitrateTrackIndex = i
                lowestBitrate = format.bitrate
            }
            adaptiveTrackSelection.blacklist(tracks[i],
                BLACKLIST_DURATION
            )
        }
        if (lowestBitrateTrackIndex != C.INDEX_UNSET) {
            adaptiveTrackSelection.blacklist(tracks[lowestBitrateTrackIndex], 0)
        }
        return adaptiveTrackSelection
    }

    companion object {

        // end blacklisting after ten seconds earliest
        private val BLACKLIST_DURATION = (10 * 1000).toLong()
    }
}

Этот класс нам нужен для адаптивного стриминга, про который я говорил выше, в этом классе мы говорим стриму что стартовать нам нужно как можно быстрее, по этому в начале проигрыша видео, мы видео играем в низком качестве, в самом низком в котором можно, а по мере проигрывания если появляется возможность улучшить его, мы повышаем.

ExoPlayerVideoPlayerBootstrap.kt
import android.content.Context
import android.net.Uri
import android.os.Handler
import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS
import com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_TARGET_BUFFER_BYTES
import com.google.android.exoplayer2.Player.REPEAT_MODE_ALL
import com.google.android.exoplayer2.source.hls.HlsMediaSource
import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection
import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection.*
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
import com.google.android.exoplayer2.upstream.DefaultAllocator
import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory
import com.google.android.exoplayer2.util.Util
import dajver.com.videorecyclerview.player.bootstrap.enums.VideoPlayerQuality
import dajver.com.videorecyclerview.player.bootstrap.factory.StartupTrackSelectionFactory

open class ExoPlayerVideoPlayerBootstrap(context: Context, videoPlayerQuality: VideoPlayerQuality) {

    var exoPlayer: SimpleExoPlayer
    private var mContext: Context

    var isVideoPrepared = false
    var isVideoPaused = false;
    var isAppStopped = false
    var isBuffering = false

    init {
        val trackSelector = DefaultTrackSelector()
        if (videoPlayerQuality == VideoPlayerQuality.LOWEST) {
            // low quality
            trackSelector.setParameters(lowQualityTrackSelectorFactory())
        } else {
            // high quality
            trackSelector.setParameters(highQualityTrackSelectorFactory())
        }

        val loadControl = DefaultLoadControl(
            DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE),
            DEFAULT_MIN_BUFFER_SIZE_MS,
            DEFAULT_MAX_BUFFER_SIZE_MS,
            DEFAULT_BUFFER_FOR_PLAYBACK_MS,
            DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS,
            DEFAULT_TARGET_BUFFER_BYTES,
            DEFAULT_PRIORITZE_TIME_OVER_THREADS
        )
        exoPlayer = ExoPlayerFactory.newSimpleInstance(context, DefaultRenderersFactory(context), trackSelector, loadControl)
        exoPlayer.setRepeatMode(REPEAT_MODE_ALL)

        mContext = context
    }

    private fun lowQualityTrackSelectorFactory(): DefaultTrackSelector.ParametersBuilder {
        val bandwidthMeter = DefaultBandwidthMeter()
        val adaptiveVideoTrackSelection =
            StartupTrackSelectionFactory(bandwidthMeter)
        val trackSelector = DefaultTrackSelector(adaptiveVideoTrackSelection)
        val parameters: DefaultTrackSelector.ParametersBuilder

        parameters = trackSelector.parameters.buildUpon()
        parameters.setForceLowestBitrate(true)
        parameters.setAllowNonSeamlessAdaptiveness(true)
        return parameters
    }

    private fun highQualityTrackSelectorFactory(): DefaultTrackSelector.ParametersBuilder {
        val parameters: DefaultTrackSelector.ParametersBuilder
        val bandwidthMeter = DefaultBandwidthMeter()

        val videoTrackSelectionFactory = AdaptiveTrackSelection.Factory(bandwidthMeter,
            DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS,
            DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS,
            DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS,
            DEFAULT_BANDWIDTH_FRACTION)

        val trackSelector = DefaultTrackSelector(videoTrackSelectionFactory)
        parameters = trackSelector.parameters.buildUpon()
        parameters.setForceHighestSupportedBitrate(true)
        parameters.setAllowNonSeamlessAdaptiveness(true)
        return parameters
    }

    fun buildMediaSource(uri: Uri): HlsMediaSource {
        val defaultBandwidthMeter = DefaultBandwidthMeter()
        val dataSourceFactory = DefaultDataSourceFactory(mContext, Util.getUserAgent(mContext, "Exo2"), defaultBandwidthMeter)
        return HlsMediaSource.Factory(dataSourceFactory).setAllowChunklessPreparation(true).createMediaSource(uri, Handler(), null)
    }

    fun release() {
        exoPlayer.release()
    }

    companion object {
        private const val DEFAULT_MIN_BUFFER_SIZE_MS = 2600
        private const val DEFAULT_MAX_BUFFER_SIZE_MS = 3000
        private const val DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS = 2000
        private const val DEFAULT_PRIORITZE_TIME_OVER_THREADS = true
    }
}

Что же у нас тут происходит, а происходит вот что, в теле init мы определяем в каком формате нам нужно настраивать поток, и устанавливаем track selector который выбирает в каком качестве играть, в низком или в высоком. Далее создаем SimpleExoPlayer и инициализируем его и указываем что видео у нас будут играть по кругу.
— lowQualityTrackSelectorFactory и highQualityTrackSelectorFactory — нам нужны чисто для сокращения кода в init, в первом мы устанавливаем низкое качество потока, во втором высокое.
— buildMediaSource — нужен для инициализации потока который мы будем проигрывать, собственно тут мы указываем ссылку на видео которое хотим проиграть.

Так же нам понадобится листенер который будет использовать ExoPlayer для понимания статуса видео, готово ли оно для проигрывания или может оно буферится, или запаузено…

PlayerStateChangedListener.kt
import android.util.Log
import com.google.android.exoplayer2.ExoPlaybackException
import com.google.android.exoplayer2.PlaybackParameters
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.Timeline
import com.google.android.exoplayer2.source.TrackGroupArray
import com.google.android.exoplayer2.trackselection.TrackSelectionArray

class PlayerStateChangedListener(private val onPlayerStateChangedListener: OnPlayerStateChangedListener) :
    Player.EventListener {

    override fun onTimelineChanged(timeline: Timeline?, manifest: Any?, reason: Int) {}

    override fun onTracksChanged(trackGroups: TrackGroupArray?, trackSelections: TrackSelectionArray?) {}

    override fun onLoadingChanged(isLoading: Boolean) {}

    override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
        onPlayerStateChangedListener.onPlayerStateChanged(playWhenReady, playbackState, false)
    }

    override fun onRepeatModeChanged(repeatMode: Int) {}

    override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {}

    override fun onPlayerError(error: ExoPlaybackException?) {
        when (error!!.type) {
            ExoPlaybackException.TYPE_SOURCE -> Log.e(TAG, "TYPE_SOURCE: " + error.sourceException.message)
            ExoPlaybackException.TYPE_RENDERER -> Log.e(TAG, "TYPE_RENDERER: " + error.rendererException.message)
            ExoPlaybackException.TYPE_UNEXPECTED -> Log.e(TAG, "TYPE_UNEXPECTED: " + error.unexpectedException.message)
        }
    }

    override fun onPositionDiscontinuity(reason: Int) {}

    override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters?) {}

    override fun onSeekProcessed() {}

    interface OnPlayerStateChangedListener {
        fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int, status: Boolean)
    }

    companion object {
        val TAG = PlayerStateChangedListener::class.java.simpleName
    }
}

Собственно метод onPlayerStateChanged и нужен, его мы и дублируем в наш плеер через интерфейс, остальные методы для нас бесполезны, ну кроме onPlayerError, но он и так выведет нам нужную инфу в случае ошибки в лог.

Теперь мы дошли наконец-то до нашего плеера, ура! Но для начала давайте посмотрим как выглядит xml у плеера.

view_video_player.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              xmlns:app="http://schemas.android.com/apk/res-auto"
              android:layout_width="match_parent"
              android:layout_height="wrap_content"
              android:orientation="vertical">

    <RelativeLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_weight="0.1"
            android:orientation="horizontal">

        <com.google.android.exoplayer2.ui.PlayerView
                android:id="@+id/exoVideoPlayer"
                android:layout_width="match_parent"
                android:layout_height="match_parent" >

        </com.google.android.exoplayer2.ui.PlayerView>

        <ProgressBar
                android:id="@+id/progressBar"
                style="?android:attr/progressBarStyle"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_centerInParent="true"
                android:visibility="gone" />

    </RelativeLayout>

</LinearLayout>

Здесь у нас все придельно тривиально, у нас есть PlayerView и поверх него у нас лежит ProgressBar который будет показываться при загрузке видео и будет прятаться при включении потока.

VideoPlayerView.kt
import android.app.Activity
import android.content.Context
import android.net.Uri
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import androidx.customview.widget.ViewDragHelper.STATE_IDLE
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.Player.STATE_ENDED
import com.google.android.exoplayer2.Player.STATE_READY
import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
import dajver.com.videorecyclerview.adapter.holder.ViewHolder
import dajver.com.videorecyclerview.models.VideosModel
import dajver.com.videorecyclerview.player.bootstrap.ExoPlayerVideoPlayerBootstrap
import dajver.com.videorecyclerview.player.bootstrap.enums.VideoPlayerQuality
import dajver.com.videorecyclerview.player.listener.PlayerStateChangedListener
import kotlinx.android.synthetic.main.view_video_player.view.*

open class VideoPlayerView : LinearLayout, PlayerStateChangedListener.OnPlayerStateChangedListener {

    private lateinit var model: VideosModel
    private lateinit var exoPlayerVideoPlayerBootstrap: ExoPlayerVideoPlayerBootstrap

    private var player: SimpleExoPlayer? = null
    private var quality: VideoPlayerQuality? = null

    val videoHeight: Int get() {
        val metrics = context.resources.displayMetrics
        (context as Activity).windowManager.defaultDisplay.getRealMetrics(metrics)
        val ratio = metrics.widthPixels * metrics.density / (metrics.heightPixels * metrics.density)
        val screenHeight = (metrics.widthPixels * ratio).toInt()
        return screenHeight + PLAYER_RATIO_HEIGHT_OFFSET
    }

    constructor(context: Context) : super(context) {
        init()
    }

    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
        init()
    }

    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        init()
    }

    private fun init() {
        val inflater = LayoutInflater.from(context)
        inflater.inflate(dajver.com.videorecyclerview.R.layout.view_video_player, this)
        setup(VideoPlayerQuality.LOWEST)
    }

    private fun setup(quality: VideoPlayerQuality) {
        this.quality = quality
    }

    fun initPlayer(videosModel: VideosModel, onPlayerVisibleListener: ViewHolder.OnPlayerVisibleListener?) {
        this.model = videosModel

        exoPlayerVideoPlayerBootstrap = ExoPlayerVideoPlayerBootstrap(context, quality!!)
        exoPlayerVideoPlayerBootstrap.isVideoPrepared = false

        if (player == null) {
            player = exoPlayerVideoPlayerBootstrap.exoPlayer
            if (exoVideoPlayer != null) {
                exoVideoPlayer!!.setPlayer(player)
                exoVideoPlayer!!.keepScreenOn
                exoVideoPlayer!!.setKeepContentOnPlayerReset(true)
                exoVideoPlayer!!.useController = false
                exoVideoPlayer!!.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL
            }

            val mediaSource: MediaSource
            mediaSource = exoPlayerVideoPlayerBootstrap.buildMediaSource(Uri.parse(videosModel.videoUrl))

            player!!.prepare(mediaSource, true, true)
            player!!.addListener(PlayerStateChangedListener(this))

            onPlayerVisibleListener?.onActivePlayerView(this)
        }
    }

    fun playVideo() {
        progressBar!!.visibility = View.VISIBLE

        exoPlayerVideoPlayerBootstrap.isAppStopped = false

        if (player != null) {
            player!!.playWhenReady = true
            player!!.playbackState
        }
    }

    fun pauseVideo() {
        if (player != null) {
            player!!.playWhenReady = false
            player!!.playbackState
        }

        exoPlayerVideoPlayerBootstrap.isVideoPaused = true
    }

    fun resumeVideo() {
        if (player != null) {
            player!!.playWhenReady = true
            player!!.playbackState
        }

        exoPlayerVideoPlayerBootstrap.isVideoPaused = false
    }

    fun stopVideo() {
        progressBar!!.visibility = View.GONE

        exoPlayerVideoPlayerBootstrap.isVideoPrepared = false
        exoPlayerVideoPlayerBootstrap.isAppStopped = true

        if (player != null) {
            releasePlayer()
        }
    }

    fun releasePlayer() {
        if (player != null) {
            player!!.playWhenReady = false
            player!!.removeListener(null)
            player!!.stop()
            player!!.release()
            player = null

            exoPlayerVideoPlayerBootstrap.release()
        }
    }

    override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int, status: Boolean) {
        if (!exoPlayerVideoPlayerBootstrap.isVideoPrepared && playbackState == STATE_READY) {
            exoPlayerVideoPlayerBootstrap.isVideoPrepared = true
        }

        if (playbackState == STATE_READY) {
            progressBar!!.visibility = View.GONE
            exoPlayerVideoPlayerBootstrap.isBuffering = false
        } else if (playbackState == Player.STATE_BUFFERING) {
            progressBar!!.visibility = View.VISIBLE
            exoPlayerVideoPlayerBootstrap.isBuffering = true
        }

        if (playbackState == STATE_IDLE || playbackState == STATE_ENDED) {
            progressBar!!.visibility = View.GONE
        }
    }

    companion object {
        const val PLAYER_RATIO_HEIGHT_OFFSET = 100
    }
}

— videoHeight — метод который возвращает высоту видео, он нам нужен для установки высоты видео в айтеме в адаптере.
— init — у нас инициализирует нашу вьюху и устанавливает качество стрима — LOWEST.
— initPlayer нам нужен прежде всего для того что бы мы могли проинициализировать наш плеер когда фокус падает на айтем который пользователь захотел просмотреть. В этом методе мы инициализируем наш bootstrap класс, создаем плеер, задаем все нужные параметры — такие как:
— задаем плеер в котором нужно играть видео.
— устанавливаем что нам нужно что экран будет включен все время сколько видео будет проигрываться.
— видео будет стартовать с начала каждый раз когда будет заканчиваться.
— мы отключили контролы в плеере, по этому они не будут отображаться.
— и установили ресайз для видео что бы оно растягивало его по ширине и высоте, в том резолюшене который мы задали расчитав его в методе videoHeight.

Так же мы задали mediaSource и вызвали prepeare что бы видео начало прогружаться. И так же, кинули коллбек для onPlayerVisibleListener в холдер что у нас появился активный плеер которым дальше из адаптера мы сможем управлять, паузить, резюмить или останавливать.

Дальше идут методы playVideo, pauseVideom resumeVideo и stopVideo — они все повторяют стандартный функционал в котором мы говорим что нам делать с потоком, ну и дергаем нужные переключатели для сохранения статуса потока, что бы не запутаться.

— releasePlayer — останавливает плеер и уничтожает все его инстансы, это нам нужно для того что бы эти переменные не висели без толку если мы их уже не используем.
— onPlayerStateChanged — это наш метод из PlayerStateChangedListener, который возвращает стейт потока, в этом методе мы регулируем, когда показывать прогресс бар, когда его прятать и опять же управляем переключателем isBuffering который нужен нам для разного рода проверок в будущем.

Дальше начинается самая легкая часть, мы создадим адаптер, холдер и подключим все эти штуки в MainActivity для отображения.

Но для начала нам нужно создать модель в которую мы будем загружать ссылки на поток и названия видео.

VideosModel.kt
class VideosModel(var title: String?, var videoUrl: String?)

Обожаю котлин за это :)

ViewHolder.kt
import android.view.View
import android.widget.FrameLayout
import dajver.com.videorecyclerview.models.VideosModel
import dajver.com.videorecyclerview.player.VideoPlayerView
import dajver.com.videorecyclerview.player.views.holder.VideoHolder
import kotlinx.android.synthetic.main.item_video_view_holder.view.*

class ViewHolder(itemView: View, mOnPlayerVisibleListener: OnPlayerVisibleListener) : VideoHolder(itemView, mOnPlayerVisibleListener) {

    private var mVideosModel: VideosModel? = null
    private var onPlayerVisibleListener: OnPlayerVisibleListener? = null

    init {
        this.onPlayerVisibleListener = mOnPlayerVisibleListener
    }

    override val videoLayout: View
        get() = itemView.video

    fun bind(videosModel: VideosModel) {
        mVideosModel = videosModel
        itemView.video.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, itemView.video.videoHeight)
        itemView.title.text = videosModel.title
    }

    override fun playVideo() {
        itemView.video.initPlayer(mVideosModel!!, onPlayerVisibleListener!!)
        itemView.video.playVideo()
    }

    override fun stopVideo() {
        itemView.video.stopVideo()
    }

    interface OnPlayerVisibleListener {
        fun onActivePlayerView(videoPlayerView: VideoPlayerView)
    }
}

Мы унаследовали наш ViewHolder от VideoHolder который у нас используется в нашем AutoPlayRecyclerView, и оно нам предложило создать три метода которые собственно мы и прописывали там. В videoLayout мы передаем наш плеер что бы тот мог управлять им по надобности, а в play и stop прописываем включение и выключение потока.
— init — в этом методе мы инициализируем наш колбек который будет возвращать текущий активный холдер на экране, который проигрывается.
— в методе bind мы просто отображаем что у нас находится в модельке, а это тайтл видео. И задаем высоту видео в айтеме.

item_video_view_holder.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:layout_width="match_parent"
              android:layout_height="wrap_content"
              android:layout_marginTop="10dp"
              android:orientation="vertical">

    <TextView
            android:text="TextView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" android:id="@+id/title" android:padding="15dp"
            android:textColor="@android:color/black" android:textSize="18sp"/>
    <FrameLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

        <dajver.com.videorecyclerview.player.VideoPlayerView
                android:id="@+id/video"
                android:layout_width="match_parent"
                android:layout_height="wrap_content" />

    </FrameLayout>

</LinearLayout>

Просто текст и наш плеер который мы создали, этого достаточно нам для примера как я считаю.

VideoRecyclerAdapter.kt
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import dajver.com.videorecyclerview.R
import dajver.com.videorecyclerview.adapter.holder.ViewHolder
import dajver.com.videorecyclerview.models.VideosModel
import dajver.com.videorecyclerview.player.VideoPlayerView
import dajver.com.videorecyclerview.player.views.AutoPlayVideoRecyclerView
import java.util.*

open class VideoRecyclerAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>(), ViewHolder.OnPlayerVisibleListener {

    private var videoPlayerView: VideoPlayerView? = null
    private var videosModels: MutableList<VideosModel> = ArrayList()

    private var mRecyclerView: AutoPlayVideoRecyclerView? = null
    private val isInternetConnected = true

    fun addItems(blinkEdgeModels: List<VideosModel>) {
        this.videosModels.addAll(blinkEdgeModels)
        notifyDataSetChanged()
    }

    override fun onActivePlayerView(videoPlayerView: VideoPlayerView) {
        this.videoPlayerView = videoPlayerView
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val cardView = LayoutInflater.from(parent.context).inflate(R.layout.item_video_view_holder, parent, false)
        return ViewHolder(cardView, this)
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        val viewHolder = holder as ViewHolder
        viewHolder.bind(videosModels[position])
        holder.setIsRecyclable(true)
    }

    override fun getItemCount(): Int {
        return videosModels.size
    }

    fun stop() {
        if (videoPlayerView != null) {
            videoPlayerView!!.stopVideo()
        }
    }

    fun pause() {
        if (videoPlayerView != null) {
            videoPlayerView!!.pauseVideo()
        }
    }

    fun resumeLastPlayer() {
        if (isInternetConnected && videoPlayerView != null && mRecyclerView != null) {
            videoPlayerView!!.resumeVideo()
        }
    }

    override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
        super.onAttachedToRecyclerView(recyclerView)
        mRecyclerView = recyclerView as AutoPlayVideoRecyclerView
    }

    override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
        super.onDetachedFromRecyclerView(recyclerView)
        mRecyclerView = null
    }
}

Адаптер у нас в полне стандартный, в нем мы просто подписываемся на наш интерфейс который возвращает нам активный плеер, и дальше у нас появляется возможность паузить видео, резюмить или останавливать вообще, на пример когда нам нужно по сворачиванию или выключению приложения сделать так что бы видео не играло в фоне.

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">

    <dajver.com.videorecyclerview.player.views.AutoPlayVideoRecyclerView
            android:id="@+id/recyclerView"
            android:layout_width="match_parent"
            android:layout_height="match_parent"/>

</LinearLayout>

В нашей активити у нас будет отображаться только один RecyclerView который мы написали, более нам больше ничего и не нужно.

MainActivity.kt
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import dajver.com.videorecyclerview.adapter.VideoRecyclerAdapter
import dajver.com.videorecyclerview.models.VideosModel
import kotlinx.android.synthetic.main.activity_main.*

class MainActivity : AppCompatActivity() {

    var videoRecyclerAdapter: VideoRecyclerAdapter? = null

    public override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        var videosList = ArrayList<VideosModel>()
        videosList.add(VideosModel("The redbull party", "https://bitdash-a.akamaihd.net/content/MI201109210084_1/m3u8s/f08e80da-bf1d-4e3d-8899-f0f6155f6efa.m3u8"))
        videosList.add(VideosModel("The mountains","https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8"))
        videosList.add(VideosModel("Tableronde", "https://mnmedias.api.telequebec.tv/m3u8/29880.m3u8"))
        videosList.add(VideosModel("Bunny", "http://184.72.239.149/vod/smil:BigBuckBunny.smil/playlist.m3u8"))
        videosList.add(VideosModel("The redbull party", "https://bitdash-a.akamaihd.net/content/MI201109210084_1/m3u8s/f08e80da-bf1d-4e3d-8899-f0f6155f6efa.m3u8"))
        videosList.add(VideosModel("The mountains", "https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8"))
        videosList.add(VideosModel("Bunny", "http://184.72.239.149/vod/smil:BigBuckBunny.smil/playlist.m3u8"))

        videoRecyclerAdapter = VideoRecyclerAdapter()
        videoRecyclerAdapter!!.addItems(videosList)
        recyclerView.adapter = videoRecyclerAdapter
    }

    public override fun onPause() {
        super.onPause()
        videoRecyclerAdapter?.pause()
    }

    public override fun onDestroy() {
        super.onDestroy()
        videoRecyclerAdapter?.stop()
        recyclerView.adapter = null
    }

    public override fun onStop() {
        super.onStop()
        videoRecyclerAdapter?.pause()
    }

    public override fun onResume() {
        super.onResume()
        videoRecyclerAdapter?.resumeLastPlayer()
    }
}

— onCreate — у нас инициализирует список с ссылками на потоки видео, и адаптер который будет отображать их. 
— onPause, onDestroy, onStop и onResume нам нужны для сохранения жизненного цикла приложения, если пользователь свернет, развернет или выключит приложение, у нас наш поток остановится и не будет играть в фоне, и не создаст проблем при использовании нашего приложения пользователю.

Вроде бы ничего не забыл. Статья оказалась огромной просто, но в любом случае это отличный пример того как можно сделать автоплей в своем приложении. 

Исходники:
GitHub

2 комментария:

  1. Спасибо, Глеб! Ваше "грязное дело" да пусть не отсохнет:) Приятно было узнать, что Вы из Харькова.

    ОтветитьУдалить