299 lines
10 KiB
Kotlin
299 lines
10 KiB
Kotlin
/**
|
|
* Teapod
|
|
*
|
|
* Copyright 2020-2022 <seil0@mosad.xyz>
|
|
*
|
|
* This program is free software; you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation; either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program; if not, write to the Free Software
|
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
|
|
* MA 02110-1301, USA.
|
|
*
|
|
*/
|
|
|
|
package org.mosad.teapod.ui.activity.player
|
|
|
|
import android.app.Application
|
|
import android.net.Uri
|
|
import android.support.v4.media.session.MediaSessionCompat
|
|
import android.util.Log
|
|
import androidx.lifecycle.AndroidViewModel
|
|
import androidx.lifecycle.viewModelScope
|
|
import com.google.android.exoplayer2.ExoPlayer
|
|
import com.google.android.exoplayer2.MediaItem
|
|
import com.google.android.exoplayer2.Player
|
|
import com.google.android.exoplayer2.SimpleExoPlayer
|
|
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
|
import com.google.android.exoplayer2.source.hls.HlsMediaSource
|
|
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory
|
|
import com.google.android.exoplayer2.util.Util
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.joinAll
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.runBlocking
|
|
import org.mosad.teapod.R
|
|
import org.mosad.teapod.parser.crunchyroll.Crunchyroll
|
|
import org.mosad.teapod.parser.crunchyroll.NoneEpisode
|
|
import org.mosad.teapod.parser.crunchyroll.NoneEpisodes
|
|
import org.mosad.teapod.parser.crunchyroll.NonePlayback
|
|
import org.mosad.teapod.preferences.Preferences
|
|
import org.mosad.teapod.util.EpisodeMeta
|
|
import org.mosad.teapod.util.Meta
|
|
import org.mosad.teapod.util.TVShowMeta
|
|
import org.mosad.teapod.util.tmdb.TMDBTVSeason
|
|
import java.util.*
|
|
|
|
/**
|
|
* 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(application: Application) : AndroidViewModel(application) {
|
|
|
|
val player = SimpleExoPlayer.Builder(application).build()
|
|
private val dataSourceFactory = DefaultDataSourceFactory(application, Util.getUserAgent(application, "Teapod"))
|
|
private val mediaSession = MediaSessionCompat(application, "TEAPOD_PLAYER_SESSION")
|
|
|
|
val currentEpisodeChangedListener = ArrayList<() -> Unit>()
|
|
private val preferredLanguage = if (Preferences.preferSecondary) Locale.JAPANESE else Locale.GERMAN
|
|
private var currentPlayhead: Long = 0
|
|
|
|
// tmdb/meta data TODO currently not implemented for cr
|
|
var mediaMeta: Meta? = null
|
|
internal set
|
|
var tmdbTVSeason: TMDBTVSeason? =null
|
|
internal set
|
|
var currentEpisodeMeta: EpisodeMeta? = null
|
|
internal set
|
|
|
|
// crunchyroll episodes/playback
|
|
var episodes = NoneEpisodes
|
|
internal set
|
|
var currentEpisode = NoneEpisode
|
|
internal set
|
|
var currentPlayback = NonePlayback
|
|
|
|
// current playback settings
|
|
var currentLanguage: Locale = Preferences.preferredLocal
|
|
internal set
|
|
|
|
init {
|
|
initMediaSession()
|
|
|
|
player.addListener(object : Player.Listener {
|
|
override fun onPlaybackStateChanged(state: Int) {
|
|
super.onPlaybackStateChanged(state)
|
|
|
|
if (state == ExoPlayer.STATE_ENDED) updatePlayhead()
|
|
}
|
|
|
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
|
super.onIsPlayingChanged(isPlaying)
|
|
|
|
if (!isPlaying) updatePlayhead()
|
|
}
|
|
})
|
|
|
|
|
|
}
|
|
|
|
override fun onCleared() {
|
|
super.onCleared()
|
|
|
|
mediaSession.release()
|
|
player.release()
|
|
|
|
Log.d(javaClass.name, "Released player")
|
|
}
|
|
|
|
/**
|
|
* set the media session to active
|
|
* create a media session connector to set title and description
|
|
*/
|
|
private fun initMediaSession() {
|
|
val mediaSessionConnector = MediaSessionConnector(mediaSession)
|
|
mediaSessionConnector.setPlayer(player)
|
|
|
|
mediaSession.isActive = true
|
|
}
|
|
|
|
fun loadMediaAsync(seasonId: String, episodeId: String) = viewModelScope.launch {
|
|
episodes = Crunchyroll.episodes(seasonId)
|
|
|
|
setCurrentEpisode(episodeId)
|
|
playCurrentMedia(currentPlayhead)
|
|
|
|
// TODO reimplement for cr
|
|
// run async as it should be loaded by the time the episodes a
|
|
// viewModelScope.launch {
|
|
// // get tmdb season info, if metaDB knows the tv show
|
|
// if (media.type == DataTypes.MediaType.TVSHOW && mediaMeta != null) {
|
|
// val tvShowMeta = mediaMeta as TVShowMeta
|
|
// tmdbTVSeason = TMDBApiController().getTVSeasonDetails(tvShowMeta.tmdbId, tvShowMeta.tmdbSeasonNumber)
|
|
// }
|
|
// }
|
|
//
|
|
// currentEpisodeMeta = getEpisodeMetaByAoDMediaId(currentEpisodeAoD.mediaId)
|
|
// currentLanguage = currentEpisodeAoD.getPreferredStream(preferredLanguage).language
|
|
}
|
|
|
|
fun setLanguage(language: Locale) {
|
|
currentLanguage = language
|
|
playCurrentMedia(player.currentPosition)
|
|
}
|
|
|
|
// player actions
|
|
|
|
fun seekToOffset(offset: Long) {
|
|
player.seekTo(player.currentPosition + offset)
|
|
}
|
|
|
|
fun togglePausePlay() {
|
|
if (player.isPlaying) player.pause() else player.play()
|
|
}
|
|
|
|
/**
|
|
* play the next episode, if nextEpisodeId is not null
|
|
*/
|
|
fun playNextEpisode() = currentEpisode.nextEpisodeId?.let { nextEpisodeId ->
|
|
setCurrentEpisode(nextEpisodeId, startPlayback = true)
|
|
}
|
|
|
|
/**
|
|
* Set currentEpisodeCr to the episode of the given ID
|
|
* @param episodeId The ID of the episode you want to set currentEpisodeCr to
|
|
*/
|
|
fun setCurrentEpisode(episodeId: String, startPlayback: Boolean = false) {
|
|
currentEpisode = episodes.items.find { episode ->
|
|
episode.id == episodeId
|
|
} ?: NoneEpisode
|
|
|
|
// update player gui (title, next ep button) after currentEpisode has changed
|
|
currentEpisodeChangedListener.forEach { it() }
|
|
|
|
// needs to be blocking, currentPlayback must be present when calling playCurrentMedia()
|
|
runBlocking {
|
|
joinAll(
|
|
viewModelScope.launch(Dispatchers.IO) {
|
|
currentPlayback = Crunchyroll.playback(currentEpisode.playback)
|
|
},
|
|
viewModelScope.launch(Dispatchers.IO) {
|
|
Crunchyroll.playheads(listOf(currentEpisode.id))[currentEpisode.id]?.let {
|
|
currentPlayhead = (it.playhead.times(1000)).toLong()
|
|
}
|
|
}
|
|
)
|
|
}
|
|
println("loaded playback ${currentEpisode.playback}")
|
|
|
|
// TODO update metadata and language (it should not be needed to update the language here!)
|
|
|
|
if (startPlayback) {
|
|
playCurrentMedia()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Play the current media from currentPlaybackCr.
|
|
*
|
|
* @param seekPosition The seek position for the episode (default = 0).
|
|
*/
|
|
fun playCurrentMedia(seekPosition: Long = 0) {
|
|
// get preferred stream url, set current language if it differs from the preferred one
|
|
val preferredLocale = currentLanguage
|
|
val fallbackLocal = Locale.US
|
|
val url = when {
|
|
currentPlayback.streams.adaptive_hls.containsKey(preferredLocale.toLanguageTag()) -> {
|
|
currentPlayback.streams.adaptive_hls[preferredLocale.toLanguageTag()]?.url
|
|
}
|
|
currentPlayback.streams.adaptive_hls.containsKey(fallbackLocal.toLanguageTag()) -> {
|
|
currentLanguage = fallbackLocal
|
|
currentPlayback.streams.adaptive_hls[fallbackLocal.toLanguageTag()]?.url
|
|
}
|
|
else -> {
|
|
currentLanguage = Locale.ROOT
|
|
currentPlayback.streams.adaptive_hls[Locale.ROOT.toLanguageTag()]?.url ?: ""
|
|
}
|
|
}
|
|
println("stream url: $url")
|
|
|
|
// create the media source object
|
|
val mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(
|
|
MediaItem.fromUri(Uri.parse(url))
|
|
)
|
|
|
|
// the actual player playback code
|
|
player.setMediaSource(mediaSource)
|
|
player.prepare()
|
|
if (seekPosition > 0) player.seekTo(seekPosition)
|
|
player.playWhenReady = true
|
|
}
|
|
|
|
/**
|
|
* Returns the current episode title (with episode number, if it's a tv show)
|
|
*/
|
|
fun getMediaTitle(): String {
|
|
// currentEpisode.episodeNumber defines the media type (tv show = none null, movie = null)
|
|
return if (currentEpisode.episodeNumber != null) {
|
|
getApplication<Application>().getString(
|
|
R.string.component_episode_title,
|
|
currentEpisode.episode,
|
|
currentEpisode.title
|
|
)
|
|
} else {
|
|
currentEpisode.title
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if the current episode is the last in the episodes list.
|
|
*
|
|
* @return Boolean: true if it is the last, else false.
|
|
*/
|
|
fun currentEpisodeIsLastEpisode(): Boolean {
|
|
return episodes.items.lastOrNull()?.id == currentEpisode.id
|
|
}
|
|
|
|
fun getEpisodeMetaByAoDMediaId(aodMediaId: Int): EpisodeMeta? {
|
|
val meta = mediaMeta
|
|
return if (meta is TVShowMeta) {
|
|
meta.episodes.firstOrNull { it.aodMediaId == aodMediaId }
|
|
} else {
|
|
null
|
|
}
|
|
}
|
|
|
|
// TODO reimplement for cr
|
|
private suspend fun loadMediaMeta(aodId: Int): Meta? {
|
|
// return if (media.type == DataTypes.MediaType.TVSHOW) {
|
|
// MetaDBController().getTVShowMetadata(aodId)
|
|
// } else {
|
|
// null
|
|
// }
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Update the playhead of the current episode, if currentPosition > 1000ms.
|
|
*/
|
|
private fun updatePlayhead() {
|
|
val playhead = (player.currentPosition / 1000)
|
|
|
|
if (playhead > 0) {
|
|
viewModelScope.launch { Crunchyroll.postPlayheads(currentEpisode.id, playhead.toInt()) }
|
|
Log.i(javaClass.name, "Set playhead for episode ${currentEpisode.id} to $playhead sec.")
|
|
}
|
|
}
|
|
|
|
}
|