Merge pull request 'version 0.4.2' (#44) from develop into master

Reviewed-on: #44
This commit is contained in:
Jannik 2021-07-09 18:56:34 +02:00
commit 19552d3950
33 changed files with 822 additions and 460 deletions

View File

@ -10,8 +10,8 @@ android {
applicationId "org.mosad.teapod"
minSdkVersion 23
targetSdkVersion 30
versionCode 4100 //00.04.100
versionName "0.4.1"
versionCode 4200 //00.04.200
versionName "0.4.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resValue "string", "build_time", buildTime()
@ -41,22 +41,25 @@ android {
dependencies {
implementation fileTree(dir: "libs", include: ["*.jar"])
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0'
implementation 'androidx.core:core-ktx:1.3.2'
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'androidx.core:core-ktx:1.6.0'
implementation 'androidx.appcompat:appcompat:1.3.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.4'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.4'
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.5'
implementation 'androidx.security:security-crypto:1.1.0-alpha03'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'
implementation 'com.google.android.material:material:1.3.0'
implementation 'com.google.code.gson:gson:2.8.6'
implementation 'com.google.android.exoplayer:exoplayer-core:2.13.2'
implementation 'com.google.android.exoplayer:exoplayer-hls:2.13.2'
implementation 'com.google.android.exoplayer:exoplayer-dash:2.13.2'
implementation 'com.google.android.exoplayer:exoplayer-ui:2.13.2'
implementation 'com.google.android.material:material:1.4.0'
implementation 'com.google.code.gson:gson:2.8.7'
implementation 'com.google.android.exoplayer:exoplayer-core:2.14.1'
implementation 'com.google.android.exoplayer:exoplayer-hls:2.14.1'
implementation 'com.google.android.exoplayer:exoplayer-dash:2.14.1'
implementation 'com.google.android.exoplayer:exoplayer-ui:2.14.1'
implementation 'com.google.android.exoplayer:extension-mediasession:2.14.1'
implementation 'org.jsoup:jsoup:1.13.1'
implementation 'com.github.bumptech.glide:glide:4.12.0'
@ -64,12 +67,12 @@ dependencies {
implementation 'com.afollestad.material-dialogs:core:3.3.0'
implementation 'com.afollestad.material-dialogs:bottomsheets:3.3.0'
testImplementation 'junit:junit:4.13.1'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}
static def buildTime() {
return new Date().format("yyyy-MM-dd", TimeZone.getTimeZone("UTC"))
}
}

View File

@ -40,6 +40,7 @@ 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"
@ -98,10 +99,12 @@ object AoDParser {
/**
* initially load all media and home screen data
*/
fun initialLoading() = listOf(
loadHome(),
listAnimes()
)
suspend fun initialLoading() {
coroutineScope {
launch { loadHome() }
launch { listAnimes() }
}
}
/**
* get a media by it's ID (int)
@ -117,7 +120,27 @@ object AoDParser {
return media
}
fun markAsWatched(mediaId: Int, episodeId: Int) = GlobalScope.launch {
/**
* 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)
.cookies(sessionCookies)
.get()
return@async res.select("a:contains(Anime-Abo)").text()
.removePrefix("Anime-Abo").trim()
}
}
}
fun getSubscriptionUrl(): String {
return baseUrl + subscriptionPath
}
suspend fun markAsWatched(mediaId: Int, episodeId: Int) {
val episode = getMediaById(mediaId).getEpisodeById(episodeId)
episode.watched = true
sendCallback(episode.watchedCallback)
@ -126,250 +149,262 @@ object AoDParser {
}
// TODO don't use jsoup here
private fun sendCallback(callbackPath: String) = GlobalScope.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"),
)
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)
.cookies(sessionCookies)
.headers(headers)
.execute()
} catch (ex: IOException) {
Log.e(javaClass.name, "Callback for $callbackPath failed.", ex)
try {
Jsoup.connect(baseUrl + callbackPath)
.ignoreContentType(true)
.cookies(sessionCookies)
.headers(headers)
.execute()
} catch (ex: IOException) {
Log.e(javaClass.name, "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 fun listAnimes() = GlobalScope.launch(Dispatchers.IO) {
val resAnimes = Jsoup.connect(baseUrl + libraryPath).get()
//println(resAnimes)
private suspend fun listAnimes() = withContext(Dispatchers.IO) {
launch(Dispatchers.IO) {
val resAnimes = Jsoup.connect(baseUrl + libraryPath).get()
//println(resAnimes)
itemMediaList.clear()
mediaList.clear()
resAnimes.select("div.animebox").forEach {
val type = if (it.select("p.animebox-link").select("a").text().toLowerCase(Locale.ROOT) == "zur serie") {
MediaType.TVSHOW
} else {
MediaType.MOVIE
itemMediaList.clear()
mediaList.clear()
resAnimes.select("div.animebox").forEach {
val type = if (it.select("p.animebox-link").select("a").text().lowercase(Locale.ROOT) == "zur serie") {
MediaType.TVSHOW
} else {
MediaType.MOVIE
}
val mediaTitle = it.select("h3.animebox-title").text()
val mediaLink = it.select("p.animebox-link").select("a").attr("href")
val mediaImage = it.select("p.animebox-image").select("img").attr("src")
val mediaShortText = it.select("p.animebox-shorttext").text()
val mediaId = mediaLink.substringAfterLast("/").toInt()
itemMediaList.add(ItemMedia(mediaId, mediaTitle, mediaImage))
mediaList.add(Media(mediaId, mediaLink, type).apply {
info.title = mediaTitle
info.posterUrl = mediaImage
info.shortDesc = mediaShortText
})
}
val mediaTitle = it.select("h3.animebox-title").text()
val mediaLink = it.select("p.animebox-link").select("a").attr("href")
val mediaImage = it.select("p.animebox-image").select("img").attr("src")
val mediaShortText = it.select("p.animebox-shorttext").text()
val mediaId = mediaLink.substringAfterLast("/").toInt()
itemMediaList.add(ItemMedia(mediaId, mediaTitle, mediaImage))
mediaList.add(Media(mediaId, mediaLink, type).apply {
info.title = mediaTitle
info.posterUrl = mediaImage
info.shortDesc = mediaShortText
})
Log.i(javaClass.name, "Total library size is: ${mediaList.size}")
}
Log.i(javaClass.name, "Total library size is: ${mediaList.size}")
}
/**
* load new episodes, titles and highlights
*/
private fun loadHome() = GlobalScope.launch(Dispatchers.IO) {
val resHome = Jsoup.connect(baseUrl).get()
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")
// 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))
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()}"
// 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))
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()
// 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))
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()
// 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))
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()
// 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 (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,"", ""))
// 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(javaClass.name, "loaded home")
}
}
/**
* TODO rework the media loading process, don't modify media object
* TODO catch SocketTimeoutException from loading to show a waring dialog
* load streams for the media path, movies have one episode
* @param media is used as call ba reference
*/
private fun loadStreams(media: Media) = GlobalScope.launch(Dispatchers.IO) {
if (sessionCookies.isEmpty()) login()
private suspend fun loadStreams(media: Media) = coroutineScope {
launch(Dispatchers.IO) {
if (sessionCookies.isEmpty()) login()
if (!loginSuccess) {
Log.w(javaClass.name, "Login, was not successful.")
return@launch
}
// get the media page
val res = Jsoup.connect(baseUrl + media.link)
.cookies(sessionCookies)
.get()
//println(res)
if (csrfToken.isEmpty()) {
csrfToken = res.select("meta[name=csrf-token]").attr("content")
//Log.i(javaClass.name, "New csrf token is $csrfToken")
}
val besides = res.select("div.besides").first()
val playlists = besides.select("input.streamstarter_html5").map { streamstarter ->
parsePlaylistAsync(
streamstarter.attr("data-playlist"),
streamstarter.attr("data-lang")
)
}.awaitAll()
playlists.forEach { aod ->
// TODO improve language handling
val locale = when (aod.extLanguage) {
"ger" -> Locale.GERMAN
"jap" -> Locale.JAPANESE
else -> Locale.ROOT
if (!loginSuccess) {
Log.w(javaClass.name, "Login, was not successful.")
return@launch
}
aod.playlist.forEach { ep ->
try {
if (media.hasEpisode(ep.mediaid)) {
media.getEpisodeById(ep.mediaid).streams.add(
Stream(ep.sources.first().file, locale)
)
} else {
media.episodes.add(Episode(
id = ep.mediaid,
streams = mutableListOf(Stream(ep.sources.first().file, locale)),
posterUrl = ep.image,
title = ep.title,
description = ep.description,
number = getNumberFromTitle(ep.title, media.type)
))
}
} catch (ex: Exception) {
Log.w(javaClass.name, "Could not parse episode information.", ex)
// get the media page
val res = Jsoup.connect(baseUrl + media.link)
.cookies(sessionCookies)
.get()
//println(res)
if (csrfToken.isEmpty()) {
csrfToken = res.select("meta[name=csrf-token]").attr("content")
//Log.i(javaClass.name, "New csrf token is $csrfToken")
}
val besides = res.select("div.besides").first()
val playlists = besides.select("input.streamstarter_html5").map { streamstarter ->
parsePlaylistAsync(
streamstarter.attr("data-playlist"),
streamstarter.attr("data-lang")
)
}.awaitAll()
playlists.forEach { aod ->
// TODO improve language handling
val locale = when (aod.extLanguage) {
"ger" -> Locale.GERMAN
"jap" -> Locale.JAPANESE
else -> Locale.ROOT
}
}
}
Log.i(javaClass.name, "Loaded playlists successfully")
// additional info from the media page
res.select("table.vertical-table").select("tr").forEach { row ->
when (row.select("th").text().toLowerCase(Locale.ROOT)) {
"produktionsjahr" -> media.info.year = row.select("td").text().toInt()
"fsk" -> media.info.age = row.select("td").text().toInt()
"episodenanzahl" -> {
media.info.episodesCount = row.select("td").text()
.substringBefore("/")
.filter { it.isDigit() }
.toInt()
}
}
}
// similar titles from media page
media.info.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 {
null
}
}
// additional information for tv shows the episode title (description) is loaded from the "api"
if (media.type == MediaType.TVSHOW) {
res.select("div.three-box-container > div.episodebox").forEach { episodebox ->
// make sure the episode has a streaming link
if (episodebox.select("input.streamstarter_html5").isNotEmpty()) {
val episodeId = 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()
media.episodes.firstOrNull { it.id == episodeId }?.apply {
shortDesc = episodeShortDesc
watched = episodeWatched
watchedCallback = episodeWatchedCallback
aod.playlist.forEach { ep ->
try {
if (media.hasEpisode(ep.mediaid)) {
media.getEpisodeById(ep.mediaid).streams.add(
Stream(ep.sources.first().file, locale)
)
} else {
media.episodes.add(Episode(
id = ep.mediaid,
streams = mutableListOf(Stream(ep.sources.first().file, locale)),
posterUrl = ep.image,
title = ep.title,
description = ep.description,
number = getNumberFromTitle(ep.title, media.type)
))
}
} catch (ex: Exception) {
Log.w(javaClass.name, "Could not parse episode information.", ex)
}
}
}
Log.i(javaClass.name, "Loaded playlists successfully")
// additional info from the media page
res.select("table.vertical-table").select("tr").forEach { row ->
when (row.select("th").text().lowercase(Locale.ROOT)) {
"produktionsjahr" -> media.info.year = row.select("td").text().toInt()
"fsk" -> media.info.age = row.select("td").text().toInt()
"episodenanzahl" -> {
media.info.episodesCount = row.select("td").text()
.substringBefore("/")
.filter { it.isDigit() }
.toInt()
}
}
}
// similar titles from media page
media.info.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 {
null
}
}
// additional information for tv shows the episode title (description) is loaded from the "api"
if (media.type == MediaType.TVSHOW) {
res.select("div.three-box-container > div.episodebox").forEach { episodebox ->
// make sure the episode has a streaming link
if (episodebox.select("input.streamstarter_html5").isNotEmpty()) {
val episodeId = 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()
media.episodes.firstOrNull { it.id == episodeId }?.apply {
shortDesc = episodeShortDesc
watched = episodeWatched
watchedCallback = episodeWatchedCallback
}
}
}
}
Log.i(javaClass.name, "media loaded successfully")
}
Log.i(javaClass.name, "media loaded successfully")
}
/**
@ -380,7 +415,7 @@ object AoDParser {
return CompletableDeferred(AoDObject(listOf(), language))
}
return GlobalScope.async(Dispatchers.IO) {
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"),

View File

@ -11,6 +11,8 @@ object Preferences {
internal set
var autoplay = true
internal set
var devSettings = false
internal set
var theme = DataTypes.Theme.DARK
internal set
@ -39,6 +41,15 @@ object Preferences {
this.autoplay = autoplay
}
fun saveDevSettings(context: Context, devSettings: Boolean) {
with(getSharedPref(context).edit()) {
putBoolean(context.getString(R.string.save_key_dev_settings), devSettings)
apply()
}
this.devSettings = devSettings
}
fun saveTheme(context: Context, theme: DataTypes.Theme) {
with(getSharedPref(context).edit()) {
putString(context.getString(R.string.save_key_theme), theme.toString())
@ -60,6 +71,9 @@ object Preferences {
autoplay = sharedPref.getBoolean(
context.getString(R.string.save_key_autoplay), true
)
devSettings = sharedPref.getBoolean(
context.getString(R.string.save_key_dev_settings), false
)
theme = DataTypes.Theme.valueOf(
sharedPref.getString(
context.getString(R.string.save_key_theme), DataTypes.Theme.DARK.toString()

View File

@ -31,28 +31,27 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.callbacks.onDismiss
import com.google.android.material.bottomnavigation.BottomNavigationView
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.runBlocking
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.ui.activity.player.PlayerActivity
import org.mosad.teapod.preferences.EncryptedPreferences
import org.mosad.teapod.preferences.Preferences
import org.mosad.teapod.ui.components.LoginDialog
import org.mosad.teapod.ui.activity.main.fragments.AccountFragment
import org.mosad.teapod.ui.activity.main.fragments.HomeFragment
import org.mosad.teapod.ui.activity.main.fragments.LibraryFragment
import org.mosad.teapod.ui.activity.main.fragments.SearchFragment
import org.mosad.teapod.ui.activity.onboarding.OnboardingActivity
import org.mosad.teapod.ui.activity.player.PlayerActivity
import org.mosad.teapod.ui.components.LoginDialog
import org.mosad.teapod.util.DataTypes
import org.mosad.teapod.util.StorageController
import org.mosad.teapod.util.exitAndRemoveTask
import java.net.SocketTimeoutException
import kotlin.system.measureTimeMillis
class MainActivity : AppCompatActivity(), BottomNavigationView.OnNavigationItemSelectedListener {
class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListener {
private lateinit var binding: ActivityMainBinding
private var activeBaseFragment: Fragment = HomeFragment() // the currently active fragment, home at the start
@ -73,7 +72,7 @@ class MainActivity : AppCompatActivity(), BottomNavigationView.OnNavigationItemS
theme.applyStyle(getThemeResource(), true)
binding = ActivityMainBinding.inflate(layoutInflater)
binding.navView.setOnNavigationItemSelectedListener(this)
binding.navView.setOnItemSelectedListener(this)
setContentView(binding.root)
supportFragmentManager.commit {
@ -138,14 +137,15 @@ class MainActivity : AppCompatActivity(), BottomNavigationView.OnNavigationItemS
*/
private fun load() {
val time = measureTimeMillis {
val loadingJob = AoDParser.initialLoading() // start the initial loading
val loadingJob = CoroutineScope(Dispatchers.IO + CoroutineName("InitialLoadingScope"))
.async { AoDParser.initialLoading() } // start the initial loading
// load all saved stuff here
Preferences.load(this)
EncryptedPreferences.readCredentials(this)
StorageController.load(this)
// show onbaording
// show onboarding
if (EncryptedPreferences.password.isEmpty()) {
showOnboarding()
} else {
@ -165,7 +165,7 @@ class MainActivity : AppCompatActivity(), BottomNavigationView.OnNavigationItemS
}
}
runBlocking { loadingJob.joinAll() } // wait for initial loading to finish
runBlocking { loadingJob.await() } // wait for initial loading to finish
}
Log.i(javaClass.name, "loading and login in $time ms")

View File

@ -6,6 +6,7 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.annotation.RawRes
import androidx.fragment.app.Fragment
import com.afollestad.materialdialogs.MaterialDialog
@ -13,15 +14,21 @@ import org.mosad.teapod.BuildConfig
import org.mosad.teapod.R
import org.mosad.teapod.databinding.FragmentAboutBinding
import org.mosad.teapod.databinding.ItemComponentBinding
import org.mosad.teapod.preferences.Preferences
import org.mosad.teapod.util.DataTypes.License
import org.mosad.teapod.util.ThirdPartyComponent
import java.lang.StringBuilder
import java.util.Timer
import kotlin.concurrent.schedule
class AboutFragment : Fragment() {
private val teapodRepoUrl = "https://git.mosad.xyz/Seil0/teapod"
private lateinit var binding: FragmentAboutBinding
private val teapodRepoUrl = "https://git.mosad.xyz/Seil0/teapod"
private val devClickMax = 5
private var devClickCount = 0
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentAboutBinding.inflate(inflater, container, false)
return binding.root
@ -52,6 +59,10 @@ class AboutFragment : Fragment() {
}
private fun initActions() {
binding.imageAppIcon.setOnClickListener {
checkDevSettings()
}
binding.linearSource.setOnClickListener {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(teapodRepoUrl)))
}
@ -64,6 +75,30 @@ class AboutFragment : Fragment() {
}
}
/**
* check if dev settings shall be enabled
*/
private fun checkDevSettings() {
// if the dev settings are already enabled show a toast
if (Preferences.devSettings) {
Toast.makeText(context, getString(R.string.dev_settings_already), Toast.LENGTH_SHORT).show()
return
}
// reset dev settings count after 5 seconds
if (devClickCount == 0) {
Timer("", false).schedule(5000) {
devClickCount = 0
}
}
devClickCount++
if (devClickCount == devClickMax) {
Preferences.saveDevSettings(requireContext(), true)
Toast.makeText(context, getString(R.string.dev_settings_enabled), Toast.LENGTH_SHORT).show()
}
}
private fun getThirdPartyComponents(): List<ThirdPartyComponent> {
return listOf(
ThirdPartyComponent("AndroidX", "", "The Android Open Source Project",

View File

@ -1,28 +1,59 @@
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
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.list.listItemsSingleChoice
import kotlinx.coroutines.launch
import org.mosad.teapod.BuildConfig
import org.mosad.teapod.ui.activity.main.MainActivity
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
import org.mosad.teapod.ui.components.LoginDialog
import org.mosad.teapod.util.DataTypes.Theme
import org.mosad.teapod.util.StorageController
import org.mosad.teapod.util.showFragment
class AccountFragment : Fragment() {
private lateinit var binding: FragmentAccountBinding
private val getUriExport = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.data?.also { uri ->
StorageController.exportMyList(requireContext(), uri)
}
}
}
private val getUriImport = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.data?.also { uri ->
val success = StorageController.importMyList(requireContext(), uri)
if (success == 0) {
Toast.makeText(
context, getString(R.string.import_data_success),
Toast.LENGTH_SHORT
).show()
}
}
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentAccountBinding.inflate(inflater, container, false)
return binding.root
@ -31,6 +62,15 @@ class AccountFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 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()
)
}
binding.textAccountLogin.text = EncryptedPreferences.login
binding.textInfoAboutDesc.text = getString(R.string.info_about_desc, BuildConfig.VERSION_NAME, getString(R.string.build_time))
binding.textThemeSelected.text = when (Preferences.theme) {
@ -41,6 +81,8 @@ class AccountFragment : Fragment() {
binding.switchSecondary.isChecked = Preferences.preferSecondary
binding.switchAutoplay.isChecked = Preferences.autoplay
binding.linearDevSettings.isVisible = Preferences.devSettings
initActions()
}
@ -49,6 +91,10 @@ class AccountFragment : Fragment() {
showLoginDialog(true)
}
binding.linearAccountSubscription.setOnClickListener {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(AoDParser.getSubscriptionUrl())))
}
binding.linearTheme.setOnClickListener {
showThemeDialog()
}
@ -64,6 +110,23 @@ class AccountFragment : Fragment() {
binding.switchAutoplay.setOnClickListener {
Preferences.saveAutoplay(requireContext(), binding.switchAutoplay.isChecked)
}
binding.linearExportData.setOnClickListener {
val i = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "text/json"
putExtra(Intent.EXTRA_TITLE, "my-list.json")
}
getUriExport.launch(i)
}
binding.linearImportData.setOnClickListener {
val i = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
}
getUriImport.launch(i)
}
}
private fun showLoginDialog(firstTry: Boolean) {
@ -92,11 +155,12 @@ class AccountFragment : Fragment() {
when(index) {
0 -> Preferences.saveTheme(context, Theme.LIGHT)
1 -> Preferences.saveTheme(context, Theme.DARK)
else -> Preferences.saveTheme(context, Theme.LIGHT)
else -> Preferences.saveTheme(context, Theme.DARK)
}
(activity as MainActivity).restart()
}
}
}
}

View File

@ -6,14 +6,13 @@ 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.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.mosad.teapod.R
import org.mosad.teapod.ui.activity.main.MainActivity
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
@ -40,7 +39,7 @@ class HomeFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
GlobalScope.launch(Dispatchers.Main) {
lifecycleScope.launch {
context?.let {
initHighlight()
initRecyclerViews()
@ -73,12 +72,7 @@ class HomeFragment : Fragment() {
binding.recyclerTopTen.addItemDecoration(MediaItemDecoration(9))
// my list
val myListMedia = StorageController.myList.map { elementId ->
AoDParser.itemMediaList.first {
elementId == it.id
}
}
adapterMyList = MediaItemAdapter(myListMedia)
adapterMyList = MediaItemAdapter(mapMyListToItemMedia())
binding.recyclerMyList.adapter = adapterMyList
// new episodes
@ -101,7 +95,7 @@ class HomeFragment : Fragment() {
private fun initActions() {
binding.buttonPlayHighlight.setOnClickListener {
// TODO get next episode
GlobalScope.launch {
lifecycleScope.launch {
val media = AoDParser.getMediaById(highlightMedia.id)
Log.d(javaClass.name, "Starting Player with mediaId: ${media.id}")
@ -154,14 +148,19 @@ class HomeFragment : Fragment() {
* * only update actual change and not all data (performance)
*/
fun updateMyListMedia() {
val myListMedia = StorageController.myList.map { elementId ->
AoDParser.itemMediaList.first {
elementId == it.id
}
}
adapterMyList.updateMediaList(myListMedia)
adapterMyList.updateMediaList(mapMyListToItemMedia())
adapterMyList.notifyDataSetChanged()
}
private fun mapMyListToItemMedia(): List<ItemMedia> {
return StorageController.myList.mapNotNull { elementId ->
AoDParser.itemMediaList.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.")
}
}
}
}
}

View File

@ -5,10 +5,8 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.mosad.teapod.databinding.FragmentLibraryBinding
import org.mosad.teapod.parser.AoDParser
import org.mosad.teapod.util.adapter.MediaItemAdapter
@ -29,18 +27,16 @@ class LibraryFragment : Fragment() {
super.onViewCreated(view, savedInstanceState)
// init async
GlobalScope.launch {
lifecycleScope.launch {
// create and set the adapter, needs context
withContext(Dispatchers.Main) {
context?.let {
adapter = MediaItemAdapter(AoDParser.itemMediaList)
adapter.onItemClick = { mediaId, _ ->
activity?.showFragment(MediaFragment(mediaId))
}
binding.recyclerMediaLibrary.adapter = adapter
binding.recyclerMediaLibrary.addItemDecoration(MediaItemDecoration(9))
context?.let {
adapter = MediaItemAdapter(AoDParser.itemMediaList)
adapter.onItemClick = { mediaId, _ ->
activity?.showFragment(MediaFragment(mediaId))
}
binding.recyclerMediaLibrary.adapter = adapter
binding.recyclerMediaLibrary.addItemDecoration(MediaItemDecoration(9))
}
}

View File

@ -10,20 +10,21 @@ import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.tabs.TabLayoutMediator
import jp.wasabeef.glide.transformations.BlurTransformation
import kotlinx.coroutines.*
import kotlinx.coroutines.launch
import org.mosad.teapod.R
import org.mosad.teapod.databinding.FragmentMediaBinding
import org.mosad.teapod.ui.activity.main.MainActivity
import org.mosad.teapod.ui.activity.main.viewmodel.MediaFragmentViewModel
import org.mosad.teapod.util.*
import org.mosad.teapod.util.DataTypes.MediaType
import org.mosad.teapod.util.Episode
import org.mosad.teapod.util.StorageController
/**
* The media detail fragment.
@ -61,13 +62,12 @@ class MediaFragment(private val mediaId: Int) : Fragment() {
}
}.attach()
GlobalScope.launch(Dispatchers.Main) {
lifecycleScope.launch {
model.load(mediaId) // load the streams and tmdb for the selected media
if (this@MediaFragment.isAdded) {
updateGUI()
initActions()
}
updateGUI()
initActions()
}
}

View File

@ -6,7 +6,8 @@ import android.view.View
import android.view.ViewGroup
import android.widget.SearchView
import androidx.fragment.app.Fragment
import kotlinx.coroutines.*
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
@ -26,9 +27,8 @@ class SearchFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
GlobalScope.launch {
lifecycleScope.launch {
// create and set the adapter, needs context
withContext(Dispatchers.Main) {
context?.let {
adapter = MediaItemAdapter(AoDParser.itemMediaList)
adapter!!.onItemClick = { mediaId, _ ->
@ -39,7 +39,6 @@ class SearchFragment : Fragment() {
binding.recyclerMediaSearch.adapter = adapter
binding.recyclerMediaSearch.addItemDecoration(MediaItemDecoration(9))
}
}
}
initActions()

View File

@ -5,6 +5,7 @@ import android.view.LayoutInflater
import android.view.View
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
@ -35,7 +36,7 @@ class OnLoginFragment: Fragment() {
EncryptedPreferences.saveCredentials(email, password, requireContext()) // save the credentials
binding.buttonLogin.isClickable = false
loginJob = GlobalScope.launch {
loginJob = lifecycleScope.launch {
if (AoDParser.login()) {
// if login was successful, switch to main
if (activity is OnboardingActivity) {

View File

@ -7,6 +7,7 @@ import android.app.PictureInPictureParams
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Rect
import android.os.Build
import android.os.Bundle
import android.util.Log
@ -19,16 +20,14 @@ import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.GestureDetectorCompat
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ui.StyledPlayerControlView
import com.google.android.exoplayer2.util.Util
import kotlinx.android.synthetic.main.activity_player.*
import kotlinx.android.synthetic.main.player_controls.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
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
@ -147,8 +146,15 @@ class PlayerActivity : AppCompatActivity() {
} else {
val width = model.player.videoFormat?.width ?: 0
val height = model.player.videoFormat?.height ?: 0
val contentFrame: View = video_view.findViewById(R.id.exo_content_frame)
val contentRect = with(contentFrame) {
val (x, y) = intArrayOf(0, 0).also(::getLocationInWindow)
Rect(x, y, x + width, y + height)
}
val params = PictureInPictureParams.Builder()
.setAspectRatio(Rational(width, height))
.setSourceRectHint(contentRect)
.build()
enterPictureInPictureMode(params)
}
@ -187,7 +193,7 @@ class PlayerActivity : AppCompatActivity() {
* set play when ready and listeners
*/
private fun initExoPlayer() {
model.player.addListener(object : Player.EventListener {
model.player.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
super.onPlaybackStateChanged(state)
@ -208,7 +214,7 @@ class PlayerActivity : AppCompatActivity() {
}
}
})
// start playing the current episode, after all needed player components have been initialized
model.playEpisode(model.currentEpisode, true)
}
@ -255,31 +261,27 @@ class PlayerActivity : AppCompatActivity() {
}
timerUpdates = Timer().scheduleAtFixedRate(0, 500) {
GlobalScope.launch {
var btnNextEpIsVisible: Boolean
var controlsVisible: Boolean
lifecycleScope.launch {
val btnNextEpIsVisible = button_next_ep.isVisible
val controlsVisible = controller.isVisible
withContext(Dispatchers.Main) {
if (model.player.duration > 0) {
remainingTime = model.player.duration - model.player.currentPosition
remainingTime = if (remainingTime < 0) 0 else remainingTime
}
btnNextEpIsVisible = button_next_ep.isVisible
controlsVisible = controller.isVisible
if (model.player.duration > 0) {
remainingTime = model.player.duration - model.player.currentPosition
remainingTime = if (remainingTime < 0) 0 else remainingTime
}
if (remainingTime in 1..20000) {
// if the next ep button is not visible, make it visible. Don't show in pip mode
if (!btnNextEpIsVisible && model.nextEpisode != null && Preferences.autoplay && !isInPiPMode()) {
withContext(Dispatchers.Main) { showButtonNextEp() }
showButtonNextEp()
}
} else if (btnNextEpIsVisible) {
withContext(Dispatchers.Main) { hideButtonNextEp() }
hideButtonNextEp()
}
// if controls are visible, update them
if (controlsVisible) {
withContext(Dispatchers.Main) { updateControls() }
updateControls()
}
}
}

View File

@ -2,15 +2,19 @@ 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.C
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.source.MediaSource
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.launch
import kotlinx.coroutines.runBlocking
import org.mosad.teapod.R
import org.mosad.teapod.parser.AoDParser
@ -29,10 +33,11 @@ import kotlin.collections.ArrayList
class PlayerViewModel(application: Application) : AndroidViewModel(application) {
val player = SimpleExoPlayer.Builder(application).build()
val dataSourceFactory = DefaultDataSourceFactory(application, Util.getUserAgent(application, "Teapod"))
private val dataSourceFactory = DefaultDataSourceFactory(application, Util.getUserAgent(application, "Teapod"))
private val mediaSession = MediaSessionCompat(application, "TEAPOD_PLAYER_SESSION")
val currentEpisodeChangedListener = ArrayList<() -> Unit>()
val preferredLanguage = if (Preferences.preferSecondary) Locale.JAPANESE else Locale.GERMAN
private val preferredLanguage = if (Preferences.preferSecondary) Locale.JAPANESE else Locale.GERMAN
var media: Media = Media(-1, "", DataTypes.MediaType.OTHER)
internal set
@ -43,13 +48,30 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
var currentLanguage: Locale = Locale.ROOT
internal set
init {
initMediaSession()
}
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 loadMedia(mediaId: Int, episodeId: Int) {
runBlocking {
media = AoDParser.getMediaById(mediaId)
@ -107,10 +129,15 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
// if episodes has not been watched, mark as watched
if (!episode.watched) {
AoDParser.markAsWatched(media.id, episode.id)
viewModelScope.launch {
AoDParser.markAsWatched(media.id, episode.id)
}
}
}
/**
* change the players media source and start playback
*/
fun playMedia(source: MediaSource, replace: Boolean = false, seekPosition: Long = 0) {
if (replace || player.contentDuration == C.TIME_UNSET) {
player.setMediaSource(source)

View File

@ -1,12 +1,14 @@
package org.mosad.teapod.util
import android.content.Context
import android.net.Uri
import android.util.Log
import com.google.gson.Gson
import com.google.gson.JsonParser
import kotlinx.coroutines.*
import java.io.File
import java.lang.Exception
import java.io.FileReader
import java.io.FileWriter
/**
* This controller contains the logic for permanently saved data.
@ -19,6 +21,10 @@ object StorageController {
val myList = ArrayList<Int>() // a list of saved mediaIds
fun load(context: Context) {
loadMyList(context)
}
fun loadMyList(context: Context) {
val file = File(context.filesDir, fileNameMyList)
if (!file.exists()) runBlocking { saveMyList(context).join() }
@ -30,15 +36,54 @@ object StorageController {
myList.clear()
Log.e(javaClass.name, "Parsing of My-List failed.")
}
}
fun saveMyList(context: Context): Job {
val file = File(context.filesDir, fileNameMyList)
return GlobalScope.launch(Dispatchers.IO) {
return CoroutineScope(Dispatchers.IO).launch {
file.writeText(Gson().toJson(myList.distinct()))
}
}
fun exportMyList(context: Context, uri: Uri) {
try {
context.contentResolver.openFileDescriptor(uri, "w")?.use {
FileWriter(it.fileDescriptor).use { writer ->
writer.write(Gson().toJson(myList.distinct()))
}
}
} catch (ex: Exception) {
Log.e(javaClass.name, "Exporting my list failed.", ex)
}
}
/**
* import my list from a (previously exported) json file
* @param context the current context
* @param uri the uri of the selected file
* @return 0 if import was successfull, else 1
*/
fun importMyList(context: Context, uri: Uri): Int {
try {
val text = context.contentResolver.openFileDescriptor(uri, "r")?.use {
FileReader(it.fileDescriptor).use { reader ->
reader.readText()
}
}
myList.clear()
myList.addAll(JsonParser.parseString(text).asJsonArray.map { it.asInt }.distinct())
// after the list has been imported also save it
saveMyList(context)
} catch (ex: Exception) {
myList.clear()
Log.e(javaClass.name, "Importing my list failed.", ex)
return 1
}
return 0
}
}

View File

@ -4,9 +4,9 @@ import android.util.Log
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import kotlinx.coroutines.*
import org.mosad.teapod.util.DataTypes.MediaType
import java.net.URL
import java.net.URLEncoder
import org.mosad.teapod.util.DataTypes.MediaType
class TMDBApiController {
@ -21,11 +21,15 @@ class TMDBApiController {
private val imageUrl = "https://image.tmdb.org/t/p/w500"
suspend fun search(title: String, type: MediaType): TMDBResponse {
val searchTerm = title.replace("(Sub)", "").trim()
// remove unneeded text from the media title before searching
val searchTerm = title.replace("(Sub)", "")
.replace(Regex("-?\\s?[0-9]+.\\s?(Staffel|Season)"), "")
.replace(Regex("(Staffel|Season)\\s?[0-9]+"), "")
.trim()
return when (type) {
MediaType.MOVIE -> searchMovie(searchTerm).await()
MediaType.TVSHOW -> searchTVShow(searchTerm).await()
MediaType.MOVIE -> searchMovie(searchTerm)
MediaType.TVSHOW -> searchTVShow(searchTerm)
else -> {
Log.e(javaClass.name, "Wrong Type: $type")
TMDBResponse()
@ -34,62 +38,64 @@ class TMDBApiController {
}
fun searchTVShow(title: String): Deferred<TMDBResponse> {
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun searchTVShow(title: String): TMDBResponse = withContext(Dispatchers.IO) {
val url = URL("$searchTVUrl$preparedParameters&query=${URLEncoder.encode(title, "UTF-8")}")
val response = JsonParser.parseString(url.readText()).asJsonObject
// println(response)
return GlobalScope.async {
val response = JsonParser.parseString(url.readText()).asJsonObject
//println(response)
val sortedResults = response.get("results").asJsonArray.toList().sortedBy {
getStringNotNull(it.asJsonObject, "name")
}
if (response.get("total_results").asInt > 0) {
response.get("results").asJsonArray.first().asJsonObject.let {
val id = getStringNotNull(it, "id").toInt()
val overview = getStringNotNull(it, "overview")
val posterPath = getStringNotNullPrefix(it, "poster_path", imageUrl)
val backdropPath = getStringNotNullPrefix(it, "backdrop_path", imageUrl)
return@withContext if (sortedResults.isNotEmpty()) {
sortedResults.first().asJsonObject.let {
val id = getStringNotNull(it, "id").toInt()
val overview = getStringNotNull(it, "overview")
val posterPath = getStringNotNullPrefix(it, "poster_path", imageUrl)
val backdropPath = getStringNotNullPrefix(it, "backdrop_path", imageUrl)
TMDBResponse(id, "", overview, posterPath, backdropPath)
}
} else {
TMDBResponse()
TMDBResponse(id, "", overview, posterPath, backdropPath)
}
} else {
TMDBResponse()
}
}
fun searchMovie(title: String): Deferred<TMDBResponse> {
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun searchMovie(title: String): TMDBResponse = withContext(Dispatchers.IO) {
val url = URL("$searchMovieUrl$preparedParameters&query=${URLEncoder.encode(title, "UTF-8")}")
val response = JsonParser.parseString(url.readText()).asJsonObject
// println(response)
return GlobalScope.async {
val response = JsonParser.parseString(url.readText()).asJsonObject
//println(response)
val sortedResults = response.get("results").asJsonArray.toList().sortedBy {
getStringNotNull(it.asJsonObject, "title")
}
if (response.get("total_results").asInt > 0) {
response.get("results").asJsonArray.first().asJsonObject.let {
val id = getStringNotNull(it,"id").toInt()
val overview = getStringNotNull(it,"overview")
val posterPath = getStringNotNullPrefix(it, "poster_path", imageUrl)
val backdropPath = getStringNotNullPrefix(it, "backdrop_path", imageUrl)
val runtime = getMovieRuntime(id)
return@withContext if (sortedResults.isNotEmpty()) {
sortedResults.first().asJsonObject.let {
val id = getStringNotNull(it,"id").toInt()
val overview = getStringNotNull(it,"overview")
val posterPath = getStringNotNullPrefix(it, "poster_path", imageUrl)
val backdropPath = getStringNotNullPrefix(it, "backdrop_path", imageUrl)
val runtime = getMovieRuntime(id)
TMDBResponse(id, "", overview, posterPath, backdropPath, runtime)
}
} else {
TMDBResponse()
TMDBResponse(id, "", overview, posterPath, backdropPath, runtime)
}
} else {
TMDBResponse()
}
}
/**
* currently only used for runtime, need a rework
*/
fun getMovieRuntime(id: Int): Int = runBlocking {
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun getMovieRuntime(id: Int): Int = withContext(Dispatchers.IO) {
val url = URL("$getMovieUrl/$id?api_key=$apiKey&language=$language")
GlobalScope.async {
val response = JsonParser.parseString(url.readText()).asJsonObject
return@async getStringNotNull(response,"runtime").toInt()
}.await()
val response = JsonParser.parseString(url.readText()).asJsonObject
return@withContext getStringNotNull(response,"runtime").toInt()
}
/**

View File

@ -49,14 +49,14 @@ class MediaItemAdapter(private val initMedia: List<ItemMedia>) : RecyclerView.Ad
inner class MediaFilter : Filter() {
override fun performFiltering(constraint: CharSequence?): FilterResults {
val filterTerm = constraint.toString().toLowerCase(Locale.ROOT)
val filterTerm = constraint.toString().lowercase(Locale.ROOT)
val results = FilterResults()
val filteredList = if (filterTerm.isEmpty()) {
initMedia
} else {
initMedia.filter {
it.title.toLowerCase(Locale.ROOT).contains(filterTerm)
it.title.lowercase(Locale.ROOT).contains(filterTerm)
}
}

View File

@ -0,0 +1,6 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z"/>
<path android:fillColor="@android:color/white" android:pathData="M12.5,7H11v6l5.25,3.15 0.75,-1.23 -4.5,-2.67z"/>
</vector>

View File

@ -3,17 +3,18 @@
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="0.051679686"
android:scaleY="0.051679686"
android:translateX="27.54"
android:translateY="38.90954">
<path
android:pathData="m850.19,372.71c87.88,-11.01 119.04,-84.97 123.1,-99.87 4.06,-14.89 24.91,-80.57 11.92,-129.36 -12.99,-48.79 -34.36,-72.36 -58.62,-77.25 -24.25,-4.9 -50.59,10.51 -65,32.81 -14.41,22.3 -14.68,45.14 -14.78,55.29 -0.11,10.15 0.76,23.2 -3.37,33.29 -4.13,10.09 3.23,25.71 6.04,35.23 2.81,9.52 9.67,82.62 5.78,115.57 -3.89,32.95 -5.07,34.29 -5.07,34.29zM0.4,23.58C55.81,77.29 56.45,120.86 56.08,132.92c-0.36,12.06 4.77,130.59 11.47,150.76 4.42,13.3 12.11,50.16 41.78,74.48 25.51,20.91 58.65,31.38 58.65,31.38 0,0 36.42,78.46 78.83,108.64 31.56,22.46 39.61,23.74 46.5,35.55 6.18,10.6 93.56,62.62 275.1,47.23 127.29,-10.79 138.56,-44.3 138.56,-44.3 0,0 49.41,-21.9 101.15,-80.43 12.87,-14.56 4.41,-13.21 28.57,-17.79 24.16,-4.58 138.01,-45.58 170.66,-154.36C1039.99,175.32 1017.81,96.01 994.52,69.12 971.23,42.22 931.6,24.18 912.25,24.93c-18.47,0.71 -44.78,4.24 -80.21,46.87 -35.43,42.62 -28.94,37.4 -39.36,41.73 -6.82,2.83 -5.68,3.91 -26.75,-11.65 -20.23,-14.93 -28.9,-21.24 -43.38,-27.24 -7.96,-3.3 2.05,-5.55 2.59,-19.48 0.54,-13.93 2.4,-23.51 -17.32,-23.77 -19.72,-0.26 -408.02,0.21 -408.02,0.21 0,0 -18.8,-1.29 -7.79,24.82 4.2,9.94 -1.45,6.43 -33.27,25.85 -31.82,19.42 -55.58,34.4 -72.28,66.09 -8.43,16 -22.91,23.02 -27.97,8.05C153.44,141.43 125.2,48.96 105.17,23.22 85.56,-1.97 77.8,0.26 77.8,0.26Z"
android:strokeLineJoin="miter"
android:strokeWidth="0.41878"
android:fillColor="#000000"
android:strokeColor="#000000"
android:fillType="evenOdd"
android:strokeLineCap="butt"/>
</group>
<group
android:scaleX="0.051679686"
android:scaleY="0.051679686"
android:translateX="27.54"
android:translateY="38.90954">
<path
android:fillColor="#000000"
android:fillType="evenOdd"
android:pathData="m850.19,372.71c87.88,-11.01 119.04,-84.97 123.1,-99.87 4.06,-14.89 24.91,-80.57 11.92,-129.36 -12.99,-48.79 -34.36,-72.36 -58.62,-77.25 -24.25,-4.9 -50.59,10.51 -65,32.81 -14.41,22.3 -14.68,45.14 -14.78,55.29 -0.11,10.15 0.76,23.2 -3.37,33.29 -4.13,10.09 3.23,25.71 6.04,35.23 2.81,9.52 9.67,82.62 5.78,115.57 -3.89,32.95 -5.07,34.29 -5.07,34.29zM0.4,23.58C55.81,77.29 56.45,120.86 56.08,132.92c-0.36,12.06 4.77,130.59 11.47,150.76 4.42,13.3 12.11,50.16 41.78,74.48 25.51,20.91 58.65,31.38 58.65,31.38 0,0 36.42,78.46 78.83,108.64 31.56,22.46 39.61,23.74 46.5,35.55 6.18,10.6 93.56,62.62 275.1,47.23 127.29,-10.79 138.56,-44.3 138.56,-44.3 0,0 49.41,-21.9 101.15,-80.43 12.87,-14.56 4.41,-13.21 28.57,-17.79 24.16,-4.58 138.01,-45.58 170.66,-154.36C1039.99,175.32 1017.81,96.01 994.52,69.12 971.23,42.22 931.6,24.18 912.25,24.93c-18.47,0.71 -44.78,4.24 -80.21,46.87 -35.43,42.62 -28.94,37.4 -39.36,41.73 -6.82,2.83 -5.68,3.91 -26.75,-11.65 -20.23,-14.93 -28.9,-21.24 -43.38,-27.24 -7.96,-3.3 2.05,-5.55 2.59,-19.48 0.54,-13.93 2.4,-23.51 -17.32,-23.77 -19.72,-0.26 -408.02,0.21 -408.02,0.21 0,0 -18.8,-1.29 -7.79,24.82 4.2,9.94 -1.45,6.43 -33.27,25.85 -31.82,19.42 -55.58,34.4 -72.28,66.09 -8.43,16 -22.91,23.02 -27.97,8.05C153.44,141.43 125.2,48.96 105.17,23.22 85.56,-1.97 77.8,0.26 77.8,0.26Z"
android:strokeWidth="0.41878"
android:strokeColor="#000000"
android:strokeLineCap="butt"
android:strokeLineJoin="miter" />
</group>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#FFFFFF"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M18,15v3H6v-3H4v3c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2v-3H18zM17,11l-1.41,-1.41L13,12.17V4h-2v8.17L8.41,9.59L7,11l5,5L17,11z" />
</vector>

View File

@ -1,5 +1,10 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M11,7h2v2h-2zM11,11h2v6h-2zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8z"/>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#FFFFFF"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M11,7h2v2h-2zM11,11h2v6h-2zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8z" />
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#FFFFFF"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M18,15v3H6v-3H4v3c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2v-3H18zM7,9l1.41,1.41L11,7.83V16h2V7.83l2.59,2.58L17,9l-5,-5L7,9z" />
</vector>

View File

@ -79,8 +79,52 @@
android:text="@string/account_login_desc"
android:textColor="?textSecondary" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/linear_account_subscription"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:foreground="?android:selectableItemBackground"
android:gravity="center"
android:orientation="horizontal"
android:padding="7dp">
<ImageView
android:id="@+id/imageView6"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/account"
android:minWidth="48dp"
android:minHeight="48dp"
android:padding="9dp"
android:scaleType="fitXY"
android:src="@drawable/ic_baseline_access_time_24"
app:tint="?iconColor" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/text_account_subscription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/account_subscription"
android:textSize="16sp" />
<TextView
android:id="@+id/text_account_subscription_desc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/account_subscription_desc"
android:textColor="?textSecondary" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
<LinearLayout
@ -176,7 +220,7 @@
android:padding="7dp">
<ImageView
android:id="@+id/imageView4"
android:id="@+id/image_autoplay"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/settings_autoplay"
@ -237,7 +281,7 @@
android:padding="7dp">
<ImageView
android:id="@+id/imageViewTheme"
android:id="@+id/image_theme"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/account"
@ -274,6 +318,118 @@
</LinearLayout>
<LinearLayout
android:id="@+id/linear_dev_settings"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="?themeSecondary"
android:clipToPadding="false"
android:elevation="5dp"
android:orientation="vertical">
<TextView
android:id="@+id/text_dev_settings"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="7dp"
android:paddingEnd="7dp"
android:text="@string/dev_settings"
android:textSize="16sp"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/linear_export_data"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:foreground="?android:selectableItemBackground"
android:gravity="center"
android:orientation="horizontal"
android:padding="7dp">
<ImageView
android:id="@+id/image_export_data"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/info"
android:minWidth="48dp"
android:minHeight="48dp"
android:padding="9dp"
android:scaleType="fitXY"
app:srcCompat="@drawable/ic_outline_upload_24"
app:tint="?iconColor" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/text_export_data"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/export_data"
android:textSize="16sp" />
<TextView
android:id="@+id/text_export_data_desc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/export_data_desc"
android:textColor="?textSecondary" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/linear_import_data"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:foreground="?android:selectableItemBackground"
android:gravity="center"
android:orientation="horizontal"
android:padding="7dp">
<ImageView
android:id="@+id/image_import_data"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/info"
android:minWidth="48dp"
android:minHeight="48dp"
android:padding="9dp"
android:scaleType="fitXY"
app:srcCompat="@drawable/ic_outline_download_24"
app:tint="?iconColor" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/text_import_data"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/import_data"
android:textSize="16sp" />
<TextView
android:id="@+id/text_import_data_desc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/import_data_desc"
android:textColor="?textSecondary" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/linear_info"
android:layout_width="match_parent"

View File

@ -86,7 +86,7 @@
android:id="@+id/button_select"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/save"
android:text="@string/apply"
android:textAllCaps="false"
android:textColor="@color/themePrimaryDark"
android:textSize="16sp"

View File

@ -1,68 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<notices>
<notice>
<name>AndroidX</name>
<url>https://developer.android.com/jetpack/androidx</url>
<copyright>Copyright The Android Open Source Project</copyright>
<license>Apache Software License 2.0</license>
</notice>
<notice>
<name>Material Components for Android</name>
<url>https://github.com/material-components/material-components-android</url>
<copyright>Copyright The Android Open Source Project</copyright>
<license>Apache Software License 2.0</license>
</notice>
<notice>
<name>ExoPlayer</name>
<url>https://github.com/google/ExoPlayer</url>
<copyright>Copyright The Android Open Source Project</copyright>
<license>Apache Software License 2.0</license>
</notice>
<notice>
<name>Gson</name>
<url>https://github.com/google/gson</url>
<copyright>Copyright 2008 Google Inc.</copyright>
<license>Apache Software License 2.0</license>
</notice>
<notice>
<name>Material design icons</name>
<url>https://github.com/google/material-design-icons</url>
<copyright>Copyright Google Inc.</copyright>
<license>Apache Software License 2.0</license>
</notice>
<notice>
<name>Material Dialogs</name>
<url>https://github.com/afollestad/material-dialogs</url>
<copyright>Copyright Aidan Follestad</copyright>
<license>Apache Software License 2.0</license>
</notice>
<notice>
<name>Jsoup</name>
<url>https://jsoup.org/</url>
<copyright>Copyright 2009 - 2020 Jonathan Hedley</copyright>
<license>MIT License</license>
</notice>
<notice>
<name>kotlinx.coroutines</name>
<url>https://github.com/Kotlin/kotlinx.coroutines</url>
<copyright>Copyright 2016 - 2019 JetBrains</copyright>
<license>Apache Software License 2.0</license>
</notice>
<notice>
<name>Glide</name>
<url>https://github.com/bumptech/glide</url>
<copyright>Copyright Google, Inc</copyright>
<license>BSD 2-Clause License</license>
</notice>
<notice>
<name>Glide Transformations</name>
<url>https://github.com/wasabeef/glide-transformations</url>
<copyright>Copyright 2020 Wasabeef</copyright>
<license>Apache Software License 2.0</license>
</notice>
<notice>
<name>The Movie Database API</name>
<url>https://www.themoviedb.org</url>
<copyright>This product uses the TMDb API but is not endorsed or certified by TMDb</copyright>
</notice>
</notices>

View File

@ -22,7 +22,6 @@
<item quantity="one">%d Episode</item>
<item quantity="other">%d Episoden</item>
</plurals>
<string name="text_runtime">%1$d Minuten</string>
<plurals name="text_runtime">
<item quantity="one">%d Minute</item>
<item quantity="other">%d Minuten</item>
@ -34,6 +33,8 @@
<!-- settings fragment -->
<string name="account">Account</string>
<string name="account_login_desc">Zum bearbeiten tippen</string>
<string name="account_subscription">Abo %1$s</string>
<string name="account_subscription_desc">Zum verlängern tippen</string>
<string name="info">Info</string>
<string name="info_about_desc">Version %1$s (%2$s)</string>
<string name="settings">Einstellungen</string>
@ -44,6 +45,12 @@
<string name="theme">Design</string>
<string name="theme_light">Hell</string>
<string name="theme_dark">Dunkel</string>
<string name="dev_settings">Entwickler Einstellungen</string>
<string name="export_data">Daten exportieren</string>
<string name="export_data_desc">Speichere "Meine Liste" in eine Datei</string>
<string name="import_data">Daten importieren</string>
<string name="import_data_desc">Lade "Meine Liste" aus einer Datei</string>
<string name="import_data_success">"Meine Liste" erfolgreich importiert</string>
<!-- about fragment -->
<string name="version">Version</string>
@ -53,6 +60,8 @@
<string name="about_info">Eine inoffizielle App für Anime on Demand.</string>
<string name="third_party_heading">Lizenzen von Drittanbietern</string>
<string name="third_party_component_desc">© %1$s %2$s unter %3$s</string>
<string name="dev_settings_enabled">Du bist jetzt ein Entwickler</string>
<string name="dev_settings_already">Du bist schon ein Entwickler</string>
<!-- player -->
<string name="close_player">Player schließen</string>
@ -75,8 +84,10 @@
<string name="on_login_failed">Login nicht erfolgreich! Stelle sicher das deine Login-Daten korrekt sind und versuche es erneut.</string>
<!-- dialogs -->
<string name="save">speichern</string>
<string name="save">Speichern</string>
<string name="apply">Übernehmen</string>
<string name="cancel">@android:string/cancel</string>
<string name="loading">Lädt…</string>
<string name="dialog_timeout_head">Anmelden fehlgeschlagen</string>
<string name="dialog_timeout_desc">Der Server scheint langsam zu antworten. Bitte versuche es später noch einmal.</string>

View File

@ -29,7 +29,6 @@
<item quantity="one">%d episode</item>
<item quantity="other">%d episodes</item>
</plurals>
<string name="text_runtime">%1$d Minutes</string>
<plurals name="text_runtime">
<item quantity="one">%d Minute</item>
<item quantity="other">%d Minutes</item>
@ -44,6 +43,8 @@
<string name="account">Account</string>
<string name="account_login_ex" translatable="false">user@example.com</string>
<string name="account_login_desc">Tap to edit</string>
<string name="account_subscription">Subscription %1$s</string>
<string name="account_subscription_desc">Tap to extend</string>
<string name="info">Info</string>
<string name="info_about" translatable="false">Teapod by @Seil0</string>
<string name="info_about_desc">Version %1$s (%2$s)</string>
@ -55,6 +56,13 @@
<string name="theme">Theme</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
<string name="dev_settings">Developer Settings</string>
<string name="export_data">export data</string>
<string name="export_data_desc">export "My list" to a file</string>
<string name="import_data">import data</string>
<string name="import_data_desc">import "My list" from a file</string>
<string name="import_data_success">imported "My list" successfully</string>
<!-- about fragment -->
<string name="version">Version</string>
@ -69,6 +77,8 @@
<string name="tmdb_notice" translatable="false">This product uses the TMDb API but is not endorsed or certified by TMDb.</string>
<string name="third_party_heading">Third Party Licenses</string>
<string name="third_party_component_desc">© %1$s %2$s under %3$s</string>
<string name="dev_settings_enabled">You are now a developer</string>
<string name="dev_settings_already">You are already a developer</string>
<!-- player -->
<string name="close_player">close player</string>
@ -95,8 +105,10 @@
<string name="on_login_failed">Could not login! Make sure Username and Password are correct and try again.</string>
<!-- dialogs -->
<string name="save">save</string>
<string name="save">Save</string>
<string name="cancel">@android:string/cancel</string>
<string name="apply">Apply</string>
<string name="loading">Loading…</string>
<string name="dialog_timeout_head">Login failed</string>
<string name="dialog_timeout_desc">Looks like the server is taking to long to respond. Please try again later.</string>
@ -113,12 +125,10 @@
<string name="save_key_user_password" translatable="false">org.mosad.teapod.user_password</string>
<string name="save_key_prefer_secondary" translatable="false">org.mosad.teapod.prefer_secondary</string>
<string name="save_key_autoplay" translatable="false">org.mosad.teapod.autoplay</string>
<string name="save_key_dev_settings" translatable="false">org.mosad.teapod.dev.settings</string>
<string name="save_key_theme" translatable="false">org.mosad.teapod.theme</string>
<!-- intents & states -->
<string name="intent_media_id" translatable="false">intent_media_id</string>
<string name="intent_episode_id" translatable="false">intent_episode_id</string>
<string name="state_resume_window" translatable="false">state_resume_window</string>
<string name="state_resume_position" translatable="false">state_resume_position</string>
<string name="state_is_playing" translatable="false">state_is_playing</string>
</resources>

View File

@ -1,12 +1,12 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = "1.4.31"
ext.kotlin_version = "1.5.20"
repositories {
google()
jcenter()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.1.2'
classpath 'com.android.tools.build:gradle:4.2.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// NOTE: Do not place your application dependencies here; they belong
@ -17,7 +17,7 @@ buildscript {
allprojects {
repositories {
google()
jcenter()
mavenCentral()
}
}

View File

@ -0,0 +1,5 @@
* Entwickleroptionen
* Export/Import für "Meine Liste"
* Der Picture in Picture Modus hat nun Controlls (#35)
* Teapod stürtzt nicht mehr ab, wenn ein Element aus "Meine List" nicht geladen werden konnte (#42)
* Staffel-Informationen im Title werden bei der Suche in tmdb ignoriert (#43)

View File

@ -0,0 +1,5 @@
* Developer options
* Export/Import for "My List"
* The Picture in Picture Modus now has Controlls (#35)
* Teapod deosn't crash, if a element from "My List" could not be loaded (#42)
* Season-Information in titles will be ignored, when searching in tmdb (#43)

Binary file not shown.

View File

@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

2
gradlew vendored
View File

@ -130,7 +130,7 @@ fi
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath

21
gradlew.bat vendored
View File

@ -40,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
@ -54,7 +54,7 @@ goto fail
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
@ -64,21 +64,6 @@ echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
@ -86,7 +71,7 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell