Merge pull request 'Add Episodes List to Player' (#18) from feature/episodes_list_player into master

Reviewed-on: #18
This commit is contained in:
Jannik 2020-12-15 23:16:33 +01:00
commit 1edcf29c07
11 changed files with 257 additions and 53 deletions

View File

@ -11,7 +11,7 @@ android {
minSdkVersion 23 minSdkVersion 23
targetSdkVersion 30 targetSdkVersion 30
versionCode 2100 //00.02.100 versionCode 2100 //00.02.100
versionName "0.2.90" versionName "0.2.91"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resValue "string", "build_time", buildTime() resValue "string", "build_time", buildTime()

View File

@ -29,8 +29,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.mosad.teapod.R import org.mosad.teapod.R
import org.mosad.teapod.preferences.Preferences import org.mosad.teapod.preferences.Preferences
import org.mosad.teapod.ui.components.EpisodesListPlayer
import org.mosad.teapod.util.DataTypes import org.mosad.teapod.util.DataTypes
import org.mosad.teapod.util.Episode
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.concurrent.scheduleAtFixedRate import kotlin.concurrent.scheduleAtFixedRate
@ -124,6 +124,12 @@ class PlayerActivity : AppCompatActivity() {
initExoPlayer() initExoPlayer()
initVideoView() initVideoView()
initTimeUpdates() initTimeUpdates()
// add listener after initial media is started
model.currentEpisodeChangedListener.add {
nextEpManually = true // make sure on STATE_ENDED doesn't skip another episode
playCurrentMedia(false)
}
} }
private fun initExoPlayer() { private fun initExoPlayer() {
@ -151,16 +157,16 @@ class PlayerActivity : AppCompatActivity() {
} }
if (state == ExoPlayer.STATE_ENDED && model.nextEpisode != null && Preferences.autoplay) { if (state == ExoPlayer.STATE_ENDED && model.nextEpisode != null && Preferences.autoplay) {
if (nextEpManually) { // if next episode btn was clicked, skipp playNextEpisode() on STATE_ENDED
nextEpManually = false if (!nextEpManually) {
} else {
playNextEpisode() playNextEpisode()
} }
nextEpManually = false
} }
} }
}) })
playCurrentMedia(true) playCurrentMedia(true) // start initial media
} }
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
@ -187,6 +193,7 @@ class PlayerActivity : AppCompatActivity() {
ffwd_10.setOnButtonClickListener { fastForward() } ffwd_10.setOnButtonClickListener { fastForward() }
button_next_ep.setOnClickListener { playNextEpisode() } button_next_ep.setOnClickListener { playNextEpisode() }
button_next_ep_c.setOnClickListener { playNextEpisode() } button_next_ep_c.setOnClickListener { playNextEpisode() }
button_episodes.setOnClickListener { showEpisodesList() }
} }
private fun initTimeUpdates() { private fun initTimeUpdates() {
@ -292,19 +299,12 @@ class PlayerActivity : AppCompatActivity() {
} }
private fun togglePausePlay() { private fun togglePausePlay() {
if (player.isPlaying) { if (player.isPlaying) player.pause() else player.play()
player.pause()
} else {
player.play()
}
} }
private fun playNextEpisode() = model.nextEpisode?.let { private fun playNextEpisode() = model.nextEpisode?.let {
model.nextEpisode() // current = next, next = new or null model.nextEpisode() // current = next, next = new or null
hideButtonNextEp() hideButtonNextEp()
nextEpManually = true
playCurrentMedia(false)
} }
/** /**
@ -314,7 +314,11 @@ class PlayerActivity : AppCompatActivity() {
private fun playCurrentMedia(seekToPosition: Boolean) { private fun playCurrentMedia(seekToPosition: Boolean) {
// update the gui // update the gui
exo_text_title.text = if (model.media.type == DataTypes.MediaType.TVSHOW) { exo_text_title.text = if (model.media.type == DataTypes.MediaType.TVSHOW) {
getString(R.string.component_episode_title, model.currentEpisode.number, model.currentEpisode.description) getString(
R.string.component_episode_title,
model.currentEpisode.number,
model.currentEpisode.description
)
} else { } else {
model.currentEpisode.title model.currentEpisode.title
} }
@ -323,30 +327,16 @@ class PlayerActivity : AppCompatActivity() {
button_next_ep_c.visibility = View.GONE button_next_ep_c.visibility = View.GONE
} }
// update player/media item
player.playWhenReady = true
player.clearMediaItems() //remove previous item player.clearMediaItems() //remove previous item
val mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource( val mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(
MediaItem.fromUri(Uri.parse(autoSelectStream(model.currentEpisode))) MediaItem.fromUri(Uri.parse(model.autoSelectStream(model.currentEpisode)))
) )
if (seekToPosition) player.seekTo(playbackPosition) if (seekToPosition) player.seekTo(playbackPosition)
player.setMediaSource(mediaSource) player.setMediaSource(mediaSource)
player.prepare() player.prepare()
}
/**
* If preferSecondary or priStreamUrl is empty and secondary is present (secStreamOmU),
* use the secondary stream. Else, if the primary stream is set use the primary stream.
* If no stream is present, close the activity.
*/
private fun autoSelectStream(episode: Episode): String {
return if ((Preferences.preferSecondary || episode.priStreamUrl.isEmpty()) && episode.secStreamOmU) {
episode.secStreamUrl
} else if (episode.priStreamUrl.isNotEmpty()) {
episode.priStreamUrl
} else {
Log.e(javaClass.name, "No stream url set.")
this.finish()
""
}
} }
/** /**
@ -383,8 +373,6 @@ class PlayerActivity : AppCompatActivity() {
.setListener(null) .setListener(null)
} }
/** /**
* hide the next episode button * hide the next episode button
* TODO improve the hide animation * TODO improve the hide animation
@ -401,6 +389,17 @@ class PlayerActivity : AppCompatActivity() {
} }
private fun showEpisodesList() {
val episodesList = EpisodesListPlayer(this, model = model).apply {
onViewRemovedAction = { player.play() }
}
player_layout.addView(episodesList)
// hide player controls and pause playback
player.pause()
controller.hideImmediately()
}
inner class PlayerGestureListener : GestureDetector.SimpleOnGestureListener() { inner class PlayerGestureListener : GestureDetector.SimpleOnGestureListener() {
/** /**
@ -419,11 +418,7 @@ class PlayerActivity : AppCompatActivity() {
val viewCenterX = video_view.measuredWidth / 2 val viewCenterX = video_view.measuredWidth / 2
// if the event position is on the left side rewind, if it's on the right forward // if the event position is on the left side rewind, if it's on the right forward
if (eventPosX < viewCenterX) { if (eventPosX < viewCenterX) rewind() else fastForward()
rewind()
} else {
fastForward()
}
return true return true
} }

View File

@ -1,15 +1,25 @@
package org.mosad.teapod.player package org.mosad.teapod.player
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.mosad.teapod.parser.AoDParser import org.mosad.teapod.parser.AoDParser
import org.mosad.teapod.preferences.Preferences
import org.mosad.teapod.ui.fragments.MediaFragment import org.mosad.teapod.ui.fragments.MediaFragment
import org.mosad.teapod.util.DataTypes import org.mosad.teapod.util.DataTypes
import org.mosad.teapod.util.Episode import org.mosad.teapod.util.Episode
import org.mosad.teapod.util.Media import org.mosad.teapod.util.Media
import kotlin.properties.Delegates
/**
* PlayerViewModel handles all stuff related to media/episodes.
* When currentEpisode is changed the player will start playing it (not initial media),
* the next episode will be update and the callback is handled.
*/
class PlayerViewModel : ViewModel() { class PlayerViewModel : ViewModel() {
val currentEpisodeChangedListener = ArrayList<() -> Unit>()
var mediaId = 0 var mediaId = 0
internal set internal set
var episodeId = 0 var episodeId = 0
@ -17,8 +27,11 @@ class PlayerViewModel : ViewModel() {
var media: Media = Media(0, "", DataTypes.MediaType.OTHER) var media: Media = Media(0, "", DataTypes.MediaType.OTHER)
internal set internal set
var currentEpisode = Episode() var currentEpisode: Episode by Delegates.observable(Episode()) { _, _, _ ->
internal set currentEpisodeChangedListener.forEach { it() }
MediaFragment.instance.updateWatchedState(currentEpisode) // watchedCallback for the new episode
nextEpisode = selectNextEpisode() // update next ep
}
var nextEpisode: Episode? = null var nextEpisode: Episode? = null
internal set internal set
@ -32,6 +45,24 @@ class PlayerViewModel : ViewModel() {
currentEpisode = media.episodes.first { it.id == episodeId } currentEpisode = media.episodes.first { it.id == episodeId }
nextEpisode = selectNextEpisode() nextEpisode = selectNextEpisode()
currentEpisode
}
/**
* If preferSecondary or priStreamUrl is empty and secondary is present (secStreamOmU),
* use the secondary stream. Else, if the primary stream is set use the primary stream.
* If no stream is present, return empty string.
*/
fun autoSelectStream(episode: Episode): String {
return if ((Preferences.preferSecondary || episode.priStreamUrl.isEmpty()) && episode.secStreamOmU) {
episode.secStreamUrl
} else if (episode.priStreamUrl.isNotEmpty()) {
episode.priStreamUrl
} else {
Log.e(javaClass.name, "No stream url set. ${episode.id}")
""
}
} }
/** /**
@ -41,9 +72,6 @@ class PlayerViewModel : ViewModel() {
fun nextEpisode() = nextEpisode?.let { nextEp -> fun nextEpisode() = nextEpisode?.let { nextEp ->
currentEpisode = nextEp // set current ep to next ep currentEpisode = nextEp // set current ep to next ep
episodeId = nextEp.id episodeId = nextEp.id
MediaFragment.instance.updateWatchedState(nextEp) // watchedCallback for next ep
nextEpisode = selectNextEpisode()
} }
/** /**
@ -52,7 +80,7 @@ class PlayerViewModel : ViewModel() {
*/ */
private fun selectNextEpisode(): Episode? { private fun selectNextEpisode(): Episode? {
val nextEpIndex = media.episodes.indexOfFirst { it.id == currentEpisode.id } + 1 val nextEpIndex = media.episodes.indexOfFirst { it.id == currentEpisode.id } + 1
return if (nextEpIndex < (media.episodes.size)) { return if (nextEpIndex < media.episodes.size) {
media.episodes[nextEpIndex] media.episodes[nextEpIndex]
} else { } else {
null null

View File

@ -0,0 +1,43 @@
package org.mosad.teapod.ui.components
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.LinearLayout
import org.mosad.teapod.databinding.PlayerEpisodesListBinding
import org.mosad.teapod.player.PlayerViewModel
import org.mosad.teapod.util.adapter.PlayerEpisodeItemAdapter
class EpisodesListPlayer @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
model: PlayerViewModel? = null
) : LinearLayout(context, attrs, defStyleAttr) {
private val binding = PlayerEpisodesListBinding.inflate(LayoutInflater.from(context), this, true)
private lateinit var adapterRecEpisodes: PlayerEpisodeItemAdapter
var onViewRemovedAction: (() -> Unit)? = null // TODO find a better solution for this
init {
binding.buttonCloseEpisodesList.setOnClickListener {
(this.parent as ViewGroup).removeView(this)
onViewRemovedAction?.invoke()
}
model?.let {
adapterRecEpisodes = PlayerEpisodeItemAdapter(it.media.episodes)
adapterRecEpisodes.onImageClick = { _, position ->
(this.parent as ViewGroup).removeView(this)
it.currentEpisode = it.media.episodes[position]
}
binding.recyclerEpisodesPlayer.adapter = adapterRecEpisodes
binding.recyclerEpisodesPlayer.scrollToPosition(it.currentEpisode.number - 1) // number != index
}
}
}

View File

@ -155,12 +155,6 @@ class MediaFragment(private val mediaId: Int) : Fragment() {
private fun playEpisode(ep: Episode) { private fun playEpisode(ep: Episode) {
playStream(ep) playStream(ep)
// update watched state
updateWatchedState(ep)
//AoDParser.sendCallback(ep.watchedCallback)
//adapterRecEpisodes.updateWatchedState(true, media.episodes.indexOf(ep))
//adapterRecEpisodes.notifyDataSetChanged()
// update nextEpisode // update nextEpisode
nextEpisode = if (media.episodes.firstOrNull{ !it.watched } != null) { nextEpisode = if (media.episodes.firstOrNull{ !it.watched } != null) {
media.episodes.first{ !it.watched } media.episodes.first{ !it.watched }

View File

@ -61,6 +61,7 @@ data class Info(
/** /**
* if secStreamOmU == true, then a secondary stream is present * if secStreamOmU == true, then a secondary stream is present
* number = episode number (0..n)
*/ */
data class Episode( data class Episode(
val id: Int = 0, val id: Int = 0,

View File

@ -0,0 +1,52 @@
package org.mosad.teapod.util.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import jp.wasabeef.glide.transformations.RoundedCornersTransformation
import org.mosad.teapod.R
import org.mosad.teapod.databinding.ItemEpisodePlayerBinding
import org.mosad.teapod.util.Episode
class PlayerEpisodeItemAdapter(private val episodes: List<Episode>) : RecyclerView.Adapter<PlayerEpisodeItemAdapter.EpisodeViewHolder>() {
var onImageClick: ((String, Int) -> Unit)? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EpisodeViewHolder {
return EpisodeViewHolder(ItemEpisodePlayerBinding.inflate(LayoutInflater.from(parent.context), parent, false))
}
override fun onBindViewHolder(holder: EpisodeViewHolder, position: Int) {
val context = holder.binding.root.context
val ep = episodes[position]
val titleText = if (ep.priStreamUrl.isEmpty() && ep.secStreamOmU) {
context.getString(R.string.component_episode_title_sub, ep.number, ep.description)
} else {
context.getString(R.string.component_episode_title, ep.number, ep.description)
}
holder.binding.textEpisodeTitle2.text = titleText
holder.binding.textEpisodeDesc2.text = ep.shortDesc
if (episodes[position].posterUrl.isNotEmpty()) {
Glide.with(context).load(ep.posterUrl)
.apply(RequestOptions.bitmapTransform(RoundedCornersTransformation(10, 0)))
.into(holder.binding.imageEpisode)
}
}
override fun getItemCount(): Int {
return episodes.size
}
inner class EpisodeViewHolder(val binding: ItemEpisodePlayerBinding) : RecyclerView.ViewHolder(binding.root) {
init {
binding.imageEpisode.setOnClickListener {
onImageClick?.invoke(episodes[adapterPosition].title, adapterPosition)
}
}
}
}

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android" <androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"

View File

@ -0,0 +1,50 @@
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="7dp" >
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/image_episode"
android:layout_width="192dp"
android:layout_height="108dp"
android:contentDescription="@string/component_poster_desc"
app:shapeAppearance="@style/ShapeAppearance.Teapod.RoundedPoster"
app:srcCompat="@color/md_disabled_text_dark_theme" />
<ImageView
android:id="@+id/image_episode_play"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="@drawable/bg_circle__black_transparent_24dp"
android:contentDescription="@string/button_play"
app:srcCompat="@drawable/ic_baseline_play_arrow_24"
app:tint="#FFFFFF" />
</FrameLayout>
<TextView
android:id="@+id/text_episode_title2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="7dp"
android:text="@string/component_episode_title"
android:textColor="@color/textPrimaryDark"
android:textSize="16sp" />
<TextView
android:id="@+id/text_episode_desc2"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="5dp"
android:text="@string/text_overview_ex"
android:textColor="@color/textPrimaryDark"/>
</LinearLayout>

View File

@ -138,7 +138,6 @@
android:layout_marginEnd="7dp" android:layout_marginEnd="7dp"
android:text="@string/episodes" android:text="@string/episodes"
android:textAllCaps="false" android:textAllCaps="false"
android:visibility="gone"
app:icon="@drawable/ic_baseline_video_library_24" app:icon="@drawable/ic_baseline_video_library_24"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/button_next_ep_c" app:layout_constraintEnd_toStartOf="@+id/button_next_ep_c"

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#73000000">
<LinearLayout
android:id="@+id/linearLayout3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="7dp"
android:layout_marginEnd="12dp"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageButton
android:id="@+id/button_close_episodes_list"
style="@style/ExoStyledControls.Button.Center"
android:layout_width="44dp"
android:layout_height="44dp"
android:contentDescription="@string/close_player"
android:padding="10dp"
app:srcCompat="@drawable/ic_baseline_arrow_back_24" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_episodes_player"
android:layout_width="0dp"
android:layout_height="0dp"
android:orientation="horizontal"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/linearLayout3"
tools:listitem="@layout/item_episode_player" />
</androidx.constraintlayout.widget.ConstraintLayout>