Add Episodes List to Player #18
|
@ -11,7 +11,7 @@ android {
|
|||
minSdkVersion 23
|
||||
targetSdkVersion 30
|
||||
versionCode 2100 //00.02.100
|
||||
versionName "0.2.90"
|
||||
versionName "0.2.91"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
resValue "string", "build_time", buildTime()
|
||||
|
|
|
@ -29,8 +29,8 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.withContext
|
||||
import org.mosad.teapod.R
|
||||
import org.mosad.teapod.preferences.Preferences
|
||||
import org.mosad.teapod.ui.components.EpisodesListPlayer
|
||||
import org.mosad.teapod.util.DataTypes
|
||||
import org.mosad.teapod.util.Episode
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.concurrent.scheduleAtFixedRate
|
||||
|
@ -124,6 +124,12 @@ class PlayerActivity : AppCompatActivity() {
|
|||
initExoPlayer()
|
||||
initVideoView()
|
||||
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() {
|
||||
|
@ -151,16 +157,16 @@ class PlayerActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
if (state == ExoPlayer.STATE_ENDED && model.nextEpisode != null && Preferences.autoplay) {
|
||||
if (nextEpManually) {
|
||||
nextEpManually = false
|
||||
} else {
|
||||
// if next episode btn was clicked, skipp playNextEpisode() on STATE_ENDED
|
||||
if (!nextEpManually) {
|
||||
playNextEpisode()
|
||||
}
|
||||
nextEpManually = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
playCurrentMedia(true)
|
||||
playCurrentMedia(true) // start initial media
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
|
@ -187,6 +193,7 @@ class PlayerActivity : AppCompatActivity() {
|
|||
ffwd_10.setOnButtonClickListener { fastForward() }
|
||||
button_next_ep.setOnClickListener { playNextEpisode() }
|
||||
button_next_ep_c.setOnClickListener { playNextEpisode() }
|
||||
button_episodes.setOnClickListener { showEpisodesList() }
|
||||
}
|
||||
|
||||
private fun initTimeUpdates() {
|
||||
|
@ -292,19 +299,12 @@ class PlayerActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
private fun togglePausePlay() {
|
||||
if (player.isPlaying) {
|
||||
player.pause()
|
||||
} else {
|
||||
player.play()
|
||||
}
|
||||
if (player.isPlaying) player.pause() else player.play()
|
||||
}
|
||||
|
||||
private fun playNextEpisode() = model.nextEpisode?.let {
|
||||
model.nextEpisode() // current = next, next = new or null
|
||||
hideButtonNextEp()
|
||||
|
||||
nextEpManually = true
|
||||
playCurrentMedia(false)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -314,7 +314,11 @@ class PlayerActivity : AppCompatActivity() {
|
|||
private fun playCurrentMedia(seekToPosition: Boolean) {
|
||||
// update the gui
|
||||
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 {
|
||||
model.currentEpisode.title
|
||||
}
|
||||
|
@ -323,30 +327,16 @@ class PlayerActivity : AppCompatActivity() {
|
|||
button_next_ep_c.visibility = View.GONE
|
||||
}
|
||||
|
||||
// update player/media item
|
||||
player.playWhenReady = true
|
||||
player.clearMediaItems() //remove previous item
|
||||
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)
|
||||
player.setMediaSource(mediaSource)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* hide the next episode button
|
||||
* 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() {
|
||||
|
||||
/**
|
||||
|
@ -419,11 +418,7 @@ class PlayerActivity : AppCompatActivity() {
|
|||
val viewCenterX = video_view.measuredWidth / 2
|
||||
|
||||
// if the event position is on the left side rewind, if it's on the right forward
|
||||
if (eventPosX < viewCenterX) {
|
||||
rewind()
|
||||
} else {
|
||||
fastForward()
|
||||
}
|
||||
if (eventPosX < viewCenterX) rewind() else fastForward()
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
|
@ -1,15 +1,25 @@
|
|||
package org.mosad.teapod.player
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.mosad.teapod.parser.AoDParser
|
||||
import org.mosad.teapod.preferences.Preferences
|
||||
import org.mosad.teapod.ui.fragments.MediaFragment
|
||||
import org.mosad.teapod.util.DataTypes
|
||||
import org.mosad.teapod.util.Episode
|
||||
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() {
|
||||
|
||||
val currentEpisodeChangedListener = ArrayList<() -> Unit>()
|
||||
|
||||
var mediaId = 0
|
||||
internal set
|
||||
var episodeId = 0
|
||||
|
@ -17,8 +27,11 @@ class PlayerViewModel : ViewModel() {
|
|||
|
||||
var media: Media = Media(0, "", DataTypes.MediaType.OTHER)
|
||||
internal set
|
||||
var currentEpisode = Episode()
|
||||
internal set
|
||||
var currentEpisode: Episode by Delegates.observable(Episode()) { _, _, _ ->
|
||||
currentEpisodeChangedListener.forEach { it() }
|
||||
MediaFragment.instance.updateWatchedState(currentEpisode) // watchedCallback for the new episode
|
||||
nextEpisode = selectNextEpisode() // update next ep
|
||||
}
|
||||
var nextEpisode: Episode? = null
|
||||
internal set
|
||||
|
||||
|
@ -32,6 +45,24 @@ class PlayerViewModel : ViewModel() {
|
|||
|
||||
currentEpisode = media.episodes.first { it.id == episodeId }
|
||||
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 ->
|
||||
currentEpisode = nextEp // set current ep to next ep
|
||||
episodeId = nextEp.id
|
||||
MediaFragment.instance.updateWatchedState(nextEp) // watchedCallback for next ep
|
||||
|
||||
nextEpisode = selectNextEpisode()
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -52,7 +80,7 @@ class PlayerViewModel : ViewModel() {
|
|||
*/
|
||||
private fun selectNextEpisode(): Episode? {
|
||||
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]
|
||||
} else {
|
||||
null
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -155,12 +155,6 @@ class MediaFragment(private val mediaId: Int) : Fragment() {
|
|||
private fun playEpisode(ep: Episode) {
|
||||
playStream(ep)
|
||||
|
||||
// update watched state
|
||||
updateWatchedState(ep)
|
||||
//AoDParser.sendCallback(ep.watchedCallback)
|
||||
//adapterRecEpisodes.updateWatchedState(true, media.episodes.indexOf(ep))
|
||||
//adapterRecEpisodes.notifyDataSetChanged()
|
||||
|
||||
// update nextEpisode
|
||||
nextEpisode = if (media.episodes.firstOrNull{ !it.watched } != null) {
|
||||
media.episodes.first{ !it.watched }
|
||||
|
|
|
@ -61,6 +61,7 @@ data class Info(
|
|||
|
||||
/**
|
||||
* if secStreamOmU == true, then a secondary stream is present
|
||||
* number = episode number (0..n)
|
||||
*/
|
||||
data class Episode(
|
||||
val id: Int = 0,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
|
|
|
@ -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>
|
|
@ -138,7 +138,6 @@
|
|||
android:layout_marginEnd="7dp"
|
||||
android:text="@string/episodes"
|
||||
android:textAllCaps="false"
|
||||
android:visibility="gone"
|
||||
app:icon="@drawable/ic_baseline_video_library_24"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/button_next_ep_c"
|
||||
|
|
|
@ -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>
|
Loading…
Reference in New Issue