implement lazy loading for LibraryFragment & code cleanup

This commit is contained in:
Jannik 2021-12-27 21:14:35 +01:00
parent 7dc41da13c
commit 63ce910ec5
Signed by: Seil0
GPG Key ID: E8459F3723C52C24
16 changed files with 249 additions and 672 deletions

View File

@ -26,6 +26,34 @@
-keepattributes Signature
-dontwarn sun.misc.**
# kotlinx.serialization
# Keep `Companion` object fields of serializable classes.
# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects.
-if @kotlinx.serialization.Serializable class **
-keepclassmembers class <1> {
static <1>$Companion Companion;
}
# Keep `serializer()` on companion objects (both default and named) of serializable classes.
-if @kotlinx.serialization.Serializable class ** {
static **$* *;
}
-keepclassmembers class <1>$<3> {
kotlinx.serialization.KSerializer serializer(...);
}
# Keep `INSTANCE.serializer()` of serializable objects.
-if @kotlinx.serialization.Serializable class ** {
public static ** INSTANCE;
}
-keepclassmembers class <1> {
public static <1> INSTANCE;
kotlinx.serialization.KSerializer serializer(...);
}
# @Serializable and @Polymorphic are used at runtime for polymorphic serialization.
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
#misc
-dontwarn java.lang.instrument.ClassFileTransformer
-dontwarn java.lang.ClassValue

View File

@ -1,472 +0,0 @@
/**
* Teapod
*
* Copyright 2020-2021 <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.parser
import android.util.Log
import com.google.gson.JsonParser
import kotlinx.coroutines.*
import org.jsoup.Connection
import org.jsoup.Jsoup
import org.mosad.teapod.preferences.EncryptedPreferences
import org.mosad.teapod.util.*
import org.mosad.teapod.util.DataTypes.MediaType
import java.io.IOException
import java.net.CookieStore
import java.util.*
import kotlin.random.Random
import kotlin.reflect.jvm.jvmName
object AoDParser {
private const val baseUrl = "https://www.anime-on-demand.de"
private const val loginPath = "/users/sign_in"
private const val libraryPath = "/animes"
private const val subscriptionPath = "/mypools"
private const val userAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:84.0) Gecko/20100101 Firefox/84.0"
private lateinit var cookieStore: CookieStore
private var csrfToken: String = ""
private var loginSuccess = false
private val aodMediaList = arrayListOf<AoDMedia>() // actual media (data)
// gui media
val guiMediaList = arrayListOf<ItemMedia>()
val highlightsList = arrayListOf<ItemMedia>()
val newEpisodesList = arrayListOf<ItemMedia>()
val newSimulcastsList = arrayListOf<ItemMedia>()
val newTitlesList = arrayListOf<ItemMedia>()
val topTenList = arrayListOf<ItemMedia>()
fun login(): Boolean = runBlocking {
withContext(Dispatchers.IO) {
// get the authenticity token and cookies
val conAuth = Jsoup.connect(baseUrl + loginPath)
.header("User-Agent", userAgent)
cookieStore = conAuth.cookieStore()
csrfToken = conAuth.execute().parse().select("meta[name=csrf-token]").attr("content")
Log.d(AoDParser::class.jvmName, "Received authenticity token: $csrfToken")
Log.d(AoDParser::class.jvmName, "Received authenticity cookies: $cookieStore")
val data = mapOf(
Pair("user[login]", EncryptedPreferences.login),
Pair("user[password]", EncryptedPreferences.password),
Pair("user[remember_me]", "1"),
Pair("commit", "Einloggen"),
Pair("authenticity_token", csrfToken)
)
val resLogin = Jsoup.connect(baseUrl + loginPath)
.method(Connection.Method.POST)
.timeout(60000) // login can take some time default is 60000 (60 sec)
.data(data)
.postDataCharset("UTF-8")
.cookieStore(cookieStore)
.execute()
//println(resLogin.body())
loginSuccess = resLogin.body().contains("Hallo, du bist jetzt angemeldet.")
Log.i(AoDParser::class.jvmName, "Status: ${resLogin.statusCode()} (${resLogin.statusMessage()}), login successful: $loginSuccess")
loginSuccess
}
}
/**
* initially load all media and home screen data
*/
suspend fun initialLoading() {
coroutineScope {
launch { loadHome() }
launch { listAnimes() }
}
}
/**
* get a media by it's ID (int)
* @param aodId The AoD ID of the requested media
* @return returns a AoDMedia of type Movie or TVShow if found, else return AoDMediaNone
*/
suspend fun getMediaById(aodId: Int): AoDMedia {
return aodMediaList.firstOrNull { it.aodId == aodId } ?:
try {
loadMediaAsync(aodId).await().apply {
aodMediaList.add(this)
}
} catch (exn:NullPointerException) {
Log.e(AoDParser::class.jvmName, "Error while loading media $aodId", exn)
AoDMediaNone
}
}
/**
* get subscription info from aod website, remove "Anime-Abo" Prefix and trim
*/
suspend fun getSubscriptionInfoAsync(): Deferred<String> {
return coroutineScope {
async(Dispatchers.IO) {
val res = Jsoup.connect(baseUrl + subscriptionPath)
.cookieStore(cookieStore)
.get()
return@async res.select("a:contains(Anime-Abo)").text()
.removePrefix("Anime-Abo").trim()
}
}
}
fun getSubscriptionUrl(): String {
return baseUrl + subscriptionPath
}
suspend fun markAsWatched(aodId: Int, episodeId: Int) {
val episode = getMediaById(aodId).getEpisodeById(episodeId)
episode.watched = true
sendCallback(episode.watchedCallback)
Log.d(AoDParser::class.jvmName, "Marked episode ${episode.mediaId} as watched")
}
// TODO don't use jsoup here
private suspend fun sendCallback(callbackPath: String) = coroutineScope {
launch(Dispatchers.IO) {
val headers = mutableMapOf(
Pair("Accept", "application/json, text/javascript, */*; q=0.01"),
Pair("Accept-Language", "de,en-US;q=0.7,en;q=0.3"),
Pair("Accept-Encoding", "gzip, deflate, br"),
Pair("X-CSRF-Token", csrfToken),
Pair("X-Requested-With", "XMLHttpRequest"),
)
try {
Jsoup.connect(baseUrl + callbackPath)
.ignoreContentType(true)
.cookieStore(cookieStore)
.headers(headers)
.execute()
} catch (ex: IOException) {
Log.e(AoDParser::class.jvmName, "Callback for $callbackPath failed.", ex)
}
}
}
/**
* load all media from aod into itemMediaList and mediaList
* TODO private suspend fun listAnimes() = withContext(Dispatchers.IO) should also work, maybe a bug in android studio?
*/
private suspend fun listAnimes() = withContext(Dispatchers.IO) {
launch(Dispatchers.IO) {
val resAnimes = Jsoup.connect(baseUrl + libraryPath).get()
//println(resAnimes)
guiMediaList.clear()
val animes = resAnimes.select("div.animebox")
guiMediaList.addAll(
animes.map {
ItemMedia(
id = it.select("p.animebox-link").select("a")
.attr("href").substringAfterLast("/").toInt(),
title = it.select("h3.animebox-title").text(),
posterUrl = it.select("p.animebox-image").select("img")
.attr("src")
)
}
)
Log.i(AoDParser::class.jvmName, "Total library size is: ${guiMediaList.size}")
}
}
/**
* load new episodes, titles and highlights
*/
private suspend fun loadHome() = withContext(Dispatchers.IO) {
launch(Dispatchers.IO) {
val resHome = Jsoup.connect(baseUrl).get()
// get highlights from AoD
highlightsList.clear()
resHome.select("#aod-highlights").select("div.news-item").forEach {
val mediaId = it.select("div.news-item-text").select("a.serienlink")
.attr("href").substringAfterLast("/").toIntOrNull()
val mediaTitle = it.select("div.news-title").select("h2").text()
val mediaImage = it.select("img").attr("src")
if (mediaId != null) {
highlightsList.add(ItemMedia(mediaId, mediaTitle, mediaImage))
}
}
// get all new episodes from AoD
newEpisodesList.clear()
resHome.select("h2:contains(Neue Episoden)").next().select("li").forEach {
val mediaId = it.select("a.thumbs").attr("href")
.substringAfterLast("/").toIntOrNull()
val mediaImage = it.select("a.thumbs > img").attr("src")
val mediaTitle = "${it.select("a").text()} - ${it.select("span.neweps").text()}"
if (mediaId != null) {
newEpisodesList.add(ItemMedia(mediaId, mediaTitle, mediaImage))
}
}
// get new simulcasts from AoD
newSimulcastsList.clear()
resHome.select("h2:contains(Neue Simulcasts)").next().select("li").forEach {
val mediaId = it.select("a.thumbs").attr("href")
.substringAfterLast("/").toIntOrNull()
val mediaImage = it.select("a.thumbs > img").attr("src")
val mediaTitle = it.select("a").text()
if (mediaId != null) {
newSimulcastsList.add(ItemMedia(mediaId, mediaTitle, mediaImage))
}
}
// get new titles from AoD
newTitlesList.clear()
resHome.select("h2:contains(Neue Anime-Titel)").next().select("li").forEach {
val mediaId = it.select("a.thumbs").attr("href")
.substringAfterLast("/").toIntOrNull()
val mediaImage = it.select("a.thumbs > img").attr("src")
val mediaTitle = it.select("a").text()
if (mediaId != null) {
newTitlesList.add(ItemMedia(mediaId, mediaTitle, mediaImage))
}
}
// get top ten from AoD
topTenList.clear()
resHome.select("h2:contains(Anime Top 10)").next().select("li").forEach {
val mediaId = it.select("a.thumbs").attr("href")
.substringAfterLast("/").toIntOrNull()
val mediaImage = it.select("a.thumbs > img").attr("src")
val mediaTitle = it.select("a").text()
if (mediaId != null) {
topTenList.add(ItemMedia(mediaId, mediaTitle, mediaImage))
}
}
// if highlights is empty, add a random new title
if (highlightsList.isEmpty()) {
if (newTitlesList.isNotEmpty()) {
highlightsList.add(newTitlesList[Random.nextInt(0, newTitlesList.size)])
} else {
highlightsList.add(ItemMedia(0,"", ""))
}
}
Log.i(AoDParser::class.jvmName, "loaded home")
}
}
/**
* TODO catch SocketTimeoutException from loading to show a waring dialog
* Load media async. Every media has a playlist.
* @param aodId The AoD ID of the requested media
*/
private suspend fun loadMediaAsync(aodId: Int): Deferred<AoDMedia> = coroutineScope {
return@coroutineScope async (Dispatchers.IO) {
if (cookieStore.cookies.isEmpty()) login() // TODO is this needed?
// return none object, if login wasn't successful
if (!loginSuccess) {
Log.w(AoDParser::class.jvmName, "Login was not successful")
return@async AoDMediaNone
}
// get the media page
val res = Jsoup.connect("$baseUrl/anime/$aodId")
.cookieStore(cookieStore)
.get()
// println(res)
if (csrfToken.isEmpty()) {
csrfToken = res.select("meta[name=csrf-token]").attr("content")
Log.d(AoDParser::class.jvmName, "New csrf token is $csrfToken")
}
// playlist parsing TODO can this be async to the general info parsing?
val besides = res.select("div.besides").first()!!
val aodPlaylists = besides.select("input.streamstarter_html5").map { streamstarter ->
parsePlaylistAsync(
streamstarter.attr("data-playlist"),
streamstarter.attr("data-lang")
)
}
/**
* generic aod media data
*/
val title = res.select("h1[itemprop=name]").text()
val description = res.select("div[itemprop=description]").text()
val posterURL = res.select("img.fullwidth-image").attr("src")
val type = when {
posterURL.contains("films") -> MediaType.MOVIE
posterURL.contains("series") -> MediaType.TVSHOW
else -> MediaType.OTHER
}
var year = 0
var age = 0
res.select("table.vertical-table").select("tr").forEach { row ->
when (row.select("th").text().lowercase(Locale.ROOT)) {
"produktionsjahr" -> year = row.select("td").text().toInt()
"fsk" -> age = row.select("td").text().toInt()
}
}
// similar titles from media page
val similar = res.select("h2:contains(Ähnliche Animes)").next().select("li").mapNotNull {
val mediaId = it.select("a.thumbs").attr("href")
.substringAfterLast("/").toIntOrNull()
val mediaImage = it.select("a.thumbs > img").attr("src")
val mediaTitle = it.select("a").text()
if (mediaId != null) {
ItemMedia(mediaId, mediaTitle, mediaImage)
} else {
Log.i(AoDParser::class.jvmName, "MediaId for similar to $aodId was null")
null
}
}
/**
* additional information for episodes:
* description: a short description of the episode
* watched: indicates if the episodes has been watched
* watched callback: url to set watched in aod
*/
val episodesInfo: Map<Int, AoDEpisodeInfo> = if (type == MediaType.TVSHOW) {
res.select("div.three-box-container > div.episodebox").mapNotNull { episodeBox ->
// make sure the episode has a streaming link
if (episodeBox.select("input.streamstarter_html5").isNotEmpty()) {
val mediaId = episodeBox.select("div.flip-front").attr("id").substringAfter("-").toInt()
val episodeShortDesc = episodeBox.select("p.episodebox-shorttext").text()
val episodeWatched = episodeBox.select("div.episodebox-icons > div").hasClass("status-icon-orange")
val episodeWatchedCallback = episodeBox.select("input.streamstarter_html5").eachAttr("data-playlist").first()
AoDEpisodeInfo(mediaId, episodeShortDesc, episodeWatched, episodeWatchedCallback)
} else {
Log.i(AoDParser::class.jvmName, "Episode info for $aodId has empty streamstarter_html5 ")
null
}
}.associateBy { it.aodMediaId }
} else {
mapOf()
}
// map the aod api playlist to a teapod playlist
val playlist: List<AoDEpisode> = aodPlaylists.awaitAll().flatMap { aodPlaylist ->
aodPlaylist.list.mapIndexed { index, episode ->
AoDEpisode(
mediaId = episode.mediaid,
title = episode.title,
description = episode.description,
shortDesc = episodesInfo[episode.mediaid]?.shortDesc ?: "",
imageURL = episode.image,
numberStr = episode.title.substringAfter(", Ep. ", ""), // TODO move to parsePalylist
index = index,
watched = episodesInfo[episode.mediaid]?.watched ?: false,
watchedCallback = episodesInfo[episode.mediaid]?.watchedCallback ?: "",
streams = mutableListOf(Stream(episode.sources.first().file, aodPlaylist.language))
)
}
}.groupingBy { it.mediaId }.reduce{ _, accumulator, element ->
accumulator.copy().also {
it.streams.addAll(element.streams)
}
}.values.toList()
return@async AoDMedia(
aodId = aodId,
type = type,
title = title,
shortText = description,
posterURL = posterURL,
year = year,
age = age,
similar = similar,
playlist = playlist
)
}
}
/**
* don't use Gson().fromJson() as we don't have any control over the api and it may change
*/
private fun parsePlaylistAsync(playlistPath: String, language: String): Deferred<AoDPlaylist> {
if (playlistPath == "[]") {
return CompletableDeferred(AoDPlaylist(listOf(), Locale.ROOT))
}
return CoroutineScope(Dispatchers.IO).async(Dispatchers.IO) {
val headers = mutableMapOf(
Pair("Accept", "application/json, text/javascript, */*; q=0.01"),
Pair("Accept-Language", "de,en-US;q=0.7,en;q=0.3"),
Pair("Accept-Encoding", "gzip, deflate, br"),
Pair("X-CSRF-Token", csrfToken),
Pair("X-Requested-With", "XMLHttpRequest"),
)
//println("loading streaminfo with cstf: $csrfToken")
val res = Jsoup.connect(baseUrl + playlistPath)
.ignoreContentType(true)
.cookieStore(cookieStore)
.headers(headers)
.timeout(120000) // loading the playlist can take some time
.execute()
//Gson().fromJson(res.body(), AoDObject::class.java)
return@async AoDPlaylist(JsonParser.parseString(res.body()).asJsonObject
.get("playlist").asJsonArray.map {
Playlist(
sources = it.asJsonObject.get("sources").asJsonArray.map { source ->
Source(source.asJsonObject.get("file").asString)
},
image = it.asJsonObject.get("image").asString,
title = it.asJsonObject.get("title").asString,
description = it.asJsonObject.get("description").asString,
mediaid = it.asJsonObject.get("mediaid").asInt
)
},
// TODO improve language handling (via display language etc.)
language = when (language) {
"ger" -> Locale.GERMAN
"jap" -> Locale.JAPANESE
else -> Locale.ROOT
}
)
}
}
}

View File

@ -118,9 +118,9 @@ object Crunchyroll {
*
* @return A **[BrowseResult]** object is returned.
*/
suspend fun browse(sortBy: SortBy = SortBy.ALPHABETICAL, n: Int = 10): BrowseResult {
suspend fun browse(sortBy: SortBy = SortBy.ALPHABETICAL, start: Int = 0, n: Int = 10): BrowseResult {
val browseEndpoint = "/content/v1/browse"
val parameters = listOf("sort_by" to sortBy.str, "n" to n)
val parameters = listOf("sort_by" to sortBy.str, "start" to start, "n" to n)
val result = request(browseEndpoint, parameters)
val browseResult = result.component1()?.obj()?.let {

View File

@ -33,7 +33,6 @@ import com.google.android.material.navigation.NavigationBarView
import kotlinx.coroutines.*
import org.mosad.teapod.R
import org.mosad.teapod.databinding.ActivityMainBinding
import org.mosad.teapod.parser.AoDParser
import org.mosad.teapod.parser.crunchyroll.Crunchyroll
import org.mosad.teapod.preferences.EncryptedPreferences
import org.mosad.teapod.preferences.Preferences
@ -47,7 +46,6 @@ import org.mosad.teapod.ui.components.LoginDialog
import org.mosad.teapod.util.DataTypes
import org.mosad.teapod.util.MetaDBController
import org.mosad.teapod.util.StorageController
import java.util.*
import kotlin.system.measureTimeMillis
class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListener {
@ -152,7 +150,6 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
showOnboarding()
} else {
Crunchyroll.login(EncryptedPreferences.login, EncryptedPreferences.password)
runBlocking { Crunchyroll.browse() }
runBlocking { Crunchyroll.index() }
}
@ -188,10 +185,11 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
LoginDialog(this, false).positiveButton {
EncryptedPreferences.saveCredentials(login, password, context)
if (!AoDParser.login()) {
showLoginDialog()
Log.w(javaClass.name, "Login failed, please try again.")
}
// TODO
// if (!AoDParser.login()) {
// showLoginDialog()
// Log.w(javaClass.name, "Login failed, please try again.")
// }
}.negativeButton {
Log.i(javaClass.name, "Login canceled, exiting.")
finish()

View File

@ -2,9 +2,7 @@ package org.mosad.teapod.ui.activity.main.fragments
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@ -19,7 +17,6 @@ import kotlinx.coroutines.launch
import org.mosad.teapod.BuildConfig
import org.mosad.teapod.R
import org.mosad.teapod.databinding.FragmentAccountBinding
import org.mosad.teapod.parser.AoDParser
import org.mosad.teapod.preferences.EncryptedPreferences
import org.mosad.teapod.preferences.Preferences
import org.mosad.teapod.ui.activity.main.MainActivity
@ -62,12 +59,13 @@ class AccountFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// TODO reimplement for ct, if possible (maybe account status would be better? (premium))
// load subscription (async) info before anything else
binding.textAccountSubscription.text = getString(R.string.account_subscription, getString(R.string.loading))
lifecycleScope.launch {
binding.textAccountSubscription.text = getString(
R.string.account_subscription,
AoDParser.getSubscriptionInfoAsync().await()
"TODO"
)
}
@ -92,7 +90,8 @@ class AccountFragment : Fragment() {
}
binding.linearAccountSubscription.setOnClickListener {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(AoDParser.getSubscriptionUrl())))
// TODO
//startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(AoDParser.getSubscriptionUrl())))
}
binding.linearTheme.setOnClickListener {
@ -133,10 +132,11 @@ class AccountFragment : Fragment() {
LoginDialog(requireContext(), firstTry).positiveButton {
EncryptedPreferences.saveCredentials(login, password, context)
if (!AoDParser.login()) {
showLoginDialog(false)
Log.w(javaClass.name, "Login failed, please try again.")
}
// TODO
// if (!AoDParser.login()) {
// showLoginDialog(false)
// Log.w(javaClass.name, "Login failed, please try again.")
// }
}.show {
login = EncryptedPreferences.login
password = ""

View File

@ -1,18 +1,14 @@
package org.mosad.teapod.ui.activity.main.fragments
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import kotlinx.coroutines.launch
import org.mosad.teapod.R
import org.mosad.teapod.databinding.FragmentHomeBinding
import org.mosad.teapod.parser.AoDParser
import org.mosad.teapod.ui.activity.main.MainActivity
import org.mosad.teapod.util.ItemMedia
import org.mosad.teapod.util.StorageController
import org.mosad.teapod.util.adapter.MediaItemAdapter
@ -49,19 +45,20 @@ class HomeFragment : Fragment() {
}
private fun initHighlight() {
if (AoDParser.highlightsList.isNotEmpty()) {
highlightMedia = AoDParser.highlightsList[0]
binding.textHighlightTitle.text = highlightMedia.title
Glide.with(requireContext()).load(highlightMedia.posterUrl)
.into(binding.imageHighlight)
if (StorageController.myList.contains(highlightMedia.id)) {
binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_check_24)
} else {
binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_add_24)
}
}
// TODO
// if (AoDParser.highlightsList.isNotEmpty()) {
// highlightMedia = AoDParser.highlightsList[0]
//
// binding.textHighlightTitle.text = highlightMedia.title
// Glide.with(requireContext()).load(highlightMedia.posterUrl)
// .into(binding.imageHighlight)
//
// if (StorageController.myList.contains(0)) {
// binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_check_24)
// } else {
// binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_add_24)
// }
// }
}
private fun initRecyclerViews() {
@ -75,40 +72,42 @@ class HomeFragment : Fragment() {
adapterMyList = MediaItemAdapter(mapMyListToItemMedia())
binding.recyclerMyList.adapter = adapterMyList
// TODO
// new episodes
adapterNewEpisodes = MediaItemAdapter(AoDParser.newEpisodesList)
binding.recyclerNewEpisodes.adapter = adapterNewEpisodes
// adapterNewEpisodes = MediaItemAdapter(AoDParser.newEpisodesList)
// binding.recyclerNewEpisodes.adapter = adapterNewEpisodes
// new simulcasts
adapterNewSimulcasts = MediaItemAdapter(AoDParser.newSimulcastsList)
binding.recyclerNewSimulcasts.adapter = adapterNewSimulcasts
// adapterNewSimulcasts = MediaItemAdapter(AoDParser.newSimulcastsList)
// binding.recyclerNewSimulcasts.adapter = adapterNewSimulcasts
// new titles
adapterNewTitles = MediaItemAdapter(AoDParser.newTitlesList)
binding.recyclerNewTitles.adapter = adapterNewTitles
// adapterNewTitles = MediaItemAdapter(AoDParser.newTitlesList)
// binding.recyclerNewTitles.adapter = adapterNewTitles
// top ten
adapterTopTen = MediaItemAdapter(AoDParser.topTenList)
binding.recyclerTopTen.adapter = adapterTopTen
// adapterTopTen = MediaItemAdapter(AoDParser.topTenList)
// binding.recyclerTopTen.adapter = adapterTopTen
}
private fun initActions() {
binding.buttonPlayHighlight.setOnClickListener {
// TODO get next episode
lifecycleScope.launch {
val media = AoDParser.getMediaById(highlightMedia.id)
// TODO
//val media = AoDParser.getMediaById(0)
Log.d(javaClass.name, "Starting Player with mediaId: ${media.aodId}")
//(activity as MainActivity).startPlayer(media.aodId, media.playlist.first().mediaId) // TODO
// Log.d(javaClass.name, "Starting Player with mediaId: ${media.aodId}")
//(activity as MainActivity).startPlayer(media.aodId, media.playlist.first().mediaId)
}
}
binding.textHighlightMyList.setOnClickListener {
if (StorageController.myList.contains(highlightMedia.id)) {
StorageController.myList.remove(highlightMedia.id)
if (StorageController.myList.contains(0)) {
StorageController.myList.remove(0)
binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_add_24)
} else {
StorageController.myList.add(highlightMedia.id)
StorageController.myList.add(0)
binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_check_24)
}
StorageController.saveMyList(requireContext())
@ -124,21 +123,21 @@ class HomeFragment : Fragment() {
activity?.showFragment(MediaFragment("")) //(mediaId))
}
adapterNewEpisodes.onItemClick = { id, _ ->
activity?.showFragment(MediaFragment("")) //(mediaId))
}
adapterNewSimulcasts.onItemClick = { id, _ ->
activity?.showFragment(MediaFragment("")) //(mediaId))
}
adapterNewTitles.onItemClick = { id, _ ->
activity?.showFragment(MediaFragment("")) //(mediaId))
}
adapterTopTen.onItemClick = { id, _ ->
activity?.showFragment(MediaFragment("")) //(mediaId))
}
// adapterNewEpisodes.onItemClick = { id, _ ->
// activity?.showFragment(MediaFragment("")) //(mediaId))
// }
//
// adapterNewSimulcasts.onItemClick = { id, _ ->
// activity?.showFragment(MediaFragment("")) //(mediaId))
// }
//
// adapterNewTitles.onItemClick = { id, _ ->
// activity?.showFragment(MediaFragment("")) //(mediaId))
// }
//
// adapterTopTen.onItemClick = { id, _ ->
// activity?.showFragment(MediaFragment("")) //(mediaId))
// }
}
/**
@ -153,14 +152,15 @@ class HomeFragment : Fragment() {
}
private fun mapMyListToItemMedia(): List<ItemMedia> {
return StorageController.myList.mapNotNull { elementId ->
AoDParser.guiMediaList.firstOrNull { it.id == elementId }.also {
// it the my list entry wasn't found in itemMediaList Log it
if (it == null) {
Log.w(javaClass.name, "The element with the id $elementId was not found.")
}
}
}
return emptyList()
// return StorageController.myList.mapNotNull { elementId ->
// AoDParser.guiMediaList.firstOrNull { it.id == elementId.toString() }.also {
// // it the my list entry wasn't found in itemMediaList Log it
// if (it == null) {
// Log.w(javaClass.name, "The element with the id $elementId was not found.")
// }
// }
// }
}
}

View File

@ -6,9 +6,10 @@ import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.launch
import org.mosad.teapod.databinding.FragmentLibraryBinding
import org.mosad.teapod.parser.AoDParser
import org.mosad.teapod.parser.crunchyroll.Crunchyroll
import org.mosad.teapod.util.ItemMedia
import org.mosad.teapod.util.adapter.MediaItemAdapter
@ -20,6 +21,10 @@ class LibraryFragment : Fragment() {
private lateinit var binding: FragmentLibraryBinding
private lateinit var adapter: MediaItemAdapter
private val itemList = arrayListOf<ItemMedia>()
private val pageSize = 30
private var nextItemIndex = 0
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentLibraryBinding.inflate(inflater, container, false)
return binding.root
@ -32,22 +37,55 @@ class LibraryFragment : Fragment() {
lifecycleScope.launch {
// create and set the adapter, needs context
context?.let {
// crunchy testing TODO implement lazy loading
val results = Crunchyroll.browse(n = 50)
val list = results.items.mapIndexed { index, item ->
ItemMedia(index, item.title, item.images.poster_wide[0][0].source, idStr = item.id)
}
val initialResults = Crunchyroll.browse(n = pageSize)
itemList.addAll(initialResults.items.map { item ->
ItemMedia(item.id, item.title, item.images.poster_wide[0][0].source)
})
nextItemIndex += pageSize
adapter = MediaItemAdapter(list)
adapter = MediaItemAdapter(itemList)
adapter.onItemClick = { mediaIdStr, _ ->
activity?.showFragment(MediaFragment(mediaIdStr = mediaIdStr))
}
binding.recyclerMediaLibrary.adapter = adapter
binding.recyclerMediaLibrary.addItemDecoration(MediaItemDecoration(9))
// TODO replace with pagination3
// https://medium.com/swlh/paging3-recyclerview-pagination-made-easy-333c7dfa8797
binding.recyclerMediaLibrary.addOnScrollListener(PaginationScrollListener())
}
}
}
inner class PaginationScrollListener: RecyclerView.OnScrollListener() {
private var isLoading = false
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val layoutManager = recyclerView.layoutManager as GridLayoutManager?
if (!isLoading) layoutManager?.let {
// itemList.size - 5 to start loading a bit earlier than the actual end
if (layoutManager.findLastCompletelyVisibleItemPosition() >= (itemList.size - 5)) {
// load new browse results async
isLoading = true
lifecycleScope.launch {
val firstNewItemIndex = itemList.lastIndex + 1
val results = Crunchyroll.browse(start = nextItemIndex, n = pageSize)
itemList.addAll(results.items.map { item ->
ItemMedia(item.id, item.title, item.images.poster_wide[0][0].source)
})
nextItemIndex += pageSize
adapter.updateMediaList(itemList)
adapter.notifyItemRangeInserted(firstNewItemIndex, pageSize)
isLoading = false
}
}
}
}
}
}

View File

@ -9,7 +9,6 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.mosad.teapod.databinding.FragmentSearchBinding
import org.mosad.teapod.parser.AoDParser
import org.mosad.teapod.util.decoration.MediaItemDecoration
import org.mosad.teapod.util.adapter.MediaItemAdapter
import org.mosad.teapod.util.showFragment
@ -30,7 +29,7 @@ class SearchFragment : Fragment() {
lifecycleScope.launch {
// create and set the adapter, needs context
context?.let {
adapter = MediaItemAdapter(AoDParser.guiMediaList)
adapter = MediaItemAdapter(emptyList()) // TODO
adapter!!.onItemClick = { mediaId, _ ->
binding.searchText.clearFocus()
activity?.showFragment(MediaFragment("")) //(mediaId))

View File

@ -3,9 +3,7 @@ package org.mosad.teapod.ui.activity.main.viewmodel
import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import org.mosad.teapod.parser.AoDParser
import org.mosad.teapod.parser.crunchyroll.*
import org.mosad.teapod.ui.activity.main.MainActivity
import org.mosad.teapod.util.*
import org.mosad.teapod.util.DataTypes.MediaType
import org.mosad.teapod.util.tmdb.TMDBApiController
@ -50,11 +48,12 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
// load seasons
seasonsCrunchy = Crunchyroll.seasons(crunchyId)
println("media: $seasonsCrunchy")
println("seasons: $seasonsCrunchy")
// load first season
// TODO make sure to load the preferred season (language), language is set per season, not per stream
episodesCrunchy = Crunchyroll.episodes(seasonsCrunchy.items.first().id)
println("media: $episodesCrunchy")
println("episodes: $episodesCrunchy")
@ -75,47 +74,47 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
* set media, tmdb and nextEpisode
* TODO run aod and tmdb load parallel
*/
suspend fun loadAoD(aodId: Int) {
val tmdbApiController = TMDBApiController()
media = AoDParser.getMediaById(aodId)
// check if metaDB knows the title
val tmdbId: Int = if (MetaDBController.mediaList.media.contains(aodId)) {
// load media info from metaDB
val metaDB = MetaDBController()
mediaMeta = when (media.type) {
MediaType.MOVIE -> metaDB.getMovieMetadata(media.aodId)
MediaType.TVSHOW -> metaDB.getTVShowMetadata(media.aodId)
else -> null
}
mediaMeta?.tmdbId ?: -1
} else {
// use tmdb search to get media info
mediaMeta = null // set mediaMeta to null, if metaDB doesn't know the media
tmdbApiController.search(stripTitleInfo(media.title), media.type)
}
tmdbResult = when (media.type) {
MediaType.MOVIE -> tmdbApiController.getMovieDetails(tmdbId)
MediaType.TVSHOW -> tmdbApiController.getTVShowDetails(tmdbId)
else -> null
}
// get season info, if metaDB knows the tv show
tmdbTVSeason = if (media.type == MediaType.TVSHOW && mediaMeta != null) {
val tvShowMeta = mediaMeta as TVShowMeta
tmdbApiController.getTVSeasonDetails(tvShowMeta.tmdbId, tvShowMeta.tmdbSeasonNumber)
} else {
null
}
if (media.type == MediaType.TVSHOW) {
//nextEpisode = media.episodes.firstOrNull{ !it.watched } ?: media.episodes.first()
nextEpisodeId = media.playlist.firstOrNull { !it.watched }?.mediaId
?: media.playlist.first().mediaId
}
}
// suspend fun loadAoD(aodId: Int) {
// val tmdbApiController = TMDBApiController()
// media = AoDParser.getMediaById(aodId)
//
// // check if metaDB knows the title
// val tmdbId: Int = if (MetaDBController.mediaList.media.contains(aodId)) {
// // load media info from metaDB
// val metaDB = MetaDBController()
// mediaMeta = when (media.type) {
// MediaType.MOVIE -> metaDB.getMovieMetadata(media.aodId)
// MediaType.TVSHOW -> metaDB.getTVShowMetadata(media.aodId)
// else -> null
// }
//
// mediaMeta?.tmdbId ?: -1
// } else {
// // use tmdb search to get media info
// mediaMeta = null // set mediaMeta to null, if metaDB doesn't know the media
// tmdbApiController.search(stripTitleInfo(media.title), media.type)
// }
//
// tmdbResult = when (media.type) {
// MediaType.MOVIE -> tmdbApiController.getMovieDetails(tmdbId)
// MediaType.TVSHOW -> tmdbApiController.getTVShowDetails(tmdbId)
// else -> null
// }
//
// // get season info, if metaDB knows the tv show
// tmdbTVSeason = if (media.type == MediaType.TVSHOW && mediaMeta != null) {
// val tvShowMeta = mediaMeta as TVShowMeta
// tmdbApiController.getTVSeasonDetails(tvShowMeta.tmdbId, tvShowMeta.tmdbSeasonNumber)
// } else {
// null
// }
//
// if (media.type == MediaType.TVSHOW) {
// //nextEpisode = media.episodes.firstOrNull{ !it.watched } ?: media.episodes.first()
// nextEpisodeId = media.playlist.firstOrNull { !it.watched }?.mediaId
// ?: media.playlist.first().mediaId
// }
// }
/**
* get the next episode based on episodeId

View File

@ -7,9 +7,7 @@ import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.*
import org.mosad.teapod.R
import org.mosad.teapod.databinding.FragmentOnLoginBinding
import org.mosad.teapod.parser.AoDParser
import org.mosad.teapod.preferences.EncryptedPreferences
class OnLoginFragment: Fragment() {
@ -37,17 +35,18 @@ class OnLoginFragment: Fragment() {
binding.buttonLogin.isClickable = false
loginJob = lifecycleScope.launch {
if (AoDParser.login()) {
// if login was successful, switch to main
if (activity is OnboardingActivity) {
(activity as OnboardingActivity).launchMainActivity()
}
} else {
withContext(Dispatchers.Main) {
binding.textLoginDesc.text = getString(R.string.on_login_failed)
binding.buttonLogin.isClickable = true
}
}
// TODO
// if (AoDParser.login()) {
// // if login was successful, switch to main
// if (activity is OnboardingActivity) {
// (activity as OnboardingActivity).launchMainActivity()
// }
// } else {
// withContext(Dispatchers.Main) {
// binding.textLoginDesc.text = getString(R.string.on_login_failed)
// binding.buttonLogin.isClickable = true
// }
// }
}
}
}

View File

@ -172,7 +172,7 @@ class PlayerActivity : AppCompatActivity() {
}
private fun initPlayer() {
if (model.currentEpisode.equals(NoneEpisode)) {
if (model.currentEpisode == NoneEpisode) {
Log.e(javaClass.name, "No media was set.")
this.finish()
}
@ -207,7 +207,7 @@ class PlayerActivity : AppCompatActivity() {
else -> View.VISIBLE
}
if (state == ExoPlayer.STATE_ENDED && model.currentEpisodeCr.nextEpisodeId != null && Preferences.autoplay) {
if (state == ExoPlayer.STATE_ENDED && model.currentEpisode.nextEpisodeId != null && Preferences.autoplay) {
playNextEpisode()
}
}
@ -279,7 +279,7 @@ class PlayerActivity : AppCompatActivity() {
// if remaining time < 20 sec, a next ep is set, autoplay is enabled and not in pip:
// show next ep button
if (remainingTime in 1..20000) {
if (!btnNextEpIsVisible && model.currentEpisodeCr.nextEpisodeId != null && Preferences.autoplay && !isInPiPMode()) {
if (!btnNextEpIsVisible && model.currentEpisode.nextEpisodeId != null && Preferences.autoplay && !isInPiPMode()) {
showButtonNextEp()
}
} else if (btnNextEpIsVisible) {
@ -337,7 +337,7 @@ class PlayerActivity : AppCompatActivity() {
exo_text_title.text = model.getMediaTitle()
// hide the next ep button, if there is none
button_next_ep_c.visibility = if (model.currentEpisodeCr.nextEpisodeId == null) {
button_next_ep_c.visibility = if (model.currentEpisode.nextEpisodeId == null) {
View.GONE
} else {
View.VISIBLE

View File

@ -18,7 +18,6 @@ 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.AoDEpisodeNone
import org.mosad.teapod.util.EpisodeMeta
import org.mosad.teapod.util.Meta
import org.mosad.teapod.util.TVShowMeta
@ -40,27 +39,25 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
val currentEpisodeChangedListener = ArrayList<() -> Unit>()
private val preferredLanguage = if (Preferences.preferSecondary) Locale.JAPANESE else Locale.GERMAN
// var media: AoDMedia = AoDMediaNone
// internal set
// tmdb/meta data TODO currently not implemented for cr
var mediaMeta: Meta? = null
internal set
var tmdbTVSeason: TMDBTVSeason? =null
internal set
var currentEpisode = AoDEpisodeNone
internal set
var currentEpisodeMeta: EpisodeMeta? = null
internal set
// var nextEpisodeId: Int? = null
// internal set
// crunchyroll episodes/playback
var episodes = NoneEpisodes
internal set
var currentEpisode = NoneEpisode
internal set
private var currentPlayback = NonePlayback
// current playback settings
var currentLanguage: Locale = Locale.ROOT
internal set
var episodesCrunchy = NoneEpisodes
internal set
var currentEpisodeCr = NoneEpisode
internal set
private var currentPlaybackCr = NonePlayback
init {
initMediaSession()
}
@ -87,16 +84,16 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
fun loadMedia(seasonId: String, episodeId: String) {
runBlocking {
episodesCrunchy = Crunchyroll.episodes(seasonId)
episodes = Crunchyroll.episodes(seasonId)
//mediaMeta = loadMediaMeta(media.aodId) // can be done blocking, since it should be cached
// TODO replace this with setCurrentEpisode
currentEpisodeCr = episodesCrunchy.items.find { episode ->
currentEpisode = episodes.items.find { episode ->
episode.id == episodeId
} ?: NoneEpisode
println("loading playback ${currentEpisodeCr.playback}")
println("loading playback ${currentEpisode.playback}")
currentPlaybackCr = Crunchyroll.playback(currentEpisodeCr.playback)
currentPlayback = Crunchyroll.playback(currentEpisode.playback)
}
// TODO reimplement for cr
@ -108,9 +105,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
// tmdbTVSeason = TMDBApiController().getTVSeasonDetails(tvShowMeta.tmdbId, tvShowMeta.tmdbSeasonNumber)
// }
// }
currentEpisodeMeta = getEpisodeMetaByAoDMediaId(currentEpisode.mediaId)
currentLanguage = currentEpisode.getPreferredStream(preferredLanguage).language
//
// currentEpisodeMeta = getEpisodeMetaByAoDMediaId(currentEpisodeAoD.mediaId)
// currentLanguage = currentEpisodeAoD.getPreferredStream(preferredLanguage).language
}
fun setLanguage(language: Locale) {
@ -118,7 +115,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
playCurrentMedia(player.currentPosition)
// val mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(
// MediaItem.fromUri(Uri.parse(currentEpisode.getPreferredStream(language).url))
// MediaItem.fromUri(Uri.parse(currentEpisodeAoD.getPreferredStream(language).url))
// )
// playMedia(mediaSource, seekTime)
}
@ -134,9 +131,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
}
/**
* play the next episode, if nextEpisode is not null
* play the next episode, if nextEpisodeId is not null
*/
fun playNextEpisode() = currentEpisodeCr.nextEpisodeId?.let { nextEpisodeId ->
fun playNextEpisode() = currentEpisode.nextEpisodeId?.let { nextEpisodeId ->
setCurrentEpisode(nextEpisodeId, startPlayback = true)
}
@ -145,13 +142,13 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
* @param episodeId The ID of the episode you want to set currentEpisodeCr to
*/
fun setCurrentEpisode(episodeId: String, startPlayback: Boolean = false) {
currentEpisodeCr = episodesCrunchy.items.find { episode ->
currentEpisode = episodes.items.find { episode ->
episode.id == episodeId
} ?: NoneEpisode
// TODO don't run blocking
runBlocking {
currentPlaybackCr = Crunchyroll.playback(currentEpisodeCr.playback)
currentPlayback = Crunchyroll.playback(currentEpisode.playback)
}
// TODO update metadata and language (it should not be needed to update the language here!)
@ -171,7 +168,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
currentEpisodeChangedListener.forEach { it() }
// get preferred stream url TODO implement
val url = currentPlaybackCr.streams.adaptive_hls["en-US"]?.url ?: ""
val url = currentPlayback.streams.adaptive_hls["en-US"]?.url ?: ""
println("stream url: $url")
// create the media source object
@ -194,12 +191,12 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
return if(isTVShow) {
getApplication<Application>().getString(
R.string.component_episode_title,
currentEpisodeCr.episode,
currentEpisodeCr.title
currentEpisode.episode,
currentEpisode.title
)
} else {
// TODO movie
currentEpisodeCr.title
currentEpisode.title
}
}
@ -223,17 +220,4 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
return null
}
/**
* TODO reimplement for cr
* Based on the current episodes index, get the next episode.
* @return The next episode or null if there is none.
*/
private fun selectNextEpisode(): Int? {
// return media.playlist.firstOrNull {
// it.index > media.getEpisodeById(currentEpisode.mediaId).index
// }?.mediaId
return null
}
}

View File

@ -28,16 +28,16 @@ class EpisodesListPlayer @JvmOverloads constructor(
}
model?.let {
adapterRecEpisodes = PlayerEpisodeItemAdapter(model.episodesCrunchy, model.tmdbTVSeason?.episodes)
adapterRecEpisodes = PlayerEpisodeItemAdapter(model.episodes, model.tmdbTVSeason?.episodes)
adapterRecEpisodes.onImageClick = {_, episodeId ->
(this.parent as ViewGroup).removeView(this)
model.setCurrentEpisode(episodeId, startPlayback = true)
}
// episodeNumber starts at 1, we need the episode index -> - 1
adapterRecEpisodes.currentSelected = (model.currentEpisodeCr.episodeNumber - 1)
adapterRecEpisodes.currentSelected = (model.currentEpisode.episodeNumber - 1)
binding.recyclerEpisodesPlayer.adapter = adapterRecEpisodes
binding.recyclerEpisodesPlayer.scrollToPosition(model.currentEpisode.index)
binding.recyclerEpisodesPlayer.scrollToPosition(adapterRecEpisodes.currentSelected)
}
}

View File

@ -30,12 +30,13 @@ class LanguageSettingsPlayer @JvmOverloads constructor(
init {
model?.let {
model.currentEpisode.streams.forEach { stream ->
addLanguage(stream.language.displayName, stream.language == currentLanguage) {
currentLanguage = stream.language
updateSelectedLanguage(it as TextView)
}
}
// TODO reimplement for cr
// it.currentEpisode.streams.forEach { stream ->
// addLanguage(stream.language.displayName, stream.language == currentLanguage) {
// currentLanguage = stream.language
// updateSelectedLanguage(it as TextView)
// }
// }
}
binding.buttonCloseLanguageSettings.setOnClickListener { close() }

View File

@ -35,10 +35,9 @@ data class ThirdPartyComponent(
* it is uses in the ItemMediaAdapter (RecyclerView)
*/
data class ItemMedia(
val id: Int, // aod path id
val id: String,
val title: String,
val posterUrl: String,
val idStr: String = "" // crunchyroll id
)