18 Commits

Author SHA1 Message Date
aeb74dcb29 rework MediaItemAdapter to use ItemMedia instead of Media
This allows us to get the media onClick directly from the AoDParser. Media inforamtion are now only stored in the parsers mediaList.
2020-10-16 19:56:08 +02:00
2689c37af3 „README.md“ ändern 2020-10-16 18:31:32 +02:00
5458b43354 fix #9 & replace my list checkbox with layout for easier gui building 2020-10-16 18:24:34 +02:00
d912ed34a3 add a circular transparent background to the episode play icon 2020-10-16 14:08:17 +02:00
9f1717e646 update my list on home screen, when changed 2020-10-16 11:23:32 +02:00
085b2013ab play episode on poster click
closes #7
2020-10-16 10:05:11 +02:00
474b72df49 add favorite list to home screen 2020-10-15 21:00:31 +02:00
a8dc243d0e move all fragments into the fragments package 2020-10-15 19:01:37 +02:00
fa6419bb02 if a media was already fully loaded, don't load it again for
Since medias are cached in memory it is unnecessary to load them if they have been fully loaded once before
2020-10-15 18:57:58 +02:00
6100533c4d fix movie parsing
regression in 5b7d2cd26e
2020-10-15 18:51:29 +02:00
4ae23c4380 fix crash in episode count extraction 2020-10-15 16:23:52 +02:00
adf8a48251 replace GridView in library and search fragment with RecyclerView
closes #8
2020-10-15 13:00:44 +02:00
36c8678646 fix cancel text for german translation 2020-10-14 20:58:42 +02:00
442a02db70 update used libraries 2020-10-14 20:26:29 +02:00
5f80f1fabd show loading screen while loading media fragment
* use material components for shaped images and progress indicator
2020-10-14 20:22:20 +02:00
d2728405d1 redesign library and search fragment
* library/search now use a grid view with 2 columns
* media is now represented as card
* media details: poster and episodes have now rounded corners
2020-10-14 18:33:11 +02:00
87f9235b8a add why is it called teapod to readme 2020-10-14 01:24:51 +02:00
03cd42773d add Episode watched callback 2020-10-13 23:47:48 +02:00
36 changed files with 802 additions and 359 deletions

View File

@ -6,22 +6,35 @@ A unoffical App for Anime-on-Demand.
* acces all media in the library
* search the library
* play movies/tv shows via integrated exoplayer
* add movies/tv shows to you list, for easier access
### Missing Features
* a alternative/secondary stream is currently not supported (for dub titles the subtitle version is missing)
## Screenshots
[<img src="https://www.mosad.xyz/images/Teapod/Teapod_Library.png" width=180>](https://www.mosad.xyz/images/Teapod/Teapod_Library.png)
[<img src="https://www.mosad.xyz/images/Teapod/Teapod_Media.png" width=180>](https://www.mosad.xyz/images/Teapod/Teapod_Media.png)
[<img src="https://www.mosad.xyz/images/Teapod/Teapod_Search.png" width=180>](https://www.mosad.xyz/images/Teapod/Teapod_Search.png)
## License
### License
This App is licensed under the terms and conditions of GPL 3. This Project is not associated with Anime-on-Demand in any way.
### Known Issues
If a tv show is selected, the first episode will be marked as already watched. This is due to the difficulties of parsing. The Parser is designed to be as easy to maintain and fail safe as possible.
### Used Libraries
* gson: https://github.com/google/gson
* exoplayer: https://github.com/google/ExoPlayer
* jsoup: https://jsoup.org/
* material-dialogs: https://github.com/afollestad/material-dialogs
* kotlin.coroutines: https://github.com/Kotlin/kotlinx.coroutines
* AndroidX: https://developer.android.com/jetpack/androidx
* Material Components for Android: https://github.com/material-components/material-components-android
* ExoPlayer: https://github.com/google/ExoPlayer
* Gson: https://github.com/google/gson
* Material design icons: https://github.com/google/material-design-icons
* androidx libraries
* Material Dialogs: https://github.com/afollestad/material-dialogs
* Jsoup: https://jsoup.org
* kotlinx.coroutines: https://github.com/Kotlin/kotlinx.coroutines
* Glide: https://github.com/bumptech/glide
* Glide Transformations: https://github.com/wasabeef/glide-transformations
#### 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.
Teapod © 2020 [@Seil0](https://git.mosad.xyz/Seil0)

View File

@ -11,7 +11,7 @@ android {
minSdkVersion 23
targetSdkVersion 30
versionCode 1
versionName "0.1-alpha2"
versionName "0.1-alpha3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resValue "string", "build_time", buildTime()
@ -46,7 +46,7 @@ dependencies {
implementation 'androidx.security:security-crypto:1.1.0-alpha02'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'com.google.android.material:material:1.2.1'
implementation 'com.google.android.material:material:1.3.0-alpha03'
implementation 'com.google.code.gson:gson:2.8.6'
implementation 'com.google.android.exoplayer:exoplayer-core:2.12.0'
implementation 'com.google.android.exoplayer:exoplayer-hls:2.12.0'

View File

@ -26,42 +26,47 @@ import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.MenuItem
import com.google.android.material.bottomnavigation.BottomNavigationView
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import com.google.android.material.bottomnavigation.BottomNavigationView
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.mosad.teapod.parser.AoDParser
import org.mosad.teapod.preferences.EncryptedPreferences
import org.mosad.teapod.ui.MediaFragment
import org.mosad.teapod.ui.account.AccountFragment
import org.mosad.teapod.ui.fragments.MediaFragment
import org.mosad.teapod.ui.fragments.AccountFragment
import org.mosad.teapod.ui.components.LoginDialog
import org.mosad.teapod.ui.home.HomeFragment
import org.mosad.teapod.ui.library.LibraryFragment
import org.mosad.teapod.ui.search.SearchFragment
import org.mosad.teapod.util.Media
import org.mosad.teapod.ui.fragments.HomeFragment
import org.mosad.teapod.ui.fragments.LibraryFragment
import org.mosad.teapod.ui.fragments.SearchFragment
import org.mosad.teapod.ui.fragments.LoadingFragment
import org.mosad.teapod.util.StorageController
import org.mosad.teapod.util.TMDBApiController
import kotlin.system.measureTimeMillis
class MainActivity : AppCompatActivity(), BottomNavigationView.OnNavigationItemSelectedListener {
private var activeFragment: Fragment = HomeFragment() // the currently active fragment, home at the start
private var activeBaseFragment: Fragment = HomeFragment() // the currently active fragment, home at the start
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val navView: BottomNavigationView = findViewById(R.id.nav_view)
navView.setOnNavigationItemSelectedListener(this)
nav_view.setOnNavigationItemSelectedListener(this)
load()
supportFragmentManager.commit {
replace(R.id.nav_host_fragment, activeBaseFragment, activeBaseFragment.javaClass.simpleName)
}
}
override fun onBackPressed() {
if (supportFragmentManager.backStackEntryCount > 0) {
supportFragmentManager.popBackStack()
} else {
if (activeFragment !is HomeFragment) {
if (activeBaseFragment !is HomeFragment) {
nav_view.selectedItemId = R.id.navigation_home
} else {
super.onBackPressed()
@ -70,51 +75,75 @@ class MainActivity : AppCompatActivity(), BottomNavigationView.OnNavigationItemS
}
override fun onNavigationItemSelected(item: MenuItem): Boolean {
if (supportFragmentManager.backStackEntryCount > 0) {
supportFragmentManager.popBackStack()
}
val ret = when (item.itemId) {
R.id.navigation_home -> {
activeFragment = HomeFragment()
activeBaseFragment = HomeFragment()
true
}
R.id.navigation_library -> {
activeFragment = LibraryFragment()
activeBaseFragment = LibraryFragment()
true
}
R.id.navigation_search -> {
activeFragment = SearchFragment()
activeBaseFragment = SearchFragment()
true
}
R.id.navigation_account -> {
activeFragment = AccountFragment()
activeBaseFragment = AccountFragment()
true
}
else -> false
}
supportFragmentManager.commit {
replace(R.id.nav_host_fragment, activeFragment)
replace(R.id.nav_host_fragment, activeBaseFragment, activeBaseFragment.javaClass.simpleName)
}
return ret
}
private fun load() {
EncryptedPreferences.readCredentials(this)
// running login and list in parallel does not bring any speed improvements
val time = measureTimeMillis {
// make sure credentials are set
EncryptedPreferences.readCredentials(this)
if (EncryptedPreferences.password.isEmpty()) {
showLoginDialog(true)
} else {
// try to login in, as most sites can only bee loaded once loged in
if (!AoDParser().login()) showLoginDialog(false)
}
// make sure credentials are set and valid
if (EncryptedPreferences.password.isEmpty()) {
showLoginDialog(true)
} else if (!AoDParser().login()) {
showLoginDialog(false)
StorageController.load(this)
// initially load all media
AoDParser().listAnimes()
// TODO load home screen, can be parallel to listAnimes
}
Log.i(javaClass.name, "login and list in $time ms")
}
/**
* TODO show loading fragment
* Show the media fragment for the selected media.
* While loading show the loading fragment.
* The loading and media fragment are not stored in activeBaseFragment,
* as the don't replace a fragment but are added on top of one.
*/
fun showDetailFragment(media: Media) = GlobalScope.launch {
media.episodes = AoDParser().loadStreams(media) // load the streams for the selected media
fun showMediaFragment(mediaId: Int) = GlobalScope.launch {
val loadingFragment = LoadingFragment()
supportFragmentManager.commit {
add(R.id.nav_host_fragment, loadingFragment, "MediaFragment")
show(loadingFragment)
}
val tmdb = TMDBApiController().search(media.title, media.type)
// load the streams for the selected media
val media = AoDParser().getMediaById(mediaId)
val tmdb = TMDBApiController().search(media.info.title, media.type)
val mediaFragment = MediaFragment(media, tmdb)
supportFragmentManager.commit {
@ -122,6 +151,10 @@ class MainActivity : AppCompatActivity(), BottomNavigationView.OnNavigationItemS
addToBackStack(null)
show(mediaFragment)
}
supportFragmentManager.commit {
remove(loadingFragment)
}
}
fun startPlayer(streamUrl: String) {

View File

@ -8,27 +8,33 @@ import org.jsoup.Jsoup
import org.mosad.teapod.preferences.EncryptedPreferences
import org.mosad.teapod.util.DataTypes.MediaType
import org.mosad.teapod.util.Episode
import org.mosad.teapod.util.ItemMedia
import org.mosad.teapod.util.Media
import java.io.IOException
import java.util.*
import kotlin.collections.ArrayList
/**
* maybe AoDParser as object would be useful
*/
class AoDParser {
private val baseUrl = "https://www.anime-on-demand.de"
private val loginPath = "/users/sign_in"
private val libraryPath = "/animes"
private val userAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:80.0) Gecko/20100101 Firefox/80.0"
companion object {
private var csrfToken: String = ""
private var sessionCookies = mutableMapOf<String, String>()
private var loginSuccess = false
val mediaList = arrayListOf<Media>()
val itemMediaList = arrayListOf<ItemMedia>()
}
fun login(): Boolean = runBlocking {
val userAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:80.0) Gecko/20100101 Firefox/80.0"
withContext(Dispatchers.Default) {
// get the authenticity token
val resAuth = Jsoup.connect(baseUrl + loginPath)
@ -38,8 +44,8 @@ class AoDParser {
val authenticityToken = resAuth.parse().select("meta[name=csrf-token]").attr("content")
val authCookies = resAuth.cookies()
Log.i(javaClass.name, "Received authenticity token: $authenticityToken")
Log.i(javaClass.name, "Received authenticity cookies: $authCookies")
//Log.d(javaClass.name, "Received authenticity token: $authenticityToken")
//Log.d(javaClass.name, "Received authenticity cookies: $authCookies")
val data = mapOf(
Pair("user[login]", EncryptedPreferences.login),
@ -57,9 +63,9 @@ class AoDParser {
.execute()
//println(resLogin.body())
sessionCookies = resLogin.cookies()
loginSuccess = resLogin.body().contains("Hallo, du bist jetzt angemeldet.")
Log.i(javaClass.name, "Status: ${resLogin.statusCode()} (${resLogin.statusMessage()}), login successful: $loginSuccess")
loginSuccess
@ -86,16 +92,18 @@ class AoDParser {
} 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()
val media = Media(
it.select("h3.animebox-title").text(),
it.select("p.animebox-link").select("a").attr("href"),
type
)
media.info.posterLink = it.select("p.animebox-image").select("img").attr("src")
media.info.shortDesc = it.select("p.animebox-shorttext").text()
mediaList.add(media)
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}")
@ -104,15 +112,26 @@ class AoDParser {
}
}
fun getMediaById(mediaId: Int): Media {
val media = mediaList.first { it.id == mediaId }
if (media.episodes.isEmpty()) {
loadStreams(media)
}
return media
}
/**
* load streams for the media path
* load streams for the media path, movies have one episode
* @param media is used as call ba reference
*/
fun loadStreams(media: Media): List<Episode> = runBlocking {
private fun loadStreams(media: Media) = runBlocking {
if (sessionCookies.isEmpty()) login()
if (!loginSuccess) {
Log.w(javaClass.name, "Login, was not successful.")
return@runBlocking listOf()
return@runBlocking
}
withContext(Dispatchers.Default) {
@ -124,50 +143,59 @@ class AoDParser {
//println(res)
// parse additional info from the media page
res.select("table.vertical-table").select("tr").forEach {
when (it.select("th").text().toLowerCase(Locale.ROOT)) {
"produktionsjahr" -> media.info.year = it.select("td").text().toInt()
"fsk" -> media.info.age = it.select("td").text().toInt()
"episodenanzahl" -> media.info.episodesCount = it.select("td").text().toInt()
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()
}
}
}
/**
* TODO tv show specific for each episode (div.episodebox)
* * watchedCallback
*/
val episodes = if (media.type == MediaType.TVSHOW) {
res.select("div.three-box-container > div.episodebox").map { episodebox ->
val episodeId = episodebox.select("div.flip-front").attr("id").substringAfter("-").toInt()
val episodeWatched = episodebox.select("div.episodebox-icons > div").hasClass("status-icon-orange")
val episodeShortDesc = episodebox.select("p.episodebox-shorttext").text()
// parse additional information for tv shows
media.episodes = when (media.type) {
MediaType.MOVIE -> listOf(Episode())
MediaType.TVSHOW -> {
res.select("div.three-box-container > div.episodebox").map { episodebox ->
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()
Episode(id = episodeId, watched = episodeWatched, shortDesc = episodeShortDesc)
Episode(
id = episodeId,
shortDesc = episodeShortDesc,
watched = episodeWatched,
watchedCallback = episodeWatchedCallback
)
}
}
} else {
listOf(Episode())
MediaType.OTHER -> listOf()
}
// has attr data-lag (ger or jap)
if (csrfToken.isEmpty()) {
csrfToken = res.select("meta[name=csrf-token]").attr("content")
//Log.i(javaClass.name, "New csrf token is $csrfToken")
}
// TODO has attr data-lag (ger or jap)
val playlists = res.select("input.streamstarter_html5").eachAttr("data-playlist")
val csrfToken = res.select("meta[name=csrf-token]").attr("content")
//println("first entry: ${playlists.first()}")
//println("csrf token is: $csrfToken")
return@withContext if (playlists.size > 0) {
loadStreamInfo(playlists.first(), csrfToken, media.type, episodes)
} else {
listOf()
if (playlists.size > 0) {
loadPlaylist(playlists.first(), csrfToken, media.type, media.episodes)
}
}
}
/**
* load the playlist path and parse it, read the stream info from json
* @param episodes is used as call ba reference, additionally it is passed a return value
* @param episodes is used as call ba reference
*/
private fun loadStreamInfo(playlistPath: String, csrfToken: String, type: MediaType, episodes: List<Episode>): List<Episode> = runBlocking {
private fun loadPlaylist(playlistPath: String, csrfToken: String, type: MediaType, episodes: List<Episode>) = runBlocking {
withContext(Dispatchers.Default) {
val headers = mutableMapOf(
Pair("Accept", "application/json, text/javascript, */*; q=0.01"),
@ -177,6 +205,8 @@ class AoDParser {
Pair("X-Requested-With", "XMLHttpRequest"),
)
//println("loading streaminfo with cstf: $csrfToken")
val res = Jsoup.connect(baseUrl + playlistPath)
.ignoreContentType(true)
.cookies(sessionCookies)
@ -189,9 +219,10 @@ class AoDParser {
MediaType.MOVIE -> {
val movie = JsonParser.parseString(res.body()).asJsonObject
.get("playlist").asJsonArray
.first().asJsonObject
movie.first().asJsonObject.get("sources").asJsonArray.toList().forEach {
episodes.first().streamUrl = it.asJsonObject.get("file").asString
movie.get("sources").asJsonArray.first().apply {
episodes.first().streamUrl = this.asJsonObject.get("file").asString
}
}
@ -199,7 +230,6 @@ class AoDParser {
val episodesJson = JsonParser.parseString(res.body()).asJsonObject
.get("playlist").asJsonArray
episodesJson.forEach { jsonElement ->
val episodeId = jsonElement.asJsonObject.get("mediaid")
val episodeStream = jsonElement.asJsonObject.get("sources").asJsonArray
@ -212,7 +242,7 @@ class AoDParser {
episodes.first { it.id == episodeId.asInt }.apply {
this.title = episodeTitle
this.posterLink = episodePoster
this.posterUrl = episodePoster
this.streamUrl = episodeStream
this.description = episodeDescription
this.number = episodeNumber
@ -225,9 +255,30 @@ class AoDParser {
Log.e(javaClass.name, "Wrong Type, please report this issue.")
}
}
return@withContext episodes
}
}
fun sendCallback(callbackPath: String) = GlobalScope.launch {
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 {
withContext(Dispatchers.IO) {
Jsoup.connect(baseUrl + callbackPath)
.ignoreContentType(true)
.cookies(sessionCookies)
.headers(headers)
.execute()
}
} catch (ex: IOException) {
Log.e(javaClass.name, "Callback for $callbackPath failed.", ex)
}
}
}

View File

@ -1,4 +1,4 @@
package org.mosad.teapod.ui.account
package org.mosad.teapod.ui.fragments
import android.os.Bundle
import android.util.Log
@ -6,7 +6,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.afollestad.materialdialogs.MaterialDialog
import de.psdev.licensesdialog.LicensesDialog
import kotlinx.android.synthetic.main.fragment_account.*
import org.mosad.teapod.BuildConfig
@ -36,9 +36,9 @@ class AccountFragment : Fragment() {
}
linear_about.setOnClickListener {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.info_about)
.setMessage(R.string.info_about_dialog)
MaterialDialog(requireContext())
.title(R.string.info_about)
.message(R.string.info_about_dialog)
.show()
}

View File

@ -0,0 +1,66 @@
package org.mosad.teapod.ui.fragments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.android.synthetic.main.fragment_home.*
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.R
import org.mosad.teapod.parser.AoDParser
import org.mosad.teapod.util.StorageController
import org.mosad.teapod.util.adapter.MediaItemAdapter
import org.mosad.teapod.util.decoration.MediaItemDecoration
class HomeFragment : Fragment() {
private lateinit var adapter: MediaItemAdapter
private lateinit var layoutManager: LinearLayoutManager
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_home, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
GlobalScope.launch {
if (AoDParser.mediaList.isEmpty()) {
AoDParser().listAnimes()
}
withContext(Dispatchers.Main) {
context?.let {
layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
recycler_my_list.layoutManager = layoutManager
recycler_my_list.addItemDecoration(MediaItemDecoration(9))
updateMyListMedia()
}
}
}
}
// TODO recreating the adapter on list change is not a good solution
fun updateMyListMedia() {
val myListMedia = StorageController.myList.map { elementId ->
AoDParser.itemMediaList.first {
elementId == it.id
}
}
adapter = MediaItemAdapter(myListMedia)
adapter.onItemClick = { mediaId, _ ->
(activity as MainActivity).showMediaFragment(mediaId)
}
recycler_my_list.adapter = adapter
}
}

View File

@ -1,4 +1,4 @@
package org.mosad.teapod.ui.library
package org.mosad.teapod.ui.fragments
import android.os.Bundle
import android.view.LayoutInflater
@ -10,12 +10,12 @@ import kotlinx.coroutines.*
import org.mosad.teapod.MainActivity
import org.mosad.teapod.R
import org.mosad.teapod.parser.AoDParser
import org.mosad.teapod.util.CustomAdapter
import org.mosad.teapod.util.Media
import org.mosad.teapod.util.decoration.MediaItemDecoration
import org.mosad.teapod.util.adapter.MediaItemAdapter
class LibraryFragment : Fragment() {
private lateinit var adapter : CustomAdapter
private lateinit var adapter: MediaItemAdapter
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_library, container, false)
@ -24,6 +24,7 @@ class LibraryFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// init async
GlobalScope.launch {
if (AoDParser.mediaList.isEmpty()) {
AoDParser().listAnimes()
@ -32,23 +33,16 @@ class LibraryFragment : Fragment() {
// create and set the adapter, needs context
withContext(Dispatchers.Main) {
context?.let {
adapter = CustomAdapter(it, AoDParser.mediaList)
list_library.adapter = adapter
adapter = MediaItemAdapter(AoDParser.itemMediaList)
adapter.onItemClick = { mediaId, _ ->
(activity as MainActivity).showMediaFragment(mediaId)
}
recycler_media_library.adapter = adapter
recycler_media_library.addItemDecoration(MediaItemDecoration(9))
}
}
}
initActions()
}
private fun initActions() {
list_library.setOnItemClickListener { _, _, position, _ ->
val media = adapter.getItem(position) as Media
println("selected item is: ${media.title}")
val mainActivity = activity as MainActivity
mainActivity.showDetailFragment(media)
}
}
}

View File

@ -0,0 +1,16 @@
package org.mosad.teapod.ui.fragments
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.R
class LoadingFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_loading, container, false)
}
}

View File

@ -1,13 +1,13 @@
package org.mosad.teapod.ui
package org.mosad.teapod.ui.fragments
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
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
@ -16,14 +16,16 @@ import jp.wasabeef.glide.transformations.BlurTransformation
import kotlinx.android.synthetic.main.fragment_media.*
import org.mosad.teapod.MainActivity
import org.mosad.teapod.R
import org.mosad.teapod.parser.AoDParser
import org.mosad.teapod.util.DataTypes.MediaType
import org.mosad.teapod.util.EpisodesAdapter
import org.mosad.teapod.util.Media
import org.mosad.teapod.util.StorageController
import org.mosad.teapod.util.TMDBResponse
import org.mosad.teapod.util.adapter.EpisodeItemAdapter
class MediaFragment(private val media: Media, private val tmdb: TMDBResponse) : Fragment() {
private lateinit var adapterRecEpisodes: EpisodesAdapter
private lateinit var adapterRecEpisodes: EpisodeItemAdapter
private lateinit var viewManager: RecyclerView.LayoutManager
@ -43,25 +45,30 @@ class MediaFragment(private val media: Media, private val tmdb: TMDBResponse) :
*/
private fun initGUI() {
// generic gui
val backdropUrl = if (tmdb.backdropUrl.isNotEmpty()) tmdb.backdropUrl else media.info.posterLink
val posterUrl = if (tmdb.posterUrl.isNotEmpty()) tmdb.posterUrl else media.info.posterLink
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(25, 3)))
.apply(RequestOptions.bitmapTransform(BlurTransformation(20, 3)))
.into(image_backdrop)
Glide.with(requireContext()).load(posterUrl)
.into(image_poster)
text_title.text = media.title
text_title.text = media.info.title
text_year.text = media.info.year.toString()
text_age.text = media.info.age.toString()
text_overview.text = media.info.shortDesc //if (tmdb.overview.isNotEmpty()) tmdb.overview else media.shortDesc
text_overview.text = media.info.shortDesc
if (StorageController.myList.contains(media.id)) {
Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(image_my_list_action)
} else {
Glide.with(requireContext()).load(R.drawable.ic_baseline_add_24).into(image_my_list_action)
}
// specific gui
if (media.type == MediaType.TVSHOW) {
adapterRecEpisodes = EpisodesAdapter(media.episodes, requireContext())
adapterRecEpisodes = EpisodeItemAdapter(media.episodes)
viewManager = LinearLayoutManager(context)
recycler_episodes.layoutManager = viewManager
recycler_episodes.adapter = adapterRecEpisodes
@ -87,17 +94,39 @@ class MediaFragment(private val media: Media, private val tmdb: TMDBResponse) :
}
}
// add or remove media from myList
linear_my_list_action.setOnClickListener {
if (StorageController.myList.contains(media.id)) {
StorageController.myList.remove(media.id)
Glide.with(requireContext()).load(R.drawable.ic_baseline_add_24).into(image_my_list_action)
} else {
StorageController.myList.add(media.id)
Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(image_my_list_action)
}
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.onItemClick = { _, position ->
adapterRecEpisodes.onImageClick = { _, position ->
playStream(media.episodes[position].streamUrl)
// update watched state
AoDParser().sendCallback(media.episodes[position].watchedCallback)
adapterRecEpisodes.updateWatchedState(true, position)
adapterRecEpisodes.notifyDataSetChanged()
}
}
}
private fun playStream(url: String) {
val mainActivity = activity as MainActivity
mainActivity.startPlayer(url)
Log.d(javaClass.name, "Playing stream: $url")
(activity as MainActivity).startPlayer(url)
}
}

View File

@ -1,4 +1,4 @@
package org.mosad.teapod.ui.search
package org.mosad.teapod.ui.fragments
import android.os.Bundle
import android.view.LayoutInflater
@ -11,12 +11,12 @@ import kotlinx.coroutines.*
import org.mosad.teapod.MainActivity
import org.mosad.teapod.R
import org.mosad.teapod.parser.AoDParser
import org.mosad.teapod.util.CustomAdapter
import org.mosad.teapod.util.Media
import org.mosad.teapod.util.decoration.MediaItemDecoration
import org.mosad.teapod.util.adapter.MediaItemAdapter
class SearchFragment : Fragment() {
private var adapter : CustomAdapter? = null
private var adapter : MediaItemAdapter? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_search, container, false)
@ -33,8 +33,14 @@ class SearchFragment : Fragment() {
// create and set the adapter, needs context
withContext(Dispatchers.Main) {
context?.let {
adapter = CustomAdapter(it, AoDParser.mediaList)
list_search.adapter = adapter
adapter = MediaItemAdapter(AoDParser.itemMediaList)
adapter!!.onItemClick = { mediaId, _ ->
search_text.clearFocus()
(activity as MainActivity).showMediaFragment(mediaId)
}
recycler_media_search.adapter = adapter
recycler_media_search.addItemDecoration(MediaItemDecoration(9))
}
}
}
@ -56,16 +62,5 @@ class SearchFragment : Fragment() {
return false
}
})
list_search.setOnItemClickListener { _, _, position, _ ->
search_text.clearFocus() // remove focus from the SearchView
runBlocking {
val media = adapter?.getItem(position) as Media
println("selected item is: ${media.title}")
(activity as MainActivity).showDetailFragment(media).join()
}
}
}
}

View File

@ -1,23 +0,0 @@
package org.mosad.teapod.ui.home
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import kotlinx.android.synthetic.main.fragment_home.*
import org.mosad.teapod.R
class HomeFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_home, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
text_home.text = "This is the home fragment"
}
}

View File

@ -1,71 +0,0 @@
package org.mosad.teapod.util
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import com.bumptech.glide.Glide
import org.mosad.teapod.R
import java.util.*
class CustomAdapter(val context: Context, private val originalMedia: ArrayList<Media>) : BaseAdapter(), Filterable {
private var filteredMedia = originalMedia.map { it.copy() }
private val customFilter = CustomFilter()
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: LayoutInflater.from(context).inflate(R.layout.linear_media, parent, false)
val textTitle = view.findViewById<TextView>(R.id.text_title)
val imagePoster = view.findViewById<ImageView>(R.id.image_poster)
textTitle.text = filteredMedia[position].title
Glide.with(context).load(filteredMedia[position].info.posterLink).into(imagePoster)
return view
}
override fun getFilter(): Filter {
return customFilter
}
override fun getCount(): Int {
return filteredMedia.size
}
override fun getItem(position: Int): Any {
return filteredMedia[position]
}
override fun getItemId(position: Int): Long {
return position.toLong()
}
inner class CustomFilter : Filter() {
override fun performFiltering(constraint: CharSequence?): FilterResults {
val filterTerm = constraint.toString().toLowerCase(Locale.ROOT)
val results = FilterResults()
val filteredList = if (filterTerm.isEmpty()) {
originalMedia
} else {
originalMedia.filter {
it.title.toLowerCase(Locale.ROOT).contains(filterTerm)
}
}
results.values = filteredList
results.count = filteredList.size
return results
}
override fun publishResults(constraint: CharSequence?, results: FilterResults?) {
filteredMedia = results?.values as ArrayList<Media>
notifyDataSetChanged()
}
}
}

View File

@ -8,14 +8,31 @@ class DataTypes {
}
}
data class Media(val title: String, val link: String, val type: DataTypes.MediaType, val info : Info = Info(), var episodes: List<Episode> = listOf()) {
override fun toString(): String {
return title
}
}
/**
* this class is used to represent the item media
* it is uses in the ItemMediaAdapter (RecyclerView)
*/
data class ItemMedia(
val id: Int,
val title: String,
val posterUrl: String
)
/**
* TODO the episodes workflow could use a clean up/rework
*/
data class Media(
val id: Int,
val link: String,
val type: DataTypes.MediaType,
val info: Info = Info(),
var episodes: List<Episode> = listOf()
)
data class Info(
var posterLink: String = "",
var title: String = "",
var posterUrl: String = "",
var shortDesc: String = "",
var description: String = "",
var year: Int = 0,
@ -27,11 +44,12 @@ data class Episode(
val id: Int = 0,
var title: String = "",
var streamUrl: String = "",
var posterLink: String = "",
var posterUrl: String = "",
var description: String = "",
var shortDesc: String = "",
var number: Int = 0,
var watched: Boolean = false
var watched: Boolean = false,
var watchedCallback: String = ""
)
data class TMDBResponse(

View File

@ -1,50 +0,0 @@
package org.mosad.teapod.util
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import kotlinx.android.synthetic.main.component_episode.view.*
import org.mosad.teapod.R
class EpisodesAdapter(private val episodes: List<Episode>, private val context: Context) : RecyclerView.Adapter<EpisodesAdapter.MyViewHolder>() {
var onItemClick: ((String, Int) -> Unit)? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.component_episode, parent, false)
return MyViewHolder(view)
}
override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
holder.view.text_episode_title.text = context.getString(
R.string.component_episode_title,
episodes[position].number,
episodes[position].description
)
holder.view.text_episode_desc.text = episodes[position].shortDesc
if (episodes[position].posterLink.isNotEmpty()) {
Glide.with(context).load(episodes[position].posterLink).into(holder.view.image_episode)
}
if (!episodes[position].watched) {
holder.view.image_watched.setImageDrawable(null)
}
}
override fun getItemCount(): Int {
return episodes.size
}
inner class MyViewHolder(val view: View) : RecyclerView.ViewHolder(view) {
init {
view.setOnClickListener {
onItemClick?.invoke(episodes[adapterPosition].title, adapterPosition)
}
}
}
}

View File

@ -0,0 +1,38 @@
package org.mosad.teapod.util
import android.content.Context
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import kotlinx.coroutines.*
import java.io.File
/**
* This controller contains the logic for permanently saved data.
* On load, it loads the saved files into the variables
*/
object StorageController {
private const val fileNameMyList = "my_list.json"
val myList = ArrayList<Int>() // a list of saved mediaIds
fun load(context: Context) {
val file = File(context.filesDir, fileNameMyList)
if (!file.exists()) runBlocking { saveMyList(context).join() }
myList.clear()
myList.addAll(
GsonBuilder().create().fromJson(file.readText(), ArrayList<Int>().javaClass)
)
}
fun saveMyList(context: Context): Job {
val file = File(context.filesDir, fileNameMyList)
return GlobalScope.launch(Dispatchers.IO) {
file.writeText(Gson().toJson(myList))
}
}
}

View File

@ -0,0 +1,70 @@
package org.mosad.teapod.util.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import jp.wasabeef.glide.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.item_episode.view.*
import org.mosad.teapod.R
import org.mosad.teapod.util.Episode
class EpisodeItemAdapter(private val episodes: List<Episode>) : RecyclerView.Adapter<EpisodeItemAdapter.MyViewHolder>() {
var onItemClick: ((String, Int) -> Unit)? = null
var onImageClick: ((String, Int) -> Unit)? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_episode, parent, false)
return MyViewHolder(view)
}
override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
val context = holder.view.context
holder.view.text_episode_title.text = context.getString(
R.string.component_episode_title,
episodes[position].number,
episodes[position].description
)
holder.view.text_episode_desc.text = episodes[position].shortDesc
if (episodes[position].posterUrl.isNotEmpty()) {
Glide.with(context).load(episodes[position].posterUrl)
.apply(RequestOptions.bitmapTransform(RoundedCornersTransformation(10, 0)))
.into(holder.view.image_episode)
}
if (episodes[position].watched) {
holder.view.image_watched.setImageDrawable(
ContextCompat.getDrawable(context, R.drawable.ic_baseline_check_circle_24)
)
} else {
holder.view.image_watched.setImageDrawable(null)
}
}
override fun getItemCount(): Int {
return episodes.size
}
fun updateWatchedState(watched: Boolean, position: Int) {
episodes[position].watched = watched
}
inner class MyViewHolder(val view: View) : RecyclerView.ViewHolder(view) {
init {
view.setOnClickListener {
onItemClick?.invoke(episodes[adapterPosition].title, adapterPosition)
}
view.image_episode.setOnClickListener {
onImageClick?.invoke(episodes[adapterPosition].title, adapterPosition)
}
}
}
}

View File

@ -0,0 +1,79 @@
package org.mosad.teapod.util.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Filter
import android.widget.Filterable
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import kotlinx.android.synthetic.main.item_media.view.*
import org.mosad.teapod.R
import org.mosad.teapod.util.ItemMedia
import java.util.*
class MediaItemAdapter(private val media: List<ItemMedia>) : RecyclerView.Adapter<MediaItemAdapter.ViewHolder>(), Filterable {
var onItemClick: ((Int, Int) -> Unit)? = null
private val filter = MediaFilter()
private var filteredMedia = media.map { it.copy() }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaItemAdapter.ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_media, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: MediaItemAdapter.ViewHolder, position: Int) {
holder.view.apply {
text_title.text = filteredMedia[position].title
Glide.with(context).load(filteredMedia[position].posterUrl).into(image_poster)
}
}
override fun getItemCount(): Int {
return filteredMedia.size
}
override fun getFilter(): Filter {
return filter
}
inner class ViewHolder(val view: View) : RecyclerView.ViewHolder(view) {
init {
view.setOnClickListener {
onItemClick?.invoke(filteredMedia[adapterPosition].id, adapterPosition)
}
}
}
inner class MediaFilter : Filter() {
override fun performFiltering(constraint: CharSequence?): FilterResults {
val filterTerm = constraint.toString().toLowerCase(Locale.ROOT)
val results = FilterResults()
val filteredList = if (filterTerm.isEmpty()) {
media
} else {
media.filter {
it.title.toLowerCase(Locale.ROOT).contains(filterTerm)
}
}
results.values = filteredList
results.count = filteredList.size
return results
}
@Suppress("unchecked_cast")
/**
* suppressing unchecked cast is safe, since we only use Media
*/
override fun publishResults(constraint: CharSequence?, results: FilterResults?) {
filteredMedia = results?.values as List<ItemMedia>
notifyDataSetChanged()
}
}
}

View File

@ -0,0 +1,16 @@
package org.mosad.teapod.util.decoration
import android.graphics.Rect
import android.view.View
import androidx.recyclerview.widget.RecyclerView
class MediaItemDecoration(private val spacing: Int) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
outRect.left = spacing
outRect.right = spacing
outRect.bottom = spacing
outRect.top = spacing
}
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<size android:width="24dp"
android:height="24dp"/>
<solid android:color="#81000000"/>
</shape>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
</vector>

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
@ -15,10 +14,12 @@
android:layout_gravity="center" />
<!-- app:controller_layout_id="@layout/player_custom_control"/>-->
<ProgressBar
<com.google.android.material.progressindicator.ProgressIndicator
android:id="@+id/loading"
style="@style/Widget.MaterialComponents.ProgressIndicator.Circular.Indeterminate"
android:layout_width="70dp"
android:layout_height="70dp"
android:layout_gravity="center" />
android:layout_gravity="center"
tools:visibility="visible" />
</FrameLayout>

View File

@ -5,7 +5,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#f5f5f5"
tools:context=".ui.account.AccountFragment">
tools:context=".ui.fragments.AccountFragment">
<ScrollView
android:layout_width="match_parent"

View File

@ -5,7 +5,45 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#f5f5f5"
tools:context=".ui.home.HomeFragment">
tools:context=".ui.fragments.HomeFragment">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" >
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="10dp"
android:paddingTop="15dp"
android:paddingEnd="5dp"
android:paddingBottom="5dp"
android:text="@string/my_list"
android:textSize="16sp"
android:textStyle="bold" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_my_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_media" />
</LinearLayout>
</LinearLayout>
</ScrollView>
<TextView
android:id="@+id/text_home"

View File

@ -5,15 +5,21 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#f5f5f5"
tools:context=".ui.library.LibraryFragment">
tools:context=".ui.fragments.LibraryFragment">
<ListView
android:id="@+id/list_library"
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_media_library"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="3dp"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
app:layout_constraintTop_toTopOf="parent"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:spanCount="2"
tools:listitem="@layout/item_media" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#f5f5f5"
android:clickable="true"
android:focusable="true">
<com.google.android.material.progressindicator.ProgressIndicator
android:id="@+id/progressBar2"
style="@style/Widget.MaterialComponents.ProgressIndicator.Circular.Indeterminate"
android:layout_width="70dp"
android:layout_height="70dp"
android:layout_gravity="center"
tools:visibility="visible" />
</FrameLayout>

View File

@ -5,7 +5,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#f5f5f5"
tools:context=".ui.MediaFragment">
tools:context=".ui.fragments.MediaFragment">
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
@ -30,13 +30,13 @@
android:minHeight="220dp"
android:scaleType="centerCrop" />
<ImageView
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/image_poster"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_height="200dp"
android:layout_gravity="center"
android:minHeight="200dp"
android:src="@drawable/ic_launcher_background" />
app:shapeAppearance="@style/ShapeAppearance.Teapod.RoundedPoster"
app:srcCompat="@drawable/ic_launcher_background" />
</FrameLayout>
@ -113,6 +113,37 @@
android:layout_marginEnd="12dp"
android:text="@string/text_overview_ex" />
<LinearLayout
android:id="@+id/linear_actions"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="12dp"
android:layout_marginTop="7dp"
android:layout_marginEnd="12dp"
android:orientation="horizontal">
<LinearLayout
android:id="@+id/linear_my_list_action"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical">
<ImageView
android:id="@+id/image_my_list_action"
android:layout_width="48dp"
android:layout_height="48dp"
android:src="@drawable/ic_baseline_add_24"
app:tint="#4A4141" />
<TextView
android:id="@+id/text_my_list_action"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/my_list" />
</LinearLayout>
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_episodes"
android:layout_width="match_parent"
@ -120,7 +151,8 @@
android:layout_marginStart="7dp"
android:layout_marginTop="17dp"
android:layout_marginEnd="7dp"
tools:layout_editor_absoluteY="298dp" />
tools:layout_editor_absoluteY="298dp"
tools:listitem="@layout/item_episode" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -5,16 +5,15 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#f5f5f5"
tools:context=".ui.search.SearchFragment">
tools:context=".ui.fragments.SearchFragment">
<SearchView
android:id="@+id/search_text"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_height="0dp"
android:background="#FFFFFF"
android:elevation="8dp"
android:iconifiedByDefault="false"
android:paddingStart="5dp"
android:paddingTop="5dp"
android:paddingEnd="5dp"
android:paddingBottom="5dp"
android:queryHint="@string/search_hint"
app:layout_constraintEnd_toEndOf="parent"
@ -23,13 +22,21 @@
</SearchView>
<ListView
android:id="@+id/list_search"
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_media_search"
android:layout_width="match_parent"
android:layout_height="0dp"
android:clipToPadding="false"
android:padding="3dp"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/search_text" />
app:layout_constraintTop_toBottomOf="@+id/search_text"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:spanCount="2"
tools:listitem="@layout/item_media">
</androidx.recyclerview.widget.RecyclerView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -14,12 +14,28 @@
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageView
android:id="@+id/image_episode"
android:layout_width="128dp"
android:layout_height="72dp"
android:contentDescription="@string/component_poster_desc"
app:srcCompat="@drawable/ic_baseline_account_box_24" />
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/image_episode"
android:layout_width="128dp"
android:layout_height="72dp"
android:contentDescription="@string/component_poster_desc"
app:shapeAppearance="@style/ShapeAppearance.Teapod.RoundedPoster"
app:srcCompat="@color/md_disabled_text_dark_theme" />
<ImageView
android:id="@+id/image_episode_play"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="@drawable/bg_circle__black_transparent_24dp"
android:contentDescription="@string/button_play"
app:srcCompat="@drawable/ic_baseline_play_arrow_24"
app:tint="#FFFFFF" />
</FrameLayout>
<TextView
android:id="@+id/text_episode_title"

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="195dp"
android:layout_height="wrap_content"
android:backgroundTint="#FFFFFF"
android:visibility="visible"
app:cardCornerRadius="7dp"
app:cardElevation="4dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/image_poster"
android:layout_width="0dp"
android:layout_height="0dp"
android:contentDescription="@string/media_poster_desc"
android:scaleType="centerCrop"
app:layout_constraintBottom_toTopOf="@+id/text_title"
app:layout_constraintDimensionRatio="H,16:9"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@color/md_disabled_text_dark_theme" />
<TextView
android:id="@+id/text_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:lines="2"
android:maxLines="2"
android:padding="3dp"
android:text="@string/text_title_ex"
android:textAlignment="center"
android:textSize="15sp"
app:layout_constraintTop_toBottomOf="@+id/image_poster" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -1,29 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/linear_media"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="7dp"
android:paddingTop="3dp"
android:paddingEnd="7dp"
android:paddingBottom="5dp">
<TextView
android:id="@+id/text_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="TextView"
android:textAlignment="center"
android:textSize="18sp"
android:textStyle="bold" />
<ImageView
android:id="@+id/image_poster"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="223dp"
tools:srcCompat="@drawable/ic_launcher_background" />
</LinearLayout>

View File

@ -7,25 +7,25 @@
<fragment
android:id="@+id/navigation_home"
android:name="org.mosad.teapod.ui.home.HomeFragment"
android:name="org.mosad.teapod.ui.fragments.HomeFragment"
android:label="@string/title_home"
tools:layout="@layout/fragment_home" />
<fragment
android:id="@+id/navigation_library"
android:name="org.mosad.teapod.ui.library.LibraryFragment"
android:name="org.mosad.teapod.ui.fragments.LibraryFragment"
android:label="@string/title_library"
tools:layout="@layout/fragment_library" />
<fragment
android:id="@+id/navigation_search"
android:name="org.mosad.teapod.ui.search.SearchFragment"
android:name="org.mosad.teapod.ui.fragments.SearchFragment"
android:label="@string/title_search"
tools:layout="@layout/fragment_search" />
<fragment
android:id="@+id/navigation_account"
android:name="org.mosad.teapod.ui.account.AccountFragment"
android:name="org.mosad.teapod.ui.fragments.AccountFragment"
android:label="@string/title_account"
tools:layout="@layout/fragment_account" />

View File

@ -14,7 +14,7 @@
</notice>
<notice>
<name>ExoPlayer</name>
<url>https://github.com/material-components/material-components-android</url>
<url>https://github.com/google/ExoPlayer</url>
<copyright>Copyright The Android Open Source Project</copyright>
<license>Apache Software License 2.0</license>
</notice>

View File

@ -5,6 +5,9 @@
<string name="title_search">Suche</string>
<string name="title_account">Account</string>
<!-- home fragment -->
<string name="my_list">Meine Liste</string>
<!-- search fragment -->
<string name="search_hint">Suche nach Filmen und Serien</string>
@ -24,7 +27,7 @@
<!-- dialogs -->
<string name="save">speichern</string>
<string name="cancel">\@android:string/cancel</string>
<string name="cancel">@android:string/cancel</string>
<!-- etc -->
<string name="login">Login</string>

View File

@ -5,8 +5,12 @@
<string name="title_search">Search</string>
<string name="title_account">Account</string>
<!-- home fragment -->
<string name="my_list">My list</string>
<!-- search fragment -->
<string name="search_hint">Search for movies and series</string>
<string name="media_poster_desc" translatable="false">poster</string>
<!-- media fragment -->
<string name="button_play">Play</string>

View File

@ -14,5 +14,10 @@
<item name="android:windowContentOverlay">@null</item>
</style>
<!-- shapes -->
<style name="ShapeAppearance.Teapod.RoundedPoster" parent="ShapeAppearance.MaterialComponents.LargeComponent">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">5dp</item>
</style>
</resources>