diff --git a/app/src/main/java/com/github/apognu/otter/activities/SearchActivity.kt b/app/src/main/java/com/github/apognu/otter/activities/SearchActivity.kt index b7e7065..391368a 100644 --- a/app/src/main/java/com/github/apognu/otter/activities/SearchActivity.kt +++ b/app/src/main/java/com/github/apognu/otter/activities/SearchActivity.kt @@ -5,19 +5,20 @@ import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.LinearLayoutManager import com.github.apognu.otter.R -import com.github.apognu.otter.adapters.TracksAdapter -import com.github.apognu.otter.repositories.FavoritesRepository -import com.github.apognu.otter.repositories.Repository -import com.github.apognu.otter.repositories.SearchRepository +import com.github.apognu.otter.adapters.SearchAdapter +import com.github.apognu.otter.repositories.* import com.github.apognu.otter.utils.untilNetwork import kotlinx.android.synthetic.main.activity_search.* import java.net.URLEncoder import java.util.* class SearchActivity : AppCompatActivity() { - private lateinit var adapter: TracksAdapter + private lateinit var adapter: SearchAdapter + + lateinit var artistsRepository: ArtistsSearchRepository + lateinit var albumsRepository: AlbumsSearchRepository + lateinit var tracksRepository: TracksSearchRepository - lateinit var repository: SearchRepository lateinit var favoritesRepository: FavoritesRepository override fun onCreate(savedInstanceState: Bundle?) { @@ -25,7 +26,7 @@ class SearchActivity : AppCompatActivity() { setContentView(R.layout.activity_search) - adapter = TracksAdapter(this, FavoriteListener()).also { + adapter = SearchAdapter(this, FavoriteListener()).also { results.layoutManager = LinearLayoutManager(this) results.adapter = it } @@ -43,25 +44,47 @@ class SearchActivity : AppCompatActivity() { query?.let { val query = URLEncoder.encode(it, "UTF-8") - repository = SearchRepository(this@SearchActivity, query.toLowerCase(Locale.ROOT)) + tracksRepository = TracksSearchRepository(this@SearchActivity, query.toLowerCase(Locale.ROOT)) + albumsRepository = AlbumsSearchRepository(this@SearchActivity, query.toLowerCase(Locale.ROOT)) + artistsRepository = ArtistsSearchRepository(this@SearchActivity, query.toLowerCase(Locale.ROOT)) favoritesRepository = FavoritesRepository(this@SearchActivity) search_spinner.visibility = View.VISIBLE search_no_results.visibility = View.GONE - adapter.data.clear() + adapter.artists.clear() + adapter.albums.clear() + adapter.tracks.clear() adapter.notifyDataSetChanged() - repository.fetch(Repository.Origin.Network.origin).untilNetwork { tracks, _, _ -> + artistsRepository.fetch(Repository.Origin.Network.origin).untilNetwork { artists, _, _ -> + when (artists.isEmpty()) { + true -> search_no_results.visibility = View.VISIBLE + false -> adapter.artists.addAll(artists) + } + + adapter.notifyDataSetChanged() + } + + albumsRepository.fetch(Repository.Origin.Network.origin).untilNetwork { albums, _, _ -> + when (albums.isEmpty()) { + true -> search_no_results.visibility = View.VISIBLE + false -> adapter.albums.addAll(albums) + } + + adapter.notifyDataSetChanged() + } + + tracksRepository.fetch(Repository.Origin.Network.origin).untilNetwork { tracks, _, _ -> search_spinner.visibility = View.GONE search_empty.visibility = View.GONE when (tracks.isEmpty()) { true -> search_no_results.visibility = View.VISIBLE - false -> adapter.data.addAll(tracks) + false -> adapter.tracks.addAll(tracks) } - adapter.notifyItemRangeInserted(adapter.data.size, tracks.size) + adapter.notifyDataSetChanged() } } @@ -72,7 +95,7 @@ class SearchActivity : AppCompatActivity() { }) } - inner class FavoriteListener : TracksAdapter.OnFavoriteListener { + inner class FavoriteListener : SearchAdapter.OnFavoriteListener { override fun onToggleFavorite(id: Int, state: Boolean) { when (state) { true -> favoritesRepository.addFavorite(id) diff --git a/app/src/main/java/com/github/apognu/otter/adapters/SearchAdapter.kt b/app/src/main/java/com/github/apognu/otter/adapters/SearchAdapter.kt new file mode 100644 index 0000000..7173754 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/adapters/SearchAdapter.kt @@ -0,0 +1,206 @@ +package com.github.apognu.otter.adapters + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Typeface +import android.os.Build +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.PopupMenu +import androidx.recyclerview.widget.RecyclerView +import com.github.apognu.otter.R +import com.github.apognu.otter.utils.* +import com.squareup.picasso.Picasso +import jp.wasabeef.picasso.transformations.RoundedCornersTransformation +import kotlinx.android.synthetic.main.row_track.view.* + +class SearchAdapter(private val context: Context?, private val favoriteListener: OnFavoriteListener? = null) : RecyclerView.Adapter() { + interface OnFavoriteListener { + fun onToggleFavorite(id: Int, state: Boolean) + } + + enum class ResultType { + Header, + Artist, + Album, + Track + } + + val SECTION_COUNT = 3 + + var artists: MutableList = mutableListOf() + var albums: MutableList = mutableListOf() + var tracks: MutableList = mutableListOf() + + var currentTrack: Track? = null + + override fun getItemCount() = SECTION_COUNT + artists.size + albums.size + tracks.size + + override fun getItemId(position: Int): Long { + return when (getItemViewType(position)) { + ResultType.Header.ordinal -> { + if (position == 0) return -1 + if (position == (artists.size + 1)) return -2 + return -3 + } + + ResultType.Artist.ordinal -> artists[position].id.toLong() + ResultType.Artist.ordinal -> albums[position - artists.size - 2].id.toLong() + ResultType.Track.ordinal -> tracks[position - artists.size - albums.size - SECTION_COUNT].id.toLong() + else -> 0 + } + } + + override fun getItemViewType(position: Int): Int { + if (position == 0) return ResultType.Header.ordinal // Artists header + if (position == (artists.size + 1)) return ResultType.Header.ordinal // Albums header + if (position == (artists.size + albums.size + 2)) return ResultType.Header.ordinal // Tracks header + + if (position <= artists.size) return ResultType.Artist.ordinal + if (position <= artists.size + albums.size + 2) return ResultType.Album.ordinal + + return ResultType.Track.ordinal + } + + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + super.onAttachedToRecyclerView(recyclerView) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = when (viewType) { + ResultType.Header.ordinal -> LayoutInflater.from(context).inflate(R.layout.row_search_header, parent, false) + else -> LayoutInflater.from(context).inflate(R.layout.row_track, parent, false) + } + + return ViewHolder(view, context).also { + view.setOnClickListener(it) + } + } + + @SuppressLint("NewApi") + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val resultType = getItemViewType(position) + + if (resultType == ResultType.Header.ordinal) { + context?.let { context -> + if (position == 0) holder.title.text = context.getString(R.string.artists) + if (position == (artists.size + 1)) holder.title.text = context.getString(R.string.albums) + if (position == (artists.size + albums.size + 2)) holder.title.text = context.getString(R.string.tracks) + } + + return + } + + val item = when (resultType) { + ResultType.Artist.ordinal -> { + holder.actions.visibility = View.GONE + holder.favorite.visibility = View.GONE + + artists[position - 1] + } + + ResultType.Album.ordinal -> { + holder.actions.visibility = View.GONE + holder.favorite.visibility = View.GONE + + albums[position - artists.size - 2] + } + + ResultType.Track.ordinal -> tracks[position - artists.size - albums.size - SECTION_COUNT] + + else -> tracks[position] + } + + Picasso.get() + .maybeLoad(maybeNormalizeUrl(item.cover())) + .fit() + .transform(RoundedCornersTransformation(16, 0)) + .into(holder.cover) + + holder.title.text = item.title() + holder.artist.text = item.subtitle() + + Build.VERSION_CODES.P.onApi( + { + holder.title.setTypeface(holder.title.typeface, Typeface.DEFAULT.weight) + holder.artist.setTypeface(holder.artist.typeface, Typeface.DEFAULT.weight) + }, + { + holder.title.typeface = Typeface.create(holder.title.typeface, Typeface.NORMAL) + holder.artist.typeface = Typeface.create(holder.artist.typeface, Typeface.NORMAL) + }) + + if (resultType == ResultType.Track.ordinal) { + (item as? Track)?.let { track -> + context?.let { context -> + if (track == currentTrack || track.current) { + holder.title.setTypeface(holder.title.typeface, Typeface.BOLD) + holder.artist.setTypeface(holder.artist.typeface, Typeface.BOLD) + } + + when (track.favorite) { + true -> holder.favorite.setColorFilter(context.getColor(R.color.colorFavorite)) + false -> holder.favorite.setColorFilter(context.getColor(R.color.colorSelected)) + } + + holder.favorite.setOnClickListener { + favoriteListener?.let { + favoriteListener.onToggleFavorite(track.id, !track.favorite) + + tracks[position - artists.size - albums.size - SECTION_COUNT].favorite = !track.favorite + + notifyItemChanged(position) + } + } + + holder.actions.setOnClickListener { + PopupMenu(context, holder.actions, Gravity.START, R.attr.actionOverflowMenuStyle, 0).apply { + inflate(R.menu.row_track) + + setOnMenuItemClickListener { + when (it.itemId) { + R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(track))) + R.id.track_play_next -> CommandBus.send(Command.PlayNext(track)) + R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(track)) + } + + true + } + + show() + } + } + } + } + } + } + + inner class ViewHolder(view: View, val context: Context?) : RecyclerView.ViewHolder(view), View.OnClickListener { + val handle = view.handle + val cover = view.cover + val title = view.title + val artist = view.artist + + val favorite = view.favorite + val actions = view.actions + + override fun onClick(view: View?) { + when (getItemViewType(layoutPosition)) { + ResultType.Track.ordinal -> { + val position = layoutPosition - artists.size - albums.size - SECTION_COUNT + + tracks.subList(position, tracks.size).plus(tracks.subList(0, position)).apply { + CommandBus.send(Command.ReplaceQueue(this)) + + context.toast("All tracks were added to your queue") + } + } + + else -> { + } + } + } + } +} diff --git a/app/src/main/java/com/github/apognu/otter/repositories/SearchRepository.kt b/app/src/main/java/com/github/apognu/otter/repositories/SearchRepository.kt index 7607cb4..6f96dc3 100644 --- a/app/src/main/java/com/github/apognu/otter/repositories/SearchRepository.kt +++ b/app/src/main/java/com/github/apognu/otter/repositories/SearchRepository.kt @@ -1,10 +1,7 @@ package com.github.apognu.otter.repositories import android.content.Context -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.apognu.otter.utils.* import com.github.kittinunf.fuel.gson.gsonDeserializerOf import com.google.gson.reflect.TypeToken import kotlinx.coroutines.flow.map @@ -12,7 +9,7 @@ import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking import java.io.BufferedReader -class SearchRepository(override val context: Context?, query: String) : Repository() { +class TracksSearchRepository(override val context: Context?, query: String) : Repository() { override val cacheId: String? = null override val upstream = HttpUpstream>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&q=$query", object : TypeToken() {}.type) @@ -30,4 +27,20 @@ class SearchRepository(override val context: Context?, query: String) : Reposito track } } +} + +class ArtistsSearchRepository(override val context: Context?, query: String) : Repository() { + override val cacheId: String? = null + override val upstream = HttpUpstream>(HttpUpstream.Behavior.AtOnce, "/api/v1/artists/?playable=true&q=$query", object : TypeToken() {}.type) + + override fun cache(data: List) = ArtistsCache(data) + override fun uncache(reader: BufferedReader) = gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader) +} + +class AlbumsSearchRepository(override val context: Context?, query: String) : Repository() { + override val cacheId: String? = null + override val upstream = HttpUpstream>(HttpUpstream.Behavior.AtOnce, "/api/v1/albums/?playable=true&q=$query", object : TypeToken() {}.type) + + override fun cache(data: List) = AlbumsCache(data) + override fun uncache(reader: BufferedReader) = gsonDeserializerOf(AlbumsCache::class.java).deserialize(reader) } \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/utils/Models.kt b/app/src/main/java/com/github/apognu/otter/utils/Models.kt index 63de201..ad6ca94 100644 --- a/app/src/main/java/com/github/apognu/otter/utils/Models.kt +++ b/app/src/main/java/com/github/apognu/otter/utils/Models.kt @@ -50,24 +50,38 @@ data class Covers(val original: String) typealias AlbumList = List +interface SearchResult { + fun cover(): String? + fun title(): String + fun subtitle(): String +} + data class Album( val id: Int, val artist: Artist, val title: String, val cover: Covers -) { +) : SearchResult { data class Artist(val name: String) + + override fun cover() = cover.original + override fun title() = title + override fun subtitle() = artist.name } data class Artist( val id: Int, val name: String, val albums: List? -) { +) : SearchResult { data class Album( val title: String, val cover: Covers ) + + override fun cover() = albums?.getOrNull(0)?.cover?.original + override fun title() = name + override fun subtitle() = "Artist" } data class Track( @@ -77,7 +91,7 @@ data class Track( val album: Album, val position: Int, val uploads: List -) { +) : SearchResult { var current: Boolean = false var favorite: Boolean = false @@ -103,6 +117,10 @@ data class Track( else -> uploads.maxBy { it.bitrate } ?: uploads[0] } } + + override fun cover() = album.cover.original + override fun title() = title + override fun subtitle() = artist.name } data class Favorited(val track: Int) diff --git a/app/src/main/res/layout/row_search_header.xml b/app/src/main/res/layout/row_search_header.xml new file mode 100644 index 0000000..68f3e9e --- /dev/null +++ b/app/src/main/res/layout/row_search_header.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 9264e21..1ccb925 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -53,6 +53,7 @@ Artistes Albums + Pistes Playlists Favoris diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 44f1e89..d9705d4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -53,6 +53,7 @@ Artists Albums + Tracks Playlists Favorites