Implement home screen item cache and actions system. First action when clicking on artist.

This commit is contained in:
Antoine POPINEAU 2020-06-22 20:22:30 +02:00 committed by Antoine POPINEAU
parent 21a545d70a
commit 73ab61e64b
No known key found for this signature in database
GPG Key ID: A78AC64694F84063
10 changed files with 204 additions and 30 deletions

View File

@ -6,21 +6,39 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.HomeFragment
import com.github.apognu.otter.utils.Artist
import com.github.apognu.otter.utils.mustNormalizeUrl import com.github.apognu.otter.utils.mustNormalizeUrl
import com.squareup.picasso.Picasso import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.row_home_media.view.* import kotlinx.android.synthetic.main.row_home_media.view.*
class HomeMediaAdapter(val context: Context?, val viewRes: Int = R.layout.row_home_media) : RecyclerView.Adapter<HomeMediaAdapter.ViewHolder>() { class HomeMediaAdapter(
data class HomeMediaItem(val label: String, val cover: String?) private val context: Context?,
private val kind: ItemType,
private val viewRes: Int = R.layout.row_home_media,
private val listener: HomeFragment.OnHomeClickListener? = null
) : RecyclerView.Adapter<HomeMediaAdapter.ViewHolder>() {
enum class ItemType {
Tag, Artist, Album, Track
}
data class HomeMediaItem(
val label: String,
val cover: String?,
val artist: Artist? = null
)
var data: List<HomeMediaItem> = listOf() var data: List<HomeMediaItem> = listOf()
override fun getItemCount() = data.size override fun getItemCount() = data.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return LayoutInflater.from(context).inflate(viewRes, parent, false).run { val view = LayoutInflater.from(context).inflate(viewRes, parent, false)
ViewHolder(this)
return ViewHolder(view).also {
view.setOnClickListener(it)
} }
} }
@ -40,8 +58,14 @@ class HomeMediaAdapter(val context: Context?, val viewRes: Int = R.layout.row_ho
} }
} }
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view), View.OnClickListener {
val label = view.label val label = view.label
val cover = view.cover val cover = view.cover
override fun onClick(view: View?) {
when {
kind == ItemType.Artist -> listener?.onClick(artist = data[layoutPosition].artist)
}
}
} }
} }

View File

@ -8,39 +8,75 @@ import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.github.apognu.otter.R import com.github.apognu.otter.R
import com.github.apognu.otter.adapters.home.HomeMediaAdapter import com.github.apognu.otter.adapters.home.HomeMediaAdapter
import com.github.apognu.otter.adapters.home.HomeMediaAdapter.ItemType
import com.github.apognu.otter.repositories.Repository import com.github.apognu.otter.repositories.Repository
import com.github.apognu.otter.repositories.home.RandomArtistsRepository
import com.github.apognu.otter.repositories.home.RecentlyAddedRepository import com.github.apognu.otter.repositories.home.RecentlyAddedRepository
import com.github.apognu.otter.repositories.home.RecentlyListenedRepository import com.github.apognu.otter.repositories.home.RecentlyListenedRepository
import com.github.apognu.otter.repositories.home.TagsRepository import com.github.apognu.otter.repositories.home.TagsRepository
import com.github.apognu.otter.utils.untilNetwork import com.github.apognu.otter.utils.*
import com.google.android.flexbox.FlexboxLayoutManager import com.google.android.flexbox.FlexboxLayoutManager
import com.google.android.flexbox.JustifyContent
import com.google.gson.Gson
import kotlinx.android.synthetic.main.fragment_home.* import kotlinx.android.synthetic.main.fragment_home.*
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.*
class HomeFragment : Fragment() { class HomeFragment : Fragment() {
interface OnHomeClickListener {
fun onClick(artist: Artist? = null, album: Album? = null, track: Track? = null)
}
val CACHE_DURATION = 15 * 60 * 1000
private var bus: Job? = null
private lateinit var tagsRepository: TagsRepository private lateinit var tagsRepository: TagsRepository
private lateinit var randomArtistsRepository: RandomArtistsRepository
private lateinit var recentlyAddedRepository: RecentlyAddedRepository private lateinit var recentlyAddedRepository: RecentlyAddedRepository
private lateinit var recentlyListenedRepository: RecentlyListenedRepository private lateinit var recentlyListenedRepository: RecentlyListenedRepository
private lateinit var tagsAdapter: HomeMediaAdapter private lateinit var tagsAdapter: HomeMediaAdapter
private lateinit var randomAdapter: HomeMediaAdapter
private lateinit var recentlyAddedAdapter: HomeMediaAdapter private lateinit var recentlyAddedAdapter: HomeMediaAdapter
private lateinit var recentlyListenedAdapter: HomeMediaAdapter private lateinit var recentlyListenedAdapter: HomeMediaAdapter
private lateinit var randomAdapter: HomeMediaAdapter
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
tagsRepository = TagsRepository(context) tagsRepository = TagsRepository(context)
randomArtistsRepository = RandomArtistsRepository(context)
recentlyAddedRepository = RecentlyAddedRepository(context) recentlyAddedRepository = RecentlyAddedRepository(context)
recentlyListenedRepository = RecentlyListenedRepository(context) recentlyListenedRepository = RecentlyListenedRepository(context)
tagsAdapter = HomeMediaAdapter(context, R.layout.row_tag) tagsAdapter = HomeMediaAdapter(context, ItemType.Tag, R.layout.row_tag)
recentlyAddedAdapter = HomeMediaAdapter(context) randomAdapter = HomeMediaAdapter(context, ItemType.Artist, listener = ArtistClickListener())
recentlyListenedAdapter = HomeMediaAdapter(context) recentlyAddedAdapter = HomeMediaAdapter(context, ItemType.Track)
randomAdapter = HomeMediaAdapter(context) recentlyListenedAdapter = HomeMediaAdapter(context, ItemType.Track)
}
override fun onResume() {
super.onResume()
bus = GlobalScope.launch(IO) {
EventBus.get().collect { event ->
if (event is Event.ListingsChanged) {
refresh(true)
}
}
}
}
override fun onPause() {
super.onPause()
bus?.cancel()
bus = null
} }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
@ -51,9 +87,11 @@ class HomeFragment : Fragment() {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
tags.apply { tags.apply {
isNestedScrollingEnabled = false
adapter = tagsAdapter adapter = tagsAdapter
layoutManager = FlexboxLayoutManager(context).apply { layoutManager = FlexboxLayoutManager(context).apply {
isNestedScrollingEnabled = false justifyContent = JustifyContent.SPACE_BETWEEN
} }
} }
@ -75,34 +113,89 @@ class HomeFragment : Fragment() {
refresh() refresh()
} }
private fun refresh() { private fun originFor(repository: Repository<*, *>, force: Boolean = false): Repository.Origin {
tagsRepository.fetch(Repository.Origin.Network.origin).untilNetwork(IO) {data, _, _ -> if (force) return Repository.Origin.Network
repository.cacheId?.let { cacheId ->
repository.cache(listOf())?.let {
Cache.get(context, "$cacheId-at")?.readLine()?.toLong()?.let { date ->
return if ((Date().time - date) < CACHE_DURATION) Repository.Origin.Cache
else Repository.Origin.Network
}
}
}
return Repository.Origin.Network
}
private fun <T: Any> cache(repository: Repository<T, *>, data: List<T>) {
repository.cacheId?.let { cacheId ->
repository.cache(data)?.let { cache ->
Cache.set(
context,
cacheId,
Gson().toJson(cache).toByteArray()
)
Cache.set(context, "$cacheId-at", Date().time.toString().toByteArray())
}
}
}
private fun refresh(force: Boolean = false) {
tagsRepository.fetch(originFor(tagsRepository, force).origin).untilNetwork(IO) { data, isCache, _ ->
GlobalScope.launch(Main) { GlobalScope.launch(Main) {
tagsAdapter.data = data.map { HomeMediaAdapter.HomeMediaItem(it.name, null) } tagsAdapter.data = data.map { HomeMediaAdapter.HomeMediaItem(it.name, null) }
tagsAdapter.notifyDataSetChanged() tagsAdapter.notifyDataSetChanged()
tags_loader.visibility = View.GONE tags_loader?.visibility = View.GONE
tags.visibility = View.VISIBLE tags?.visibility = View.VISIBLE
if (!isCache) cache(tagsRepository, data)
} }
} }
recentlyListenedRepository.fetch(Repository.Origin.Network.origin).untilNetwork(IO) { data, _, _ -> randomArtistsRepository.fetch(originFor(randomArtistsRepository, force).origin).untilNetwork(IO) { data, isCache, _ ->
GlobalScope.launch(Main) {
randomAdapter.data = data.map { HomeMediaAdapter.HomeMediaItem(it.name, it.albums?.getOrNull(0)?.cover?.original, artist = it) }
randomAdapter.notifyDataSetChanged()
random_loader?.visibility = View.GONE
random?.visibility = View.VISIBLE
if (!isCache) cache(randomArtistsRepository, data)
}
}
recentlyListenedRepository.fetch(originFor(recentlyListenedRepository, force).origin).untilNetwork(IO) { data, isCache, _ ->
GlobalScope.launch(Main) { GlobalScope.launch(Main) {
recentlyListenedAdapter.data = data.map { HomeMediaAdapter.HomeMediaItem(it.track.title, it.track.album.cover.original) } recentlyListenedAdapter.data = data.map { HomeMediaAdapter.HomeMediaItem(it.track.title, it.track.album.cover.original) }
recentlyListenedAdapter.notifyDataSetChanged() recentlyListenedAdapter.notifyDataSetChanged()
recently_listened_loader.visibility = View.GONE recently_listened_loader?.visibility = View.GONE
recently_listened.visibility = View.VISIBLE recently_listened?.visibility = View.VISIBLE
if (!isCache) cache(recentlyListenedRepository, data)
} }
} }
recentlyAddedRepository.fetch(Repository.Origin.Network.origin).untilNetwork(IO) { data, _, _ -> recentlyAddedRepository.fetch(originFor(recentlyAddedRepository, force).origin).untilNetwork(IO) { data, isCache, _ ->
GlobalScope.launch(Main) { GlobalScope.launch(Main) {
recentlyAddedAdapter.data = data.map { HomeMediaAdapter.HomeMediaItem(it.title, it.album.cover.original) } recentlyAddedAdapter.data = data.map { HomeMediaAdapter.HomeMediaItem(it.title, it.album.cover.original) }
recentlyAddedAdapter.notifyDataSetChanged() recentlyAddedAdapter.notifyDataSetChanged()
recently_added_loader.visibility = View.GONE recently_added_loader?.visibility = View.GONE
recently_added.visibility = View.VISIBLE recently_added?.visibility = View.VISIBLE
if (!isCache) cache(recentlyAddedRepository, data)
}
}
}
inner class ArtistClickListener : OnHomeClickListener {
override fun onClick(artist: Artist?, album: Album?, track: Track?) {
artist?.let {
ArtistsFragment.openAlbums(context, artist)
} }
} }
} }

View File

@ -0,0 +1,27 @@
package com.github.apognu.otter.repositories.home
import android.content.Context
import com.github.apognu.otter.repositories.HttpUpstream
import com.github.apognu.otter.repositories.Repository
import com.github.apognu.otter.utils.Artist
import com.github.apognu.otter.utils.ArtistsCache
import com.github.apognu.otter.utils.ArtistsResponse
import com.github.apognu.otter.utils.FunkwhaleResponse
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import java.io.BufferedReader
class RandomArtistsRepository(override val context: Context?) : Repository<Artist, ArtistsCache>() {
override val cacheId = "home-random-artists"
override val upstream =
HttpUpstream<Artist, FunkwhaleResponse<Artist>>(
HttpUpstream.Behavior.Single,
"/api/v1/artists/?playable=true&ordering=random",
object : TypeToken<ArtistsResponse>() {}.type,
10
)
override fun cache(data: List<Artist>) = ArtistsCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader)
}

View File

@ -3,8 +3,13 @@ package com.github.apognu.otter.repositories.home
import android.content.Context import android.content.Context
import com.github.apognu.otter.repositories.HttpUpstream import com.github.apognu.otter.repositories.HttpUpstream
import com.github.apognu.otter.repositories.Repository import com.github.apognu.otter.repositories.Repository
import com.github.apognu.otter.utils.* import com.github.apognu.otter.utils.FunkwhaleResponse
import com.github.apognu.otter.utils.Track
import com.github.apognu.otter.utils.TracksCache
import com.github.apognu.otter.utils.TracksResponse
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import java.io.BufferedReader
class RecentlyAddedRepository(override val context: Context?) : Repository<Track, TracksCache>() { class RecentlyAddedRepository(override val context: Context?) : Repository<Track, TracksCache>() {
override val cacheId = "home-recently-added" override val cacheId = "home-recently-added"
@ -16,4 +21,7 @@ class RecentlyAddedRepository(override val context: Context?) : Repository<Track
object : TypeToken<TracksResponse>() {}.type, object : TypeToken<TracksResponse>() {}.type,
10 10
) )
override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
} }

View File

@ -3,8 +3,13 @@ package com.github.apognu.otter.repositories.home
import android.content.Context import android.content.Context
import com.github.apognu.otter.repositories.HttpUpstream import com.github.apognu.otter.repositories.HttpUpstream
import com.github.apognu.otter.repositories.Repository import com.github.apognu.otter.repositories.Repository
import com.github.apognu.otter.utils.* import com.github.apognu.otter.utils.FunkwhaleResponse
import com.github.apognu.otter.utils.PlaylistTrack
import com.github.apognu.otter.utils.PlaylistTracksCache
import com.github.apognu.otter.utils.PlaylistTracksResponse
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import java.io.BufferedReader
class RecentlyListenedRepository(override val context: Context?) : Repository<PlaylistTrack, PlaylistTracksCache>() { class RecentlyListenedRepository(override val context: Context?) : Repository<PlaylistTrack, PlaylistTracksCache>() {
override val cacheId = "home-recently-listened" override val cacheId = "home-recently-listened"
@ -16,4 +21,7 @@ class RecentlyListenedRepository(override val context: Context?) : Repository<Pl
object : TypeToken<PlaylistTracksResponse>() {}.type, object : TypeToken<PlaylistTracksResponse>() {}.type,
10 10
) )
override fun cache(data: List<PlaylistTrack>) = PlaylistTracksCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(PlaylistTracksCache::class.java).deserialize(reader)
} }

View File

@ -3,11 +3,16 @@ package com.github.apognu.otter.repositories.home
import android.content.Context import android.content.Context
import com.github.apognu.otter.repositories.HttpUpstream import com.github.apognu.otter.repositories.HttpUpstream
import com.github.apognu.otter.repositories.Repository import com.github.apognu.otter.repositories.Repository
import com.github.apognu.otter.utils.* import com.github.apognu.otter.utils.FunkwhaleResponse
import com.github.apognu.otter.utils.Tag
import com.github.apognu.otter.utils.TagsCache
import com.github.apognu.otter.utils.TagsResponse
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import java.io.BufferedReader
class TagsRepository(override val context: Context?) : Repository<Tag, TagsCache>() { class TagsRepository(override val context: Context?) : Repository<Tag, TagsCache>() {
override val cacheId = "tags" override val cacheId = "home-tags"
override val upstream = override val upstream =
HttpUpstream<Tag, FunkwhaleResponse<Tag>>( HttpUpstream<Tag, FunkwhaleResponse<Tag>>(
@ -15,4 +20,9 @@ class TagsRepository(override val context: Context?) : Repository<Tag, TagsCache
"/api/v1/tags/", "/api/v1/tags/",
object : TypeToken<TagsResponse>() {}.type object : TypeToken<TagsResponse>() {}.type
) )
override fun onDataFetched(data: List<Tag>) = data.shuffled().take(10)
override fun cache(data: List<Tag>) = TagsCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TagsCache::class.java).deserialize(reader)
} }

View File

@ -76,6 +76,7 @@ object Cache {
fun set(context: Context?, key: String, value: ByteArray) = context?.let { fun set(context: Context?, key: String, value: ByteArray) = context?.let {
with(File(it.cacheDir, key(key))) { with(File(it.cacheDir, key(key))) {
"$key (${key(key)} : $value"
writeBytes(value) writeBytes(value)
} }
} }

View File

@ -17,7 +17,7 @@
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:layout_marginEnd="16dp" android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
android:text="Tags" /> android:text="Some tags" />
<ProgressBar <ProgressBar
android:id="@+id/tags_loader" android:id="@+id/tags_loader"

View File

@ -3,13 +3,16 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp" android:layout_marginHorizontal="8dp"
android:orientation="vertical"> android:background="@drawable/ripple"
android:orientation="vertical"
android:padding="8dp">
<ImageView <ImageView
android:id="@+id/cover" android:id="@+id/cover"
android:layout_width="150dp" android:layout_width="150dp"
android:layout_height="150dp" android:layout_height="150dp"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
android:contentDescription="@string/alt_album_cover"
android:src="@drawable/cover" /> android:src="@drawable/cover" />
<TextView <TextView

View File

@ -7,13 +7,13 @@
android:background="@drawable/rounded" android:background="@drawable/rounded"
android:orientation="vertical" android:orientation="vertical"
android:paddingHorizontal="12dp" android:paddingHorizontal="12dp"
android:paddingVertical="8dp"> android:paddingVertical="6dp">
<TextView <TextView
android:id="@+id/label" android:id="@+id/label"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textColor="@android:color/white" android:textColor="@android:color/white"
android:textSize="18sp" /> android:textSize="16sp" />
</LinearLayout> </LinearLayout>