diff --git a/app/build.gradle b/app/build.gradle index 3a624d1..c194ff7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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")) -} \ No newline at end of file +} diff --git a/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt b/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt index faaded4..d68a8fb 100644 --- a/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt +++ b/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt @@ -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 { + 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"), diff --git a/app/src/main/java/org/mosad/teapod/preferences/Preferences.kt b/app/src/main/java/org/mosad/teapod/preferences/Preferences.kt index 0c8b0c2..b5c1d60 100644 --- a/app/src/main/java/org/mosad/teapod/preferences/Preferences.kt +++ b/app/src/main/java/org/mosad/teapod/preferences/Preferences.kt @@ -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() diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/MainActivity.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/MainActivity.kt index 19dd297..b6e1502 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/main/MainActivity.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/MainActivity.kt @@ -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") diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/AboutFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/AboutFragment.kt index a30d129..8c8da0a 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/AboutFragment.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/AboutFragment.kt @@ -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 { return listOf( ThirdPartyComponent("AndroidX", "", "The Android Open Source Project", diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/AccountFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/AccountFragment.kt index 61ca637..64c7b89 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/AccountFragment.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/AccountFragment.kt @@ -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() } } } + } \ No newline at end of file diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/HomeFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/HomeFragment.kt index 6e19d11..1f45bcc 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/HomeFragment.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/HomeFragment.kt @@ -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 { + 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.") + } + } + } + } + } \ No newline at end of file diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/LibraryFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/LibraryFragment.kt index 01ab191..f757b7a 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/LibraryFragment.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/LibraryFragment.kt @@ -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)) } } diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragment.kt index 6e5f750..a762032 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragment.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragment.kt @@ -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() } } diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/SearchFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/SearchFragment.kt index 57c43b1..b430092 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/SearchFragment.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/SearchFragment.kt @@ -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() diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/onboarding/OnLoginFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/onboarding/OnLoginFragment.kt index f65ab30..6a329be 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/onboarding/OnLoginFragment.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/onboarding/OnLoginFragment.kt @@ -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) { diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/player/PlayerActivity.kt b/app/src/main/java/org/mosad/teapod/ui/activity/player/PlayerActivity.kt index 6c29035..66728f4 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/player/PlayerActivity.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/player/PlayerActivity.kt @@ -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() } } } diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/player/PlayerViewModel.kt b/app/src/main/java/org/mosad/teapod/ui/activity/player/PlayerViewModel.kt index 4efb7c4..5dcd69f 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/player/PlayerViewModel.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/player/PlayerViewModel.kt @@ -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) diff --git a/app/src/main/java/org/mosad/teapod/util/StorageController.kt b/app/src/main/java/org/mosad/teapod/util/StorageController.kt index ac4309f..14a0f26 100644 --- a/app/src/main/java/org/mosad/teapod/util/StorageController.kt +++ b/app/src/main/java/org/mosad/teapod/util/StorageController.kt @@ -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() // 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 + } + } \ No newline at end of file diff --git a/app/src/main/java/org/mosad/teapod/util/TMDBApiController.kt b/app/src/main/java/org/mosad/teapod/util/TMDBApiController.kt index bb76090..846d544 100644 --- a/app/src/main/java/org/mosad/teapod/util/TMDBApiController.kt +++ b/app/src/main/java/org/mosad/teapod/util/TMDBApiController.kt @@ -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 { + @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 { + @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() } /** diff --git a/app/src/main/java/org/mosad/teapod/util/adapter/MediaItemAdapter.kt b/app/src/main/java/org/mosad/teapod/util/adapter/MediaItemAdapter.kt index 0038afa..2c23bcf 100644 --- a/app/src/main/java/org/mosad/teapod/util/adapter/MediaItemAdapter.kt +++ b/app/src/main/java/org/mosad/teapod/util/adapter/MediaItemAdapter.kt @@ -49,14 +49,14 @@ class MediaItemAdapter(private val initMedia: List) : 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) } } diff --git a/app/src/main/res/drawable/ic_baseline_access_time_24.xml b/app/src/main/res/drawable/ic_baseline_access_time_24.xml new file mode 100644 index 0000000..04584e8 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_access_time_24.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 6401f43..3e8d0ed 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -3,17 +3,18 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - + + + diff --git a/app/src/main/res/drawable/ic_outline_download_24.xml b/app/src/main/res/drawable/ic_outline_download_24.xml new file mode 100644 index 0000000..b987952 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_download_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_info_24.xml b/app/src/main/res/drawable/ic_outline_info_24.xml index 24bd840..b08088e 100644 --- a/app/src/main/res/drawable/ic_outline_info_24.xml +++ b/app/src/main/res/drawable/ic_outline_info_24.xml @@ -1,5 +1,10 @@ - - + + diff --git a/app/src/main/res/drawable/ic_outline_upload_24.xml b/app/src/main/res/drawable/ic_outline_upload_24.xml new file mode 100644 index 0000000..b343e7c --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_upload_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/fragment_account.xml b/app/src/main/res/layout/fragment_account.xml index 20ed76c..54fda09 100644 --- a/app/src/main/res/layout/fragment_account.xml +++ b/app/src/main/res/layout/fragment_account.xml @@ -79,8 +79,52 @@ android:text="@string/account_login_desc" android:textColor="?textSecondary" /> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - AndroidX - https://developer.android.com/jetpack/androidx - Copyright The Android Open Source Project - Apache Software License 2.0 - - - Material Components for Android - https://github.com/material-components/material-components-android - Copyright The Android Open Source Project - Apache Software License 2.0 - - - ExoPlayer - https://github.com/google/ExoPlayer - Copyright The Android Open Source Project - Apache Software License 2.0 - - - Gson - https://github.com/google/gson - Copyright 2008 Google Inc. - Apache Software License 2.0 - - - Material design icons - https://github.com/google/material-design-icons - Copyright Google Inc. - Apache Software License 2.0 - - - Material Dialogs - https://github.com/afollestad/material-dialogs - Copyright Aidan Follestad - Apache Software License 2.0 - - - Jsoup - https://jsoup.org/ - Copyright 2009 - 2020 Jonathan Hedley - MIT License - - - kotlinx.coroutines - https://github.com/Kotlin/kotlinx.coroutines - Copyright 2016 - 2019 JetBrains - Apache Software License 2.0 - - - Glide - https://github.com/bumptech/glide - Copyright Google, Inc - BSD 2-Clause License - - - Glide Transformations - https://github.com/wasabeef/glide-transformations - Copyright 2020 Wasabeef - Apache Software License 2.0 - - - The Movie Database API - https://www.themoviedb.org - This product uses the TMDb API but is not endorsed or certified by TMDb - - \ No newline at end of file diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index c958865..af2731e 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -22,7 +22,6 @@ %d Episode %d Episoden - %1$d Minuten %d Minute %d Minuten @@ -34,6 +33,8 @@ Account Zum bearbeiten tippen + Abo %1$s + Zum verlängern tippen Info Version %1$s (%2$s) Einstellungen @@ -44,6 +45,12 @@ Design Hell Dunkel + Entwickler Einstellungen + Daten exportieren + Speichere "Meine Liste" in eine Datei + Daten importieren + Lade "Meine Liste" aus einer Datei + "Meine Liste" erfolgreich importiert Version @@ -53,6 +60,8 @@ Eine inoffizielle App für Anime on Demand. Lizenzen von Drittanbietern © %1$s %2$s unter %3$s + Du bist jetzt ein Entwickler + Du bist schon ein Entwickler Player schließen @@ -75,8 +84,10 @@ Login nicht erfolgreich! Stelle sicher das deine Login-Daten korrekt sind und versuche es erneut. - speichern + Speichern + Übernehmen @android:string/cancel + Lädt… Anmelden fehlgeschlagen Der Server scheint langsam zu antworten. Bitte versuche es später noch einmal. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fe2da5e..c7ba0b9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -29,7 +29,6 @@ %d episode %d episodes - %1$d Minutes %d Minute %d Minutes @@ -44,6 +43,8 @@ Account user@example.com Tap to edit + Subscription %1$s + Tap to extend Info Teapod by @Seil0 Version %1$s (%2$s) @@ -55,6 +56,13 @@ Theme Light Dark + Developer Settings + export data + export "My list" to a file + import data + import "My list" from a file + imported "My list" successfully + Version @@ -69,6 +77,8 @@ This product uses the TMDb API but is not endorsed or certified by TMDb. Third Party Licenses © %1$s %2$s under %3$s + You are now a developer + You are already a developer close player @@ -95,8 +105,10 @@ Could not login! Make sure Username and Password are correct and try again. - save + Save @android:string/cancel + Apply + Loading… Login failed Looks like the server is taking to long to respond. Please try again later. @@ -113,12 +125,10 @@ org.mosad.teapod.user_password org.mosad.teapod.prefer_secondary org.mosad.teapod.autoplay + org.mosad.teapod.dev.settings org.mosad.teapod.theme intent_media_id intent_episode_id - state_resume_window - state_resume_position - state_is_playing \ No newline at end of file diff --git a/build.gradle b/build.gradle index d3740b8..0492e3b 100644 --- a/build.gradle +++ b/build.gradle @@ -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() } } diff --git a/fastlane/metadata/android/de/changelogs/4200.txt b/fastlane/metadata/android/de/changelogs/4200.txt new file mode 100644 index 0000000..0747648 --- /dev/null +++ b/fastlane/metadata/android/de/changelogs/4200.txt @@ -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) diff --git a/fastlane/metadata/android/en-US/changelogs/4200.txt b/fastlane/metadata/android/en-US/changelogs/4200.txt new file mode 100644 index 0000000..4e36761 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/4200.txt @@ -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) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 62d4c05..e708b1c 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 4d9ca16..0f80bbf 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew index fbd7c51..4f906e0 100755 --- a/gradlew +++ b/gradlew @@ -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 diff --git a/gradlew.bat b/gradlew.bat index a9f778a..ac1b06f 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -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