diff --git a/README.md b/README.md index c1af88c..57c2a97 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ -# teapod +# Teapod -A unofficial App for Anime-on-Demand. -Teapod is a unofficial App for Anime-on-Demand (AoD). It allows you to watch all your favourite animes from AoD on your Android Device. +Teapod is a unofficial App for Anime on Demand (AoD). It allows you to watch all your favourite animes from AoD on your android device. To use Teapod you need to have a subscription to AoD. [](https://f-droid.org/de/packages/org.mosad.teapod/) -[](https://apt.izzysoft.de/fdroid/index/apk/org.mosad.teapod) ## Features * Watch all animes from AoD on your Android device @@ -19,13 +17,12 @@ Teapod is a unofficial App for Anime-on-Demand (AoD). It allows you to watch all [](https://www.mosad.xyz/images/Teapod/Teapod_Search.webp) ### License -This Project is not associated with Anime-on-Demand in any way. Using this app may violates the ToS of AoD. Teapod is licensed under the terms and conditions of GPL 3. - -### Known Issues -If a tv show is selected, the first episode will be marked as watched. This is due to parsing the website. +Teapod is licensed under the terms and conditions of GPL 3. This Project is not associated with Anime on Demand in any way. But they allow open source apps for their service. ### Contributing -If you want to contribute to Teapod and need an account on this gitea instance, please write me an email: seil0@mosad.xyz +Currentl you need to have an AoD account to contrtibut to Teapod. Contributing without on is kind of impossible.If you want to contribute to Teapod and need an account on this gitea instance, please write me an email. + +### [FAQ](https://git.mosad.xyz/Seil0/teapod/wiki#hilfe) #### Why is it called Teapod? Teapod is a Acronym for "The ultimate anime app on demand", hence this project is called Teapod and not Teapot. diff --git a/app/build.gradle b/app/build.gradle index 110fbdb..bab39ce 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,8 +10,8 @@ android { applicationId "org.mosad.teapod" minSdkVersion 23 targetSdkVersion 30 - versionCode 3000 //00.03.000 - versionName "0.3.0" + versionCode 4000 //00.04.000 + versionName "0.4.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" resValue "string", "build_time", buildTime() @@ -46,20 +46,20 @@ dependencies { implementation 'androidx.core:core-ktx:1.3.2' implementation 'androidx.appcompat:appcompat:1.2.0' implementation 'androidx.constraintlayout:constraintlayout:2.0.4' - implementation 'androidx.navigation:navigation-fragment-ktx:2.3.2' - implementation 'androidx.navigation:navigation-ui-ktx:2.3.2' + implementation 'androidx.navigation:navigation-fragment-ktx:2.3.3' + implementation 'androidx.navigation:navigation-ui-ktx:2.3.3' implementation 'androidx.security:security-crypto:1.1.0-alpha03' implementation 'androidx.legacy:legacy-support-v4:1.0.0' - implementation 'com.google.android.material:material:1.3.0-beta01' + 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.12.2' - implementation 'com.google.android.exoplayer:exoplayer-hls:2.12.2' - implementation 'com.google.android.exoplayer:exoplayer-dash:2.12.2' - implementation 'com.google.android.exoplayer:exoplayer-ui:2.12.2' + 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 'org.jsoup:jsoup:1.13.1' - implementation 'com.github.bumptech.glide:glide:4.11.0' + implementation 'com.github.bumptech.glide:glide:4.12.0' implementation 'jp.wasabeef:glide-transformations:4.3.0' implementation 'com.afollestad.material-dialogs:core:3.3.0' implementation 'com.afollestad.material-dialogs:bottomsheets:3.3.0' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1a62b38..9cd30f9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ @@ -10,9 +11,9 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/AppTheme.Light"> + android:theme="@style/AppTheme.Dark"> @@ -22,15 +23,28 @@ + android:screenOrientation="portrait" + android:launchMode="singleTop" + android:windowSoftInputMode="adjustPan"> + + \ 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 46d73b0..defa271 100644 --- a/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt +++ b/app/src/main/java/org/mosad/teapod/parser/AoDParser.kt @@ -41,7 +41,7 @@ object AoDParser { private const val loginPath = "/users/sign_in" private const val libraryPath = "/animes" - private const val userAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:80.0) Gecko/20100101 Firefox/80.0" + private const val userAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:84.0) Gecko/20100101 Firefox/84.0" private var sessionCookies = mutableMapOf() private var csrfToken: String = "" @@ -53,10 +53,11 @@ object AoDParser { val newEpisodesList = arrayListOf() val newSimulcastsList = arrayListOf() val newTitlesList = arrayListOf() + val topTenList = arrayListOf() fun login(): Boolean = runBlocking { - withContext(Dispatchers.Default) { + withContext(Dispatchers.IO) { // get the authenticity token val resAuth = Jsoup.connect(baseUrl + loginPath) .header("User-Agent", userAgent) @@ -78,7 +79,7 @@ object AoDParser { val resLogin = Jsoup.connect(baseUrl + loginPath) .method(Connection.Method.POST) - .timeout(60000) // login can take some time + .timeout(60000) // login can take some time default is 60000 (60 sec) .data(data) .postDataCharset("UTF-8") .cookies(authCookies) @@ -96,20 +97,11 @@ object AoDParser { /** * initially load all media and home screen data - * -> blocking */ - fun initialLoading() = runBlocking { - val loadHomeJob = GlobalScope.async { - loadHome() - } - - val listJob = GlobalScope.async { + fun initialLoading() = listOf( + loadHome(), listAnimes() - } - - loadHomeJob.await() - listJob.await() - } + ) /** * get a media by it's ID (int) @@ -134,7 +126,7 @@ object AoDParser { } // TODO don't use jsoup here - fun sendCallback(callbackPath: String) = GlobalScope.launch(Dispatchers.IO) { + 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"), @@ -158,112 +150,112 @@ object AoDParser { /** * load all media from aod into itemMediaList and mediaList */ - private fun listAnimes() = runBlocking { - if (sessionCookies.isEmpty()) login() + private fun listAnimes() = GlobalScope.launch(Dispatchers.IO) { + val resAnimes = Jsoup.connect(baseUrl + libraryPath).get() + //println(resAnimes) - withContext(Dispatchers.Default) { - val resAnimes = Jsoup.connect(baseUrl + libraryPath) - .cookies(sessionCookies) - .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 - } - 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 - }) + 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 } + 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() - Log.i(javaClass.name, "Total library size is: ${mediaList.size}") + 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}") } /** * load new episodes, titles and highlights */ - private fun loadHome() = runBlocking { - if (sessionCookies.isEmpty()) login() + private fun loadHome() = GlobalScope.launch(Dispatchers.IO) { + val resHome = Jsoup.connect(baseUrl).get() - withContext(Dispatchers.Default) { - val resHome = Jsoup.connect(baseUrl) - .cookies(sessionCookies) - .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)) } + } - // 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,"", "")) - } + // get top ten from AoD + topTenList.clear() + resHome.select("h2:contains(Anime Top 10)").next().select("li").forEach { + val mediaId = it.select("a.thumbs").attr("href") + .substringAfterLast("/").toIntOrNull() + val mediaImage = it.select("a.thumbs > img").attr("src") + val mediaTitle = it.select("a").text() + + if (mediaId != null) { + topTenList.add(ItemMedia(mediaId, mediaTitle, mediaImage)) + } + } + + // if highlights is empty, add a random new title + if (highlightsList.isEmpty()) { + if (newTitlesList.isNotEmpty()) { + highlightsList.add(newTitlesList[Random.nextInt(0, newTitlesList.size)]) + } else { + highlightsList.add(ItemMedia(0,"", "")) } } } @@ -272,7 +264,7 @@ object AoDParser { * load streams for the media path, movies have one episode * @param media is used as call ba reference */ - private suspend fun loadStreams(media: Media) = GlobalScope.launch(Dispatchers.IO) { + private fun loadStreams(media: Media) = GlobalScope.launch(Dispatchers.IO) { if (sessionCookies.isEmpty()) login() if (!loginSuccess) { @@ -331,7 +323,7 @@ object AoDParser { } Log.i(javaClass.name, "Loaded playlists successfully") - // parse additional info from the media page + // 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() @@ -345,7 +337,21 @@ object AoDParser { } } - // parse additional information for tv shows the episode title (description) is loaded from the "api" + // 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 @@ -363,6 +369,7 @@ object AoDParser { } } } + Log.i(javaClass.name, "media loaded successfully") } /** 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 172fb92..0c8b0c2 100644 --- a/app/src/main/java/org/mosad/teapod/preferences/Preferences.kt +++ b/app/src/main/java/org/mosad/teapod/preferences/Preferences.kt @@ -11,7 +11,7 @@ object Preferences { internal set var autoplay = true internal set - var theme = DataTypes.Theme.LIGHT + var theme = DataTypes.Theme.DARK internal set private fun getSharedPref(context: Context): SharedPreferences { @@ -62,8 +62,8 @@ object Preferences { ) theme = DataTypes.Theme.valueOf( sharedPref.getString( - context.getString(R.string.save_key_theme), DataTypes.Theme.LIGHT.toString() - ) ?: DataTypes.Theme.LIGHT.toString() + context.getString(R.string.save_key_theme), DataTypes.Theme.DARK.toString() + ) ?: DataTypes.Theme.DARK.toString() ) } diff --git a/app/src/main/java/org/mosad/teapod/SplashActivity.kt b/app/src/main/java/org/mosad/teapod/ui/activity/SplashActivity.kt similarity index 80% rename from app/src/main/java/org/mosad/teapod/SplashActivity.kt rename to app/src/main/java/org/mosad/teapod/ui/activity/SplashActivity.kt index 54515b2..c7dfa49 100644 --- a/app/src/main/java/org/mosad/teapod/SplashActivity.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/SplashActivity.kt @@ -1,8 +1,9 @@ -package org.mosad.teapod +package org.mosad.teapod.ui.activity import android.content.Intent import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import org.mosad.teapod.ui.activity.main.MainActivity class SplashActivity : AppCompatActivity() { diff --git a/app/src/main/java/org/mosad/teapod/MainActivity.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/MainActivity.kt similarity index 68% rename from app/src/main/java/org/mosad/teapod/MainActivity.kt rename to app/src/main/java/org/mosad/teapod/ui/activity/main/MainActivity.kt index 5e74c0a..19dd297 100644 --- a/app/src/main/java/org/mosad/teapod/MainActivity.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/MainActivity.kt @@ -20,7 +20,7 @@ * */ -package org.mosad.teapod +package org.mosad.teapod.ui.activity.main import android.content.Intent import android.os.Bundle @@ -29,16 +29,27 @@ import android.view.MenuItem import androidx.appcompat.app.AppCompatActivity 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 org.mosad.teapod.R import org.mosad.teapod.databinding.ActivityMainBinding import org.mosad.teapod.parser.AoDParser -import org.mosad.teapod.player.PlayerActivity +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.fragments.* +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.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 { @@ -48,6 +59,11 @@ class MainActivity : AppCompatActivity(), BottomNavigationView.OnNavigationItemS companion object { var wasInitialized = false + lateinit var instance: MainActivity + } + + init { + instance = this } override fun onCreate(savedInstanceState: Bundle?) { @@ -111,39 +127,57 @@ class MainActivity : AppCompatActivity(), BottomNavigationView.OnNavigationItemS private fun getThemeResource(): Int { return when (Preferences.theme) { - DataTypes.Theme.DARK -> R.style.AppTheme_Dark - else -> R.style.AppTheme_Light + DataTypes.Theme.LIGHT -> R.style.AppTheme_Light + else -> R.style.AppTheme_Dark } } + /** + * initial loading and login are run in parallel, as initial loading doesn't require + * any login cookies + */ private fun load() { - // running login and list in parallel does not bring any speed improvements val time = measureTimeMillis { - Preferences.load(this) + val loadingJob = AoDParser.initialLoading() // start the initial loading - // make sure credentials are set, run's async + // load all saved stuff here + Preferences.load(this) EncryptedPreferences.readCredentials(this) + StorageController.load(this) + + // show onbaording if (EncryptedPreferences.password.isEmpty()) { - showLoginDialog(true) + showOnboarding() } else { - // try to login in, as most sites can only bee loaded once loged in - if (!AoDParser.login()) showLoginDialog(false) + try { + if (!AoDParser.login()) { + showLoginDialog() + } + } catch (ex: SocketTimeoutException) { + Log.w(javaClass.name, "Timeout during login!") + + // show waring dialog before finishing + MaterialDialog(this).show { + title(R.string.dialog_timeout_head) + message(R.string.dialog_timeout_desc) + onDismiss { exitAndRemoveTask() } + } + } } - StorageController.load(this) - AoDParser.initialLoading() - - wasInitialized = true + runBlocking { loadingJob.joinAll() } // wait for initial loading to finish } - Log.i(javaClass.name, "login and list in $time ms") + Log.i(javaClass.name, "loading and login in $time ms") + + wasInitialized = true } - private fun showLoginDialog(firstTry: Boolean) { - LoginDialog(this, firstTry).positiveButton { + private fun showLoginDialog() { + LoginDialog(this, false).positiveButton { EncryptedPreferences.saveCredentials(login, password, context) if (!AoDParser.login()) { - showLoginDialog(false) + showLoginDialog() Log.w(javaClass.name, "Login failed, please try again.") } }.negativeButton { @@ -153,18 +187,16 @@ class MainActivity : AppCompatActivity(), BottomNavigationView.OnNavigationItemS } /** - * Show a fragment on top of the current fragment. - * The current fragment is replaced and the new one is added - * to the back stack. + * start the onboarding activity and finish the main activity */ - fun showFragment(fragment: Fragment) { - supportFragmentManager.commit { - replace(R.id.nav_host_fragment, fragment, fragment.javaClass.simpleName) - addToBackStack(fragment.javaClass.name) - show(fragment) - } + private fun showOnboarding() { + startActivity(Intent(this, OnboardingActivity::class.java)) + finish() } + /** + * start the player as new activity + */ fun startPlayer(mediaId: Int, episodeId: Int) { val intent = Intent(this, PlayerActivity::class.java).apply { putExtra(getString(R.string.intent_media_id), mediaId) @@ -183,5 +215,4 @@ class MainActivity : AppCompatActivity(), BottomNavigationView.OnNavigationItemS startActivity(restartIntent) } - } \ No newline at end of file diff --git a/app/src/main/java/org/mosad/teapod/ui/fragments/AboutFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/AboutFragment.kt similarity index 84% rename from app/src/main/java/org/mosad/teapod/ui/fragments/AboutFragment.kt rename to app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/AboutFragment.kt index b1c7fb2..a30d129 100644 --- a/app/src/main/java/org/mosad/teapod/ui/fragments/AboutFragment.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/AboutFragment.kt @@ -1,5 +1,7 @@ -package org.mosad.teapod.ui.fragments +package org.mosad.teapod.ui.activity.main.fragments +import android.content.Intent +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -17,6 +19,7 @@ import java.lang.StringBuilder class AboutFragment : Fragment() { + private val teapodRepoUrl = "https://git.mosad.xyz/Seil0/teapod" private lateinit var binding: FragmentAboutBinding override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { @@ -27,7 +30,7 @@ class AboutFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - binding.textVersion.text = getString(R.string.info_about_desc, BuildConfig.VERSION_NAME, getString(R.string.build_time)) + binding.textVersionDesc.text = getString(R.string.version_desc, BuildConfig.VERSION_NAME, getString(R.string.build_time)) getThirdPartyComponents().forEach { thirdParty -> val componentBinding = ItemComponentBinding.inflate(layoutInflater) //(R.layout.item_component, container, false) @@ -44,10 +47,25 @@ class AboutFragment : Fragment() { binding.linearThirdParty.addView(componentBinding.root) } + + initActions() + } + + private fun initActions() { + binding.linearSource.setOnClickListener { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(teapodRepoUrl))) + } + + binding.linearLicense.setOnClickListener { + MaterialDialog(requireContext()) + .title(text = License.GPL3.long) + .message(text = parseLicense(R.raw.gpl_3_full)) + .show() + } } private fun getThirdPartyComponents(): List { - return listOf( + return listOf( ThirdPartyComponent("AndroidX", "", "The Android Open Source Project", "https://developer.android.com/jetpack/androidx", License.APACHE2), ThirdPartyComponent("Material Components for Android", "2020", "The Android Open Source Project", @@ -79,13 +97,10 @@ class AboutFragment : Fragment() { License.MIT -> parseLicense(R.raw.mit_full) } - println("showing: ${license.long}") - MaterialDialog(requireContext()) .title(text = license.long) .message(text = licenseText) .show() - } private fun parseLicense(@RawRes id: Int): String { diff --git a/app/src/main/java/org/mosad/teapod/ui/fragments/AccountFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/AccountFragment.kt similarity index 94% rename from app/src/main/java/org/mosad/teapod/ui/fragments/AccountFragment.kt rename to app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/AccountFragment.kt index b87a753..61ca637 100644 --- a/app/src/main/java/org/mosad/teapod/ui/fragments/AccountFragment.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/AccountFragment.kt @@ -1,4 +1,4 @@ -package org.mosad.teapod.ui.fragments +package org.mosad.teapod.ui.activity.main.fragments import android.os.Bundle import android.util.Log @@ -9,7 +9,7 @@ import androidx.fragment.app.Fragment import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.list.listItemsSingleChoice import org.mosad.teapod.BuildConfig -import org.mosad.teapod.MainActivity +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 @@ -17,6 +17,7 @@ import org.mosad.teapod.preferences.EncryptedPreferences import org.mosad.teapod.preferences.Preferences import org.mosad.teapod.ui.components.LoginDialog import org.mosad.teapod.util.DataTypes.Theme +import org.mosad.teapod.util.showFragment class AccountFragment : Fragment() { @@ -53,7 +54,7 @@ class AccountFragment : Fragment() { } binding.linearInfo.setOnClickListener { - (activity as MainActivity).showFragment(AboutFragment()) + activity?.showFragment(AboutFragment()) } binding.switchSecondary.setOnClickListener { diff --git a/app/src/main/java/org/mosad/teapod/ui/fragments/HomeFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/HomeFragment.kt similarity index 74% rename from app/src/main/java/org/mosad/teapod/ui/fragments/HomeFragment.kt rename to app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/HomeFragment.kt index c63ed15..6e19d11 100644 --- a/app/src/main/java/org/mosad/teapod/ui/fragments/HomeFragment.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/HomeFragment.kt @@ -1,27 +1,25 @@ -package org.mosad.teapod.ui.fragments +package org.mosad.teapod.ui.activity.main.fragments -import android.graphics.drawable.Drawable import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.TextView import androidx.fragment.app.Fragment import com.bumptech.glide.Glide -import com.bumptech.glide.request.target.CustomTarget -import com.bumptech.glide.request.transition.Transition import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -import org.mosad.teapod.MainActivity 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.util.ItemMedia import org.mosad.teapod.util.StorageController import org.mosad.teapod.util.adapter.MediaItemAdapter import org.mosad.teapod.util.decoration.MediaItemDecoration +import org.mosad.teapod.util.setDrawableTop +import org.mosad.teapod.util.showFragment class HomeFragment : Fragment() { @@ -30,6 +28,7 @@ class HomeFragment : Fragment() { private lateinit var adapterNewEpisodes: MediaItemAdapter private lateinit var adapterNewSimulcasts: MediaItemAdapter private lateinit var adapterNewTitles: MediaItemAdapter + private lateinit var adapterTopTen: MediaItemAdapter private lateinit var highlightMedia: ItemMedia @@ -59,9 +58,9 @@ class HomeFragment : Fragment() { .into(binding.imageHighlight) if (StorageController.myList.contains(highlightMedia.id)) { - loadIntoCompoundDrawable(R.drawable.ic_baseline_check_24, binding.textHighlightMyList) + binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_check_24) } else { - loadIntoCompoundDrawable(R.drawable.ic_baseline_add_24, binding.textHighlightMyList) + binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_add_24) } } } @@ -71,6 +70,7 @@ class HomeFragment : Fragment() { binding.recyclerNewEpisodes.addItemDecoration(MediaItemDecoration(9)) binding.recyclerNewSimulcasts.addItemDecoration(MediaItemDecoration(9)) binding.recyclerNewTitles.addItemDecoration(MediaItemDecoration(9)) + binding.recyclerTopTen.addItemDecoration(MediaItemDecoration(9)) // my list val myListMedia = StorageController.myList.map { elementId -> @@ -79,9 +79,6 @@ class HomeFragment : Fragment() { } } adapterMyList = MediaItemAdapter(myListMedia) - adapterMyList.onItemClick = { mediaId, _ -> - (activity as MainActivity).showFragment(MediaFragment(mediaId)) - } binding.recyclerMyList.adapter = adapterMyList // new episodes @@ -95,6 +92,10 @@ class HomeFragment : Fragment() { // new titles adapterNewTitles = MediaItemAdapter(AoDParser.newTitlesList) binding.recyclerNewTitles.adapter = adapterNewTitles + + // top ten + adapterTopTen = MediaItemAdapter(AoDParser.topTenList) + binding.recyclerTopTen.adapter = adapterTopTen } private fun initActions() { @@ -111,10 +112,10 @@ class HomeFragment : Fragment() { binding.textHighlightMyList.setOnClickListener { if (StorageController.myList.contains(highlightMedia.id)) { StorageController.myList.remove(highlightMedia.id) - loadIntoCompoundDrawable(R.drawable.ic_baseline_add_24, binding.textHighlightMyList) + binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_add_24) } else { StorageController.myList.add(highlightMedia.id) - loadIntoCompoundDrawable(R.drawable.ic_baseline_check_24, binding.textHighlightMyList) + binding.textHighlightMyList.setDrawableTop(R.drawable.ic_baseline_check_24) } StorageController.saveMyList(requireContext()) @@ -122,19 +123,27 @@ class HomeFragment : Fragment() { } binding.textHighlightInfo.setOnClickListener { - (activity as MainActivity).showFragment(MediaFragment(highlightMedia.id)) + activity?.showFragment(MediaFragment(highlightMedia.id)) + } + + adapterMyList.onItemClick = { mediaId, _ -> + activity?.showFragment(MediaFragment(mediaId)) } adapterNewEpisodes.onItemClick = { mediaId, _ -> - (activity as MainActivity).showFragment(MediaFragment(mediaId)) + activity?.showFragment(MediaFragment(mediaId)) } adapterNewSimulcasts.onItemClick = { mediaId, _ -> - (activity as MainActivity).showFragment(MediaFragment(mediaId)) + activity?.showFragment(MediaFragment(mediaId)) } adapterNewTitles.onItemClick = { mediaId, _ -> - (activity as MainActivity).showFragment(MediaFragment(mediaId)) + activity?.showFragment(MediaFragment(mediaId)) + } + + adapterTopTen.onItemClick = { mediaId, _ -> + activity?.showFragment(MediaFragment(mediaId)) } } @@ -155,18 +164,4 @@ class HomeFragment : Fragment() { adapterMyList.notifyDataSetChanged() } - private fun loadIntoCompoundDrawable(drawable: Int, textView: TextView) { - Glide.with(requireContext()) - .load(drawable) - .into(object : CustomTarget(48, 48) { - override fun onLoadCleared(drawable: Drawable?) { - textView.setCompoundDrawablesWithIntrinsicBounds(null, drawable, null, null) - } - - override fun onResourceReady(res: Drawable, transition: Transition?) { - textView.setCompoundDrawablesWithIntrinsicBounds(null, res, null, null) - } - - }) - } } \ No newline at end of file diff --git a/app/src/main/java/org/mosad/teapod/ui/fragments/LibraryFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/LibraryFragment.kt similarity index 90% rename from app/src/main/java/org/mosad/teapod/ui/fragments/LibraryFragment.kt rename to app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/LibraryFragment.kt index 4429e6a..01ab191 100644 --- a/app/src/main/java/org/mosad/teapod/ui/fragments/LibraryFragment.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/LibraryFragment.kt @@ -1,4 +1,4 @@ -package org.mosad.teapod.ui.fragments +package org.mosad.teapod.ui.activity.main.fragments import android.os.Bundle import android.view.LayoutInflater @@ -9,11 +9,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.mosad.teapod.MainActivity import org.mosad.teapod.databinding.FragmentLibraryBinding import org.mosad.teapod.parser.AoDParser import org.mosad.teapod.util.adapter.MediaItemAdapter import org.mosad.teapod.util.decoration.MediaItemDecoration +import org.mosad.teapod.util.showFragment class LibraryFragment : Fragment() { @@ -35,7 +35,7 @@ class LibraryFragment : Fragment() { context?.let { adapter = MediaItemAdapter(AoDParser.itemMediaList) adapter.onItemClick = { mediaId, _ -> - (activity as MainActivity).showFragment(MediaFragment(mediaId)) + activity?.showFragment(MediaFragment(mediaId)) } binding.recyclerMediaLibrary.adapter = adapter 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 new file mode 100644 index 0000000..39c86b8 --- /dev/null +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragment.kt @@ -0,0 +1,210 @@ +package org.mosad.teapod.ui.activity.main.fragments + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.activityViewModels +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 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 + + +/** + * The media detail fragment. + * Note: the fragment is created only once, when selecting a similar title etc. + * therefore fragments may be not empty and model may be the old one + */ +class MediaFragment(private val mediaId: Int) : Fragment() { + + private lateinit var binding: FragmentMediaBinding + private lateinit var pagerAdapter: FragmentStateAdapter + + private val fragments = arrayListOf() + + private val model: MediaFragmentViewModel by activityViewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragmentMediaBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.frameLoading.visibility = View.VISIBLE + + // tab layout and pager TODO + pagerAdapter = ScreenSlidePagerAdapter(requireActivity()) + // fix material components issue #1878, if more tabs are added increase + binding.pagerEpisodesSimilar.offscreenPageLimit = 2 + binding.pagerEpisodesSimilar.adapter = pagerAdapter + TabLayoutMediator(binding.tabEpisodesSimilar, binding.pagerEpisodesSimilar) { tab, position -> + tab.text = if (model.media.type == MediaType.TVSHOW && position == 0) { + getString(R.string.episodes) + } else { + getString(R.string.similar_titles) + } + }.attach() + + GlobalScope.launch(Dispatchers.Main) { + model.load(mediaId) // load the streams and tmdb for the selected media + + if (this@MediaFragment.isAdded) { + updateGUI() + initActions() + } + } + } + + override fun onResume() { + super.onResume() + + // update the next ep text if there is one, since it may have changed + if (model.nextEpisode.title.isNotEmpty()) { + binding.textTitle.text = model.nextEpisode.title + } + } + + /** + * if tmdb data is present, use it, else use the aod data + */ + private fun updateGUI() = with(model) { + // generic gui + val backdropUrl = if (tmdb.backdropUrl.isNotEmpty()) tmdb.backdropUrl else media.info.posterUrl + val posterUrl = if (tmdb.posterUrl.isNotEmpty()) tmdb.posterUrl else media.info.posterUrl + + Glide.with(requireContext()).load(backdropUrl) + .apply(RequestOptions.placeholderOf(ColorDrawable(Color.DKGRAY))) + .apply(RequestOptions.bitmapTransform(BlurTransformation(20, 3))) + .into(binding.imageBackdrop) + + Glide.with(requireContext()).load(posterUrl) + .into(binding.imagePoster) + + binding.textTitle.text = media.info.title + binding.textYear.text = media.info.year.toString() + binding.textAge.text = media.info.age.toString() + binding.textOverview.text = media.info.shortDesc + if (StorageController.myList.contains(media.id)) { + Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(binding.imageMyListAction) + } else { + Glide.with(requireContext()).load(R.drawable.ic_baseline_add_24).into(binding.imageMyListAction) + } + + // clear fragments, since it lives in onCreate scope (don't do this in onPause/onStop -> FragmentManager transaction) + fragments.clear() + pagerAdapter.notifyDataSetChanged() + + // specific gui + if (media.type == MediaType.TVSHOW) { + // get next episode + nextEpisode = if (media.episodes.firstOrNull{ !it.watched } != null) { + media.episodes.first{ !it.watched } + } else { + media.episodes.first() + } + + // title is the next episodes title + binding.textTitle.text = nextEpisode.title + + // episodes count + binding.textEpisodesOrRuntime.text = resources.getQuantityString( + R.plurals.text_episodes_count, + media.info.episodesCount, + media.info.episodesCount + ) + + // episodes + fragments.add(MediaFragmentEpisodes()) + pagerAdapter.notifyDataSetChanged() + } else if (media.type == MediaType.MOVIE) { + + if (tmdb.runtime > 0) { + binding.textEpisodesOrRuntime.text = resources.getQuantityString( + R.plurals.text_runtime, + tmdb.runtime, + tmdb.runtime + ) + } else { + binding.textEpisodesOrRuntime.visibility = View.GONE + } + } + + // if has similar titles + if (media.info.similar.isNotEmpty()) { + fragments.add(MediaFragmentSimilar()) + pagerAdapter.notifyDataSetChanged() + } + + // disable scrolling on appbar, if no tabs where added + if(fragments.isEmpty()) { + val params = binding.linearMedia.layoutParams as AppBarLayout.LayoutParams + params.scrollFlags = 0 // clear all scroll flags + } + + binding.frameLoading.visibility = View.GONE // hide loading indicator + } + + private fun initActions() = with(model) { + binding.buttonPlay.setOnClickListener { + when (media.type) { + MediaType.MOVIE -> playEpisode(media.episodes.first()) + MediaType.TVSHOW -> playEpisode(nextEpisode) + else -> Log.e(javaClass.name, "Wrong Type: $media.type") + } + } + + // add or remove media from myList + binding.linearMyListAction.setOnClickListener { + if (StorageController.myList.contains(media.id)) { + StorageController.myList.remove(media.id) + Glide.with(requireContext()).load(R.drawable.ic_baseline_add_24).into(binding.imageMyListAction) + } else { + StorageController.myList.add(media.id) + Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(binding.imageMyListAction) + } + StorageController.saveMyList(requireContext()) + + // notify home fragment on change + parentFragmentManager.findFragmentByTag("HomeFragment")?.let { + (it as HomeFragment).updateMyListMedia() + } + } + } + + /** + * play the current episode + * TODO this is also used in MediaFragmentEpisode, we should only have on implementation + */ + private fun playEpisode(ep: Episode) { + (activity as MainActivity).startPlayer(model.media.id, ep.id) + Log.d(javaClass.name, "Started Player with episodeId: ${ep.id}") + + model.updateNextEpisode(ep) // set the correct next episode + } + + /** + * A simple pager adapter + */ + private inner class ScreenSlidePagerAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) { + override fun getItemCount(): Int = fragments.size + + override fun createFragment(position: Int): Fragment = fragments[position] + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragmentEpisodes.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragmentEpisodes.kt new file mode 100644 index 0000000..78f480f --- /dev/null +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragmentEpisodes.kt @@ -0,0 +1,61 @@ +package org.mosad.teapod.ui.activity.main.fragments + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import org.mosad.teapod.ui.activity.main.MainActivity +import org.mosad.teapod.ui.activity.main.viewmodel.MediaFragmentViewModel +import org.mosad.teapod.databinding.FragmentMediaEpisodesBinding +import org.mosad.teapod.util.Episode +import org.mosad.teapod.util.adapter.EpisodeItemAdapter + +class MediaFragmentEpisodes : Fragment() { + + private lateinit var binding: FragmentMediaEpisodesBinding + private lateinit var adapterRecEpisodes: EpisodeItemAdapter + + private val model: MediaFragmentViewModel by activityViewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragmentMediaEpisodesBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + adapterRecEpisodes = EpisodeItemAdapter(model.media.episodes) + binding.recyclerEpisodes.adapter = adapterRecEpisodes + + // set onItemClick only in adapter is initialized + if (this::adapterRecEpisodes.isInitialized) { + adapterRecEpisodes.onImageClick = { _, position -> + playEpisode(model.media.episodes[position]) + } + } + } + + override fun onResume() { + super.onResume() + + // if adapterRecEpisodes is initialized, update the watched state for the episodes + if (this::adapterRecEpisodes.isInitialized) { + model.media.episodes.forEachIndexed { index, episode -> + adapterRecEpisodes.updateWatchedState(episode.watched, index) + } + adapterRecEpisodes.notifyDataSetChanged() + } + } + + private fun playEpisode(ep: Episode) { + (activity as MainActivity).startPlayer(model.media.id, ep.id) + Log.d(javaClass.name, "Started Player with episodeId: ${ep.id}") + + model.updateNextEpisode(ep) // set the correct next episode + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragmentSimilar.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragmentSimilar.kt new file mode 100644 index 0000000..db6d519 --- /dev/null +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragmentSimilar.kt @@ -0,0 +1,41 @@ +package org.mosad.teapod.ui.activity.main.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import org.mosad.teapod.databinding.FragmentMediaSimilarBinding +import org.mosad.teapod.ui.activity.main.viewmodel.MediaFragmentViewModel +import org.mosad.teapod.util.adapter.MediaItemAdapter +import org.mosad.teapod.util.decoration.MediaItemDecoration +import org.mosad.teapod.util.showFragment + +class MediaFragmentSimilar : Fragment() { + + private lateinit var binding: FragmentMediaSimilarBinding + private val model: MediaFragmentViewModel by activityViewModels() + + private lateinit var adapterSimilar: MediaItemAdapter + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragmentMediaSimilarBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + adapterSimilar = MediaItemAdapter(model.media.info.similar) + binding.recyclerMediaSimilar.adapter = adapterSimilar + binding.recyclerMediaSimilar.addItemDecoration(MediaItemDecoration(9)) + + // set onItemClick only in adapter is initialized + if (this::adapterSimilar.isInitialized) { + adapterSimilar.onItemClick = { mediaId, _ -> + activity?.showFragment(MediaFragment(mediaId)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/mosad/teapod/ui/fragments/SearchFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/SearchFragment.kt similarity index 92% rename from app/src/main/java/org/mosad/teapod/ui/fragments/SearchFragment.kt rename to app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/SearchFragment.kt index 6810030..57c43b1 100644 --- a/app/src/main/java/org/mosad/teapod/ui/fragments/SearchFragment.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/SearchFragment.kt @@ -1,4 +1,4 @@ -package org.mosad.teapod.ui.fragments +package org.mosad.teapod.ui.activity.main.fragments import android.os.Bundle import android.view.LayoutInflater @@ -7,11 +7,11 @@ import android.view.ViewGroup import android.widget.SearchView import androidx.fragment.app.Fragment import kotlinx.coroutines.* -import org.mosad.teapod.MainActivity import org.mosad.teapod.databinding.FragmentSearchBinding import org.mosad.teapod.parser.AoDParser import org.mosad.teapod.util.decoration.MediaItemDecoration import org.mosad.teapod.util.adapter.MediaItemAdapter +import org.mosad.teapod.util.showFragment class SearchFragment : Fragment() { @@ -33,7 +33,7 @@ class SearchFragment : Fragment() { adapter = MediaItemAdapter(AoDParser.itemMediaList) adapter!!.onItemClick = { mediaId, _ -> binding.searchText.clearFocus() - (activity as MainActivity).showFragment(MediaFragment(mediaId)) + activity?.showFragment(MediaFragment(mediaId)) } binding.recyclerMediaSearch.adapter = adapter diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/viewmodel/MediaFragmentViewModel.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/viewmodel/MediaFragmentViewModel.kt new file mode 100644 index 0000000..c2ba21d --- /dev/null +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/viewmodel/MediaFragmentViewModel.kt @@ -0,0 +1,48 @@ +package org.mosad.teapod.ui.activity.main.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import org.mosad.teapod.parser.AoDParser +import org.mosad.teapod.util.* +import org.mosad.teapod.util.DataTypes.MediaType + +/** + * handle media, next ep and tmdb + */ +class MediaFragmentViewModel(application: Application) : AndroidViewModel(application) { + + var media = Media(-1, "", MediaType.OTHER) + internal set + var nextEpisode = Episode() + internal set + var tmdb = TMDBResponse() + internal set + + /** + * set media, tmdb and nextEpisode + */ + suspend fun load(mediaId: Int) { + media = AoDParser.getMediaById(mediaId) + tmdb = TMDBApiController().search(media.info.title, media.type) + + if (media.type == MediaType.TVSHOW) { + nextEpisode = if (media.episodes.firstOrNull{ !it.watched } != null) { + media.episodes.first{ !it.watched } + } else { + media.episodes.first() + } + } + } + + /** + * get the next episode based on episode number (the true next episode) + * if no matching is found, use first episode + */ + fun updateNextEpisode(currentEp: Episode) { + if (media.type == MediaType.MOVIE) return // return if movie + + nextEpisode = media.episodes.firstOrNull{ it.number > currentEp.number } + ?: media.episodes.first() + } + +} \ No newline at end of file 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 new file mode 100644 index 0000000..f65ab30 --- /dev/null +++ b/app/src/main/java/org/mosad/teapod/ui/activity/onboarding/OnLoginFragment.kt @@ -0,0 +1,54 @@ +package org.mosad.teapod.ui.activity.onboarding + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import kotlinx.coroutines.* +import org.mosad.teapod.R +import org.mosad.teapod.databinding.FragmentOnLoginBinding +import org.mosad.teapod.parser.AoDParser +import org.mosad.teapod.preferences.EncryptedPreferences + +class OnLoginFragment: Fragment() { + + private lateinit var binding: FragmentOnLoginBinding + private var loginJob: Job? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragmentOnLoginBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initActions() + } + + private fun initActions() { + binding.buttonLogin.setOnClickListener { + // get login credentials from gui + val email = binding.editTextLogin.text.toString() + val password = binding.editTextPassword.text.toString() + + EncryptedPreferences.saveCredentials(email, password, requireContext()) // save the credentials + + binding.buttonLogin.isClickable = false + loginJob = GlobalScope.launch { + if (AoDParser.login()) { + // if login was successful, switch to main + if (activity is OnboardingActivity) { + (activity as OnboardingActivity).launchMainActivity() + } + } else { + withContext(Dispatchers.Main) { + binding.textLoginDesc.text = getString(R.string.on_login_failed) + binding.buttonLogin.isClickable = true + } + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/onboarding/OnWelcomeFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/onboarding/OnWelcomeFragment.kt new file mode 100644 index 0000000..b51e6ed --- /dev/null +++ b/app/src/main/java/org/mosad/teapod/ui/activity/onboarding/OnWelcomeFragment.kt @@ -0,0 +1,31 @@ +package org.mosad.teapod.ui.activity.onboarding + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import org.mosad.teapod.databinding.FragmentOnWelcomeBinding + +class OnWelcomeFragment: Fragment() { + + private lateinit var binding: FragmentOnWelcomeBinding + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragmentOnWelcomeBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initActions() + } + + private fun initActions() { + binding.buttonGetStarted.setOnClickListener { + if (activity is OnboardingActivity) { + (activity as OnboardingActivity).nextFragment() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/onboarding/OnboardingActivity.kt b/app/src/main/java/org/mosad/teapod/ui/activity/onboarding/OnboardingActivity.kt new file mode 100644 index 0000000..8087e98 --- /dev/null +++ b/app/src/main/java/org/mosad/teapod/ui/activity/onboarding/OnboardingActivity.kt @@ -0,0 +1,79 @@ +package org.mosad.teapod.ui.activity.onboarding + +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.google.android.material.tabs.TabLayoutMediator +import org.mosad.teapod.ui.activity.main.MainActivity +import org.mosad.teapod.databinding.ActivityOnboardingBinding + +class OnboardingActivity : AppCompatActivity() { + + private lateinit var binding: ActivityOnboardingBinding + private lateinit var pagerAdapter: FragmentStateAdapter + + private val fragments = arrayOf(OnLoginFragment()) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = ActivityOnboardingBinding.inflate(layoutInflater) + setContentView(binding.root) + + pagerAdapter = ScreenSlidePagerAdapter(this) + binding.viewPager.adapter = pagerAdapter + TabLayoutMediator(binding.tabLayout, binding.viewPager) { _, _ -> }.attach() + + // we don't use the skip button, instead we use the start button to skip the last fragment + binding.buttonSkip.visibility = View.GONE + + // hide tab layout if only one tab is displayed + if (fragments.size <= 1) { + binding.tabLayout.visibility = View.GONE + } + } + + override fun onBackPressed() { + if (binding.viewPager.currentItem == 0) { + super.onBackPressed() + } else { + binding.viewPager.currentItem = binding.viewPager.currentItem - 1 + } + } + + fun nextFragment() { + if (binding.viewPager.currentItem < fragments.size - 1) { + binding.viewPager.currentItem++ + } else { + launchMainActivity() + } + } + + fun btnNextClick(@Suppress("UNUSED_PARAMETER")v: View) { + //nextFragment() // currently not used in Teapod + } + + fun btnSkipClick(@Suppress("UNUSED_PARAMETER")v: View) { + //launchMainActivity() // currently not used in Teapod + } + + fun launchMainActivity() { + startActivity(Intent(this, MainActivity::class.java)) + finish() + } + + /** + * A simple pager adapter + */ + private inner class ScreenSlidePagerAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) { + override fun getItemCount(): Int = fragments.size + + override fun createFragment(position: Int): Fragment = fragments[position] + } + + +} \ No newline at end of file diff --git a/app/src/main/java/org/mosad/teapod/player/PlayerActivity.kt b/app/src/main/java/org/mosad/teapod/ui/activity/player/PlayerActivity.kt similarity index 78% rename from app/src/main/java/org/mosad/teapod/player/PlayerActivity.kt rename to app/src/main/java/org/mosad/teapod/ui/activity/player/PlayerActivity.kt index d5b76e9..9c28001 100644 --- a/app/src/main/java/org/mosad/teapod/player/PlayerActivity.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/player/PlayerActivity.kt @@ -1,13 +1,21 @@ -package org.mosad.teapod.player +package org.mosad.teapod.ui.activity.player import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.annotation.SuppressLint +import android.app.PictureInPictureParams +import android.content.Intent +import android.content.pm.PackageManager +import android.content.res.Configuration import android.os.Build import android.os.Bundle import android.util.Log -import android.view.* +import android.util.Rational +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.View import androidx.activity.viewModels +import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.core.view.GestureDetectorCompat import androidx.core.view.isVisible @@ -26,6 +34,9 @@ import org.mosad.teapod.preferences.Preferences import org.mosad.teapod.ui.components.EpisodesListPlayer import org.mosad.teapod.ui.components.LanguageSettingsPlayer import org.mosad.teapod.util.DataTypes +import org.mosad.teapod.util.hideBars +import org.mosad.teapod.util.isInPiPMode +import org.mosad.teapod.util.navToLauncherTask import java.util.* import java.util.concurrent.TimeUnit import kotlin.concurrent.scheduleAtFixedRate @@ -38,6 +49,7 @@ class PlayerActivity : AppCompatActivity() { private lateinit var gestureDetector: GestureDetectorCompat private lateinit var timerUpdates: TimerTask + private var wasInPiP = false private var playWhenReady = true private var currentWindow = 0 private var playbackPosition: Long = 0 @@ -73,6 +85,11 @@ class PlayerActivity : AppCompatActivity() { initActions() } + /** + * once minimum is android 7.0 this can be simplified + * only onStart and onStop should be needed then + * see: https://developer.android.com/guide/topics/ui/picture-in-picture#continuing_playback + */ override fun onStart() { super.onStart() if (Util.SDK_INT > 23) { @@ -83,6 +100,8 @@ class PlayerActivity : AppCompatActivity() { override fun onResume() { super.onResume() + if (isInPiPMode()) { return } + if (Util.SDK_INT <= 23) { initPlayer() video_view?.onResume() @@ -91,6 +110,8 @@ class PlayerActivity : AppCompatActivity() { override fun onPause() { super.onPause() + if (isInPiPMode()) { return } + if (Util.SDK_INT <= 23) { video_view?.onPause() releasePlayer() @@ -103,6 +124,11 @@ class PlayerActivity : AppCompatActivity() { video_view?.onPause() releasePlayer() } + + // if the player was in pip, it's on a different task + if (wasInPiP) { navToLauncherTask() } + // if the player is in pip, remove the task, else we'll get a zombie + if (isInPiPMode()) { finishAndRemoveTask() } } override fun onSaveInstanceState(outState: Bundle) { @@ -112,6 +138,57 @@ class PlayerActivity : AppCompatActivity() { super.onSaveInstanceState(outState) } + /** + * used, when the player is in pip and the user selects a new media + */ + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + + // when the intent changed, lead the new media and play it + intent?.let { + model.loadMedia( + it.getIntExtra(getString(R.string.intent_media_id), 0), + it.getIntExtra(getString(R.string.intent_episode_id), 0) + ) + model.playEpisode(model.currentEpisode, replace = true) + } + } + + /** + * previous to android n, don't override + */ + @RequiresApi(Build.VERSION_CODES.N) + override fun onUserLeaveHint() { + super.onUserLeaveHint() + + // start pip mode, if supported + if (packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + @Suppress("deprecation") + enterPictureInPictureMode() + } else { + val width = model.player.videoFormat?.width ?: 0 + val height = model.player.videoFormat?.height ?: 0 + val params = PictureInPictureParams.Builder() + .setAspectRatio(Rational(width, height)) + .build() + enterPictureInPictureMode(params) + } + + wasInPiP = isInPiPMode() + } + } + + override fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + newConfig: Configuration? + ) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + + // Hide the full-screen UI (controls, etc.) while in picture-in-picture mode. + video_view.useController = !isInPictureInPictureMode + } + private fun initPlayer() { if (model.media.id < 0) { Log.e(javaClass.name, "No media was set.") @@ -122,8 +199,8 @@ class PlayerActivity : AppCompatActivity() { initTimeUpdates() // if the player is ready or buffering we can simply play the file again, else do nothing - if ((model.player.playbackState == ExoPlayer.STATE_READY || model.player.playbackState == ExoPlayer.STATE_BUFFERING) - ) { + val playbackState = model.player.playbackState + if ((playbackState == ExoPlayer.STATE_READY || playbackState == ExoPlayer.STATE_BUFFERING)) { model.player.play() } } @@ -178,7 +255,9 @@ class PlayerActivity : AppCompatActivity() { } private fun initActions() { - exo_close_player.setOnClickListener { this.finish() } + exo_close_player.setOnClickListener { + this.finish() + } rwd_10.setOnButtonClickListener { rewind() } ffwd_10.setOnButtonClickListener { fastForward() } button_next_ep.setOnClickListener { playNextEpisode() } @@ -213,8 +292,8 @@ class PlayerActivity : AppCompatActivity() { } if (remainingTime in 1..20000) { - // if the next ep button is not visible, make it visible - if (!btnNextEpIsVisible && model.nextEpisode != null && Preferences.autoplay) { + // 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() } } } else if (btnNextEpIsVisible) { @@ -229,7 +308,7 @@ class PlayerActivity : AppCompatActivity() { } } - private fun releasePlayer(){ + private fun releasePlayer() { playbackPosition = model.player.currentPosition currentWindow = model.player.currentWindowIndex playWhenReady = model.player.playWhenReady @@ -260,11 +339,19 @@ class PlayerActivity : AppCompatActivity() { private fun onMediaChanged() { exo_text_title.text = model.getMediaTitle() + // hide the next ep button, if there is none button_next_ep_c.visibility = if (model.nextEpisode == null) { View.GONE } else { View.VISIBLE } + + // hide the episodes button, if the media type changed + button_episodes.visibility = if (model.media.type == DataTypes.MediaType.MOVIE) { + View.GONE + } else { + View.VISIBLE + } } /** @@ -312,27 +399,6 @@ class PlayerActivity : AppCompatActivity() { hideButtonNextEp() } - /** - * hide the status and navigation bar - */ - private fun hideBars() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - window.setDecorFitsSystemWindows(false) - window.insetsController?.apply { - hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars()) - systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_BARS_BY_SWIPE - } - } else { - @Suppress("deprecation") - window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE - or View.SYSTEM_UI_FLAG_LAYOUT_STABLE - or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION - or View.SYSTEM_UI_FLAG_FULLSCREEN) - } - } - /** * show the next episode button * TODO improve the show animation @@ -393,7 +459,10 @@ class PlayerActivity : AppCompatActivity() { * on single tap hide or show the controls */ override fun onSingleTapConfirmed(e: MotionEvent?): Boolean { - if (controller.isVisible) controller.hide() else controller.show() + if (!isInPiPMode()) { + if (controller.isVisible) controller.hide() else controller.show() + } + return true } diff --git a/app/src/main/java/org/mosad/teapod/player/PlayerViewModel.kt b/app/src/main/java/org/mosad/teapod/ui/activity/player/PlayerViewModel.kt similarity index 98% rename from app/src/main/java/org/mosad/teapod/player/PlayerViewModel.kt rename to app/src/main/java/org/mosad/teapod/ui/activity/player/PlayerViewModel.kt index 35117f4..4efb7c4 100644 --- a/app/src/main/java/org/mosad/teapod/player/PlayerViewModel.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/player/PlayerViewModel.kt @@ -1,4 +1,4 @@ -package org.mosad.teapod.player +package org.mosad.teapod.ui.activity.player import android.app.Application import android.net.Uri @@ -15,7 +15,6 @@ import kotlinx.coroutines.runBlocking import org.mosad.teapod.R import org.mosad.teapod.parser.AoDParser import org.mosad.teapod.preferences.Preferences -import org.mosad.teapod.ui.fragments.MediaFragment import org.mosad.teapod.util.DataTypes import org.mosad.teapod.util.Episode import org.mosad.teapod.util.Media diff --git a/app/src/main/java/org/mosad/teapod/ui/components/EpisodesListPlayer.kt b/app/src/main/java/org/mosad/teapod/ui/components/EpisodesListPlayer.kt index 9f41056..cb51deb 100644 --- a/app/src/main/java/org/mosad/teapod/ui/components/EpisodesListPlayer.kt +++ b/app/src/main/java/org/mosad/teapod/ui/components/EpisodesListPlayer.kt @@ -6,7 +6,7 @@ import android.view.LayoutInflater import android.view.ViewGroup import android.widget.LinearLayout import org.mosad.teapod.databinding.PlayerEpisodesListBinding -import org.mosad.teapod.player.PlayerViewModel +import org.mosad.teapod.ui.activity.player.PlayerViewModel import org.mosad.teapod.util.adapter.PlayerEpisodeItemAdapter class EpisodesListPlayer @JvmOverloads constructor( diff --git a/app/src/main/java/org/mosad/teapod/ui/components/LanguageSettingsPlayer.kt b/app/src/main/java/org/mosad/teapod/ui/components/LanguageSettingsPlayer.kt index ff4510a..404ba7e 100644 --- a/app/src/main/java/org/mosad/teapod/ui/components/LanguageSettingsPlayer.kt +++ b/app/src/main/java/org/mosad/teapod/ui/components/LanguageSettingsPlayer.kt @@ -13,7 +13,7 @@ import android.widget.TextView import androidx.core.view.children import org.mosad.teapod.R import org.mosad.teapod.databinding.PlayerLanguageSettingsBinding -import org.mosad.teapod.player.PlayerViewModel +import org.mosad.teapod.ui.activity.player.PlayerViewModel import java.util.* class LanguageSettingsPlayer @JvmOverloads constructor( diff --git a/app/src/main/java/org/mosad/teapod/ui/fragments/MediaFragment.kt b/app/src/main/java/org/mosad/teapod/ui/fragments/MediaFragment.kt deleted file mode 100644 index aeb6fa4..0000000 --- a/app/src/main/java/org/mosad/teapod/ui/fragments/MediaFragment.kt +++ /dev/null @@ -1,177 +0,0 @@ -package org.mosad.teapod.ui.fragments - -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide -import com.bumptech.glide.request.RequestOptions -import jp.wasabeef.glide.transformations.BlurTransformation -import kotlinx.coroutines.* -import org.mosad.teapod.MainActivity -import org.mosad.teapod.R -import org.mosad.teapod.databinding.FragmentMediaBinding -import org.mosad.teapod.parser.AoDParser -import org.mosad.teapod.util.* -import org.mosad.teapod.util.DataTypes.MediaType -import org.mosad.teapod.util.adapter.EpisodeItemAdapter - -class MediaFragment(private val mediaId: Int) : Fragment() { - - private lateinit var binding: FragmentMediaBinding - private lateinit var adapterRecEpisodes: EpisodeItemAdapter - private lateinit var viewManager: RecyclerView.LayoutManager - - private lateinit var media: Media - private lateinit var tmdb: TMDBResponse - private lateinit var nextEpisode: Episode - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - binding = FragmentMediaBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.frameLoading.visibility = View.VISIBLE - - GlobalScope.launch(Dispatchers.Main) { - // load the streams for the selected media - media = AoDParser.getMediaById(mediaId) - tmdb = TMDBApiController().search(media.info.title, media.type) - - if (this@MediaFragment.isAdded) { - updateGUI() - initActions() - } - } - } - - override fun onResume() { - super.onResume() - - // only notify adapter, if initialized - if (this::adapterRecEpisodes.isInitialized) { - // TODO find a better solution for this - media.episodes.forEachIndexed() { index, episode -> - adapterRecEpisodes.updateWatchedState(episode.watched, index) - } - adapterRecEpisodes.notifyDataSetChanged() - } - } - - /** - * if tmdb data is present, use it, else use the aod data - */ - private fun updateGUI() = with(binding) { - // generic gui - val backdropUrl = if (tmdb.backdropUrl.isNotEmpty()) tmdb.backdropUrl else media.info.posterUrl - val posterUrl = if (tmdb.posterUrl.isNotEmpty()) tmdb.posterUrl else media.info.posterUrl - - Glide.with(requireContext()).load(backdropUrl) - .apply(RequestOptions.placeholderOf(ColorDrawable(Color.DKGRAY))) - .apply(RequestOptions.bitmapTransform(BlurTransformation(20, 3))) - .into(imageBackdrop) - - Glide.with(requireContext()).load(posterUrl) - .into(imagePoster) - - textTitle.text = media.info.title - textYear.text = media.info.year.toString() - textAge.text = media.info.age.toString() - textOverview.text = media.info.shortDesc - if (StorageController.myList.contains(media.id)) { - Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(imageMyListAction) - } else { - Glide.with(requireContext()).load(R.drawable.ic_baseline_add_24).into(imageMyListAction) - } - - // specific gui - if (media.type == MediaType.TVSHOW) { - adapterRecEpisodes = EpisodeItemAdapter(media.episodes) - viewManager = LinearLayoutManager(context) - recyclerEpisodes.layoutManager = viewManager - recyclerEpisodes.adapter = adapterRecEpisodes - - binding.textEpisodesOrRuntime.text = getString(R.string.text_episodes_count, media.info.episodesCount) - - // get next episode - nextEpisode = if (media.episodes.firstOrNull{ !it.watched } != null) { - media.episodes.first{ !it.watched } - } else { - media.episodes.first() - } - - // title is the next episodes title - textTitle.text = nextEpisode.title - } else if (media.type == MediaType.MOVIE) { - recyclerEpisodes.visibility = View.GONE - - if (tmdb.runtime > 0) { - textEpisodesOrRuntime.text = getString(R.string.text_runtime, tmdb.runtime) - } else { - textEpisodesOrRuntime.visibility = View.GONE - } - } - - frameLoading.visibility = View.GONE // hide loading indicator - } - - private fun initActions() { - binding.buttonPlay.setOnClickListener { - when (media.type) { - MediaType.MOVIE -> playStream(media.episodes.first()) - MediaType.TVSHOW -> playEpisode(nextEpisode) - else -> Log.e(javaClass.name, "Wrong Type: $media.type") - } - } - - // add or remove media from myList - binding.linearMyListAction.setOnClickListener { - if (StorageController.myList.contains(media.id)) { - StorageController.myList.remove(media.id) - Glide.with(requireContext()).load(R.drawable.ic_baseline_add_24).into(binding.imageMyListAction) - } else { - StorageController.myList.add(media.id) - Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(binding.imageMyListAction) - } - StorageController.saveMyList(requireContext()) - - // notify home fragment on change - parentFragmentManager.findFragmentByTag("HomeFragment")?.let { - (it as HomeFragment).updateMyListMedia() - } - } - - // set onItemClick only in adapter is initialized - if (this::adapterRecEpisodes.isInitialized) { - adapterRecEpisodes.onImageClick = { _, position -> - playEpisode(media.episodes[position]) - } - } - } - - private fun playEpisode(ep: Episode) { - playStream(ep) - - // update nextEpisode - nextEpisode = if (media.episodes.firstOrNull{ !it.watched } != null) { - media.episodes.first{ !it.watched } - } else { - media.episodes.first() - } - binding.textTitle.text = nextEpisode.title - } - - private fun playStream(ep: Episode) { - Log.d(javaClass.name, "Starting Player with mediaId: ${media.id}") - (activity as MainActivity).startPlayer(media.id, ep.id) - } - -} \ No newline at end of file diff --git a/app/src/main/java/org/mosad/teapod/util/ActivityUtils.kt b/app/src/main/java/org/mosad/teapod/util/ActivityUtils.kt new file mode 100644 index 0000000..283d2e7 --- /dev/null +++ b/app/src/main/java/org/mosad/teapod/util/ActivityUtils.kt @@ -0,0 +1,82 @@ +package org.mosad.teapod.util + +import android.app.Activity +import android.app.ActivityManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.view.View +import android.view.WindowInsets +import android.view.WindowInsetsController +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.commit +import org.mosad.teapod.R +import kotlin.system.exitProcess + +/** + * Show a fragment on top of the current fragment. + * The current fragment is replaced and the new one is added + * to the back stack. + */ +fun FragmentActivity.showFragment(fragment: Fragment) { + supportFragmentManager.commit { + replace(R.id.nav_host_fragment, fragment, fragment.javaClass.simpleName) + addToBackStack(fragment.javaClass.name) + show(fragment) + } +} + +/** + * hide the status and navigation bar + */ +fun Activity.hideBars() { + window.apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + setDecorFitsSystemWindows(false) + insetsController?.apply { + hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars()) + systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_BARS_BY_SWIPE + } + } else { + @Suppress("deprecation") + decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE + or View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_FULLSCREEN) + } + } +} + +fun Activity.isInPiPMode(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + isInPictureInPictureMode + } else { + false // pip mode not supported + } +} + +/** + * Bring up launcher task to front + */ +fun Activity.navToLauncherTask() { + val activityManager = (getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager) + activityManager.appTasks.forEach { task -> + val baseIntent = task.taskInfo.baseIntent + val categories = baseIntent.categories + if (categories != null && categories.contains(Intent.CATEGORY_LAUNCHER)) { + task.moveToFront() + return + } + } +} + +/** + * exit and remove the app from tasks + */ +fun Activity.exitAndRemoveTask() { + finishAndRemoveTask() + exitProcess(0) +} diff --git a/app/src/main/java/org/mosad/teapod/util/DataTypes.kt b/app/src/main/java/org/mosad/teapod/util/DataTypes.kt index bc27719..cf936bd 100644 --- a/app/src/main/java/org/mosad/teapod/util/DataTypes.kt +++ b/app/src/main/java/org/mosad/teapod/util/DataTypes.kt @@ -55,6 +55,7 @@ data class Media( fun getEpisodeById(id: Int) = episodes.first { it.id == id } } +// TODO all val? data class Info( var title: String = "", var posterUrl: String = "", @@ -62,7 +63,8 @@ data class Info( var description: String = "", var year: Int = 0, var age: Int = 0, - var episodesCount: Int = 0 + var episodesCount: Int = 0, + var similar: List = listOf() ) /** @@ -96,6 +98,7 @@ data class Stream( /** * this class is used for tmdb responses + * TODO why is runtime var? */ data class TMDBResponse( val id: Int = 0, diff --git a/app/src/main/java/org/mosad/teapod/util/Utils.kt b/app/src/main/java/org/mosad/teapod/util/Utils.kt new file mode 100644 index 0000000..1b9e7f8 --- /dev/null +++ b/app/src/main/java/org/mosad/teapod/util/Utils.kt @@ -0,0 +1,7 @@ +package org.mosad.teapod.util + +import android.widget.TextView + +fun TextView.setDrawableTop(drawable: Int) { + this.setCompoundDrawablesWithIntrinsicBounds(0, drawable, 0, 0) +} diff --git a/app/src/main/java/org/mosad/teapod/util/adapter/EpisodeItemAdapter.kt b/app/src/main/java/org/mosad/teapod/util/adapter/EpisodeItemAdapter.kt index 9438dc8..6eb467c 100644 --- a/app/src/main/java/org/mosad/teapod/util/adapter/EpisodeItemAdapter.kt +++ b/app/src/main/java/org/mosad/teapod/util/adapter/EpisodeItemAdapter.kt @@ -1,5 +1,7 @@ package org.mosad.teapod.util.adapter +import android.graphics.Color +import android.graphics.drawable.ColorDrawable import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.content.ContextCompat @@ -34,6 +36,7 @@ class EpisodeItemAdapter(private val episodes: List) : RecyclerView.Ada if (episodes[position].posterUrl.isNotEmpty()) { Glide.with(context).load(ep.posterUrl) + .apply(RequestOptions.placeholderOf(ColorDrawable(Color.DKGRAY))) .apply(RequestOptions.bitmapTransform(RoundedCornersTransformation(10, 0))) .into(holder.binding.imageEpisode) } @@ -52,7 +55,8 @@ class EpisodeItemAdapter(private val episodes: List) : RecyclerView.Ada } fun updateWatchedState(watched: Boolean, position: Int) { - episodes[position].watched = watched + // use getOrNull as there could be a index out of bound when running this in onResume() + episodes.getOrNull(position)?.watched = watched } inner class EpisodeViewHolder(val binding: ItemEpisodeBinding) : RecyclerView.ViewHolder(binding.root) { diff --git a/app/src/main/res/drawable/dot_default.xml b/app/src/main/res/drawable/dot_default.xml new file mode 100644 index 0000000..cb7aef7 --- /dev/null +++ b/app/src/main/res/drawable/dot_default.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dot_selected.xml b/app/src/main/res/drawable/dot_selected.xml new file mode 100644 index 0000000..078fd8f --- /dev/null +++ b/app/src/main/res/drawable/dot_selected.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dot_tab_selector.xml b/app/src/main/res/drawable/dot_tab_selector.xml new file mode 100644 index 0000000..b32d447 --- /dev/null +++ b/app/src/main/res/drawable/dot_tab_selector.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_code_24.xml b/app/src/main/res/drawable/ic_baseline_code_24.xml new file mode 100644 index 0000000..2f812a4 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_code_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_description_24.xml b/app/src/main/res/drawable/ic_baseline_description_24.xml new file mode 100644 index 0000000..e7ef3d4 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_description_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_info_24.xml b/app/src/main/res/drawable/ic_baseline_info_24.xml deleted file mode 100644 index 17255b7..0000000 --- a/app/src/main/res/drawable/ic_baseline_info_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_people_24.xml b/app/src/main/res/drawable/ic_baseline_people_24.xml new file mode 100644 index 0000000..6fc325a --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_people_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_info_24.xml b/app/src/main/res/drawable/ic_outline_info_24.xml new file mode 100644 index 0000000..24bd840 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_info_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/activity_onboarding.xml b/app/src/main/res/layout/activity_onboarding.xml new file mode 100644 index 0000000..1bdea86 --- /dev/null +++ b/app/src/main/res/layout/activity_onboarding.xml @@ -0,0 +1,50 @@ + + + + + + + + +