This commit is contained in:
Antoine POPINEAU 2020-08-03 15:22:52 +02:00
parent 945f227ace
commit 1126d47a1a
No known key found for this signature in database
GPG Key ID: A78AC64694F84063
29 changed files with 432 additions and 277 deletions

View File

@ -2,12 +2,14 @@ package com.github.apognu.otter
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.widget.SearchView
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.room.Room import androidx.room.Room
import com.github.apognu.otter.activities.MainActivity import com.github.apognu.otter.activities.MainActivity
import com.github.apognu.otter.activities.SearchActivity import com.github.apognu.otter.activities.SearchActivity
import com.github.apognu.otter.adapters.* import com.github.apognu.otter.adapters.*
import com.github.apognu.otter.fragments.* import com.github.apognu.otter.fragments.*
import com.github.apognu.otter.models.Mediator
import com.github.apognu.otter.models.dao.OtterDatabase import com.github.apognu.otter.models.dao.OtterDatabase
import com.github.apognu.otter.playback.MediaSession import com.github.apognu.otter.playback.MediaSession
import com.github.apognu.otter.playback.QueueManager.Companion.factory import com.github.apognu.otter.playback.QueueManager.Companion.factory
@ -105,9 +107,6 @@ class Otter : Application() {
} }
} }
factory { MainActivity() }
factory { SearchActivity(get(), get()) }
fragment { BrowseFragment() } fragment { BrowseFragment() }
fragment { LandscapeQueueFragment() } fragment { LandscapeQueueFragment() }
@ -115,7 +114,7 @@ class Otter : Application() {
single { ArtistsRepository(get(), get()) } single { ArtistsRepository(get(), get()) }
factory { (id: Int) -> ArtistTracksRepository(get(), get(), id) } factory { (id: Int) -> ArtistTracksRepository(get(), get(), id) }
viewModel { ArtistsViewModel(get()) } viewModel { ArtistsViewModel(get(), get()) }
factory { (context: Context?, listener: ArtistsFragment.OnArtistClickListener) -> ArtistsAdapter(context, listener) } factory { (context: Context?, listener: ArtistsFragment.OnArtistClickListener) -> ArtistsAdapter(context, listener) }
factory { (id: Int?) -> AlbumsRepository(get(), get(), id) } factory { (id: Int?) -> AlbumsRepository(get(), get(), id) }
@ -145,6 +144,13 @@ class Otter : Application() {
single { (scope: CoroutineScope) -> QueueRepository(get(), scope) } single { (scope: CoroutineScope) -> QueueRepository(get(), scope) }
viewModel { QueueViewModel(get(), get()) } viewModel { QueueViewModel(get(), get()) }
viewModel { SearchViewModel(get(), get(), get()) }
single { ArtistsSearchRepository(get(), get()) }
single { AlbumsSearchRepository(get(), get { parametersOf(null) }) }
single { TracksSearchRepository(get(), get { parametersOf(null) }) }
single { Mediator(get(), get(), get()) }
}) })
} }

View File

@ -4,40 +4,30 @@ import android.os.Bundle
import android.view.View import android.view.View
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.observe
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.SearchAdapter import com.github.apognu.otter.adapters.SearchAdapter
import com.github.apognu.otter.fragments.AlbumsFragment import com.github.apognu.otter.fragments.AlbumsFragment
import com.github.apognu.otter.fragments.ArtistsFragment import com.github.apognu.otter.fragments.ArtistsFragment
import com.github.apognu.otter.models.dao.OtterDatabase import com.github.apognu.otter.models.dao.OtterDatabase
import com.github.apognu.otter.models.dao.toDao
import com.github.apognu.otter.models.domain.Album import com.github.apognu.otter.models.domain.Album
import com.github.apognu.otter.models.domain.Artist import com.github.apognu.otter.models.domain.Artist
import com.github.apognu.otter.models.domain.Track
import com.github.apognu.otter.repositories.AlbumsSearchRepository
import com.github.apognu.otter.repositories.ArtistsSearchRepository
import com.github.apognu.otter.repositories.FavoritesRepository import com.github.apognu.otter.repositories.FavoritesRepository
import com.github.apognu.otter.repositories.TracksSearchRepository import com.github.apognu.otter.viewmodels.SearchViewModel
import com.github.apognu.otter.utils.Event
import com.github.apognu.otter.utils.EventBus
import com.github.apognu.otter.utils.untilNetwork
import com.google.android.exoplayer2.offline.Download
import kotlinx.android.synthetic.main.activity_search.* import kotlinx.android.synthetic.main.activity_search.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import java.net.URLEncoder import java.net.URLEncoder
import java.util.*
class SearchActivity(private val database: OtterDatabase, private val favoritesRepository: FavoritesRepository) : AppCompatActivity() { class SearchActivity : AppCompatActivity() {
private val viewModel by viewModel<SearchViewModel>()
private val favoritesRepository by inject<FavoritesRepository>()
private lateinit var adapter: SearchAdapter private lateinit var adapter: SearchAdapter
lateinit var artistsRepository: ArtistsSearchRepository
lateinit var albumsRepository: AlbumsSearchRepository
lateinit var tracksRepository: TracksSearchRepository
var done = 0 var done = 0
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -51,23 +41,41 @@ class SearchActivity(private val database: OtterDatabase, private val favoritesR
} }
search.requestFocus() search.requestFocus()
viewModel.artists.observe(this) { artists ->
if (adapter.artists.size != artists.size) done++
adapter.artists = artists.toMutableSet()
lifecycleScope.launch(Main) {
refresh()
}
}
viewModel.albums.observe(this) { albums ->
if (adapter.albums.size != albums.size) done++
adapter.albums = albums.toMutableSet()
lifecycleScope.launch(Main) {
refresh()
}
}
viewModel.tracks.observe(this) { tracks ->
if (adapter.tracks.size != tracks.size) done++
adapter.tracks = tracks.toMutableSet()
lifecycleScope.launch(Main) {
refresh()
}
}
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
lifecycleScope.launch(IO) {
EventBus.get().collect { message ->
when (message) {
is Event.DownloadChanged -> refreshDownloadedTrack(message.download)
}
}
}
artistsRepository = ArtistsSearchRepository(this@SearchActivity, "")
albumsRepository = AlbumsSearchRepository(this@SearchActivity, "")
tracksRepository = TracksSearchRepository(this@SearchActivity, "")
search.setOnQueryTextListener(object : androidx.appcompat.widget.SearchView.OnQueryTextListener { search.setOnQueryTextListener(object : androidx.appcompat.widget.SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(rawQuery: String?): Boolean { override fun onQueryTextSubmit(rawQuery: String?): Boolean {
search.clearFocus() search.clearFocus()
@ -75,68 +83,18 @@ class SearchActivity(private val database: OtterDatabase, private val favoritesR
rawQuery?.let { rawQuery?.let {
done = 0 done = 0
val query = URLEncoder.encode(it, "UTF-8")
artistsRepository.query = query.toLowerCase(Locale.ROOT)
albumsRepository.query = query.toLowerCase(Locale.ROOT)
tracksRepository.query = query.toLowerCase(Locale.ROOT)
search_spinner.visibility = View.VISIBLE
search_empty.visibility = View.GONE
search_no_results.visibility = View.GONE
adapter.artists.clear() adapter.artists.clear()
adapter.albums.clear() adapter.albums.clear()
adapter.tracks.clear() adapter.tracks.clear()
adapter.notifyDataSetChanged() adapter.notifyDataSetChanged()
artistsRepository.fetch().untilNetwork(lifecycleScope, IO) { artists, _, _ -> val query = URLEncoder.encode(it, "UTF-8")
done++
artists.forEach { viewModel.search(query)
database.artists().run {
insert(it.toDao())
adapter.artists.add(Artist.fromDecoratedEntity(getDecoratedBlocking(it.id))) search_spinner.visibility = View.VISIBLE
} search_empty.visibility = View.GONE
} search_no_results.visibility = View.GONE
lifecycleScope.launch(Main) {
refresh()
}
}
albumsRepository.fetch().untilNetwork(lifecycleScope, IO) { albums, _, _ ->
done++
albums.forEach {
database.albums().run {
insert(it.toDao())
adapter.albums.add(Album.fromDecoratedEntity(getDecoratedBlocking(it.id)))
}
}
lifecycleScope.launch(Main) {
refresh()
}
}
tracksRepository.fetch().untilNetwork(lifecycleScope, IO) { tracks, _, _ ->
done++
tracks.forEach {
database.tracks().run {
insertWithAssocs(database.artists(), database.albums(), database.uploads(), it)
adapter.tracks.add(Track.fromDecoratedEntity(getDecoratedBlocking(it.id)))
}
}
lifecycleScope.launch(Main) {
refresh()
}
}
} }
return true return true
@ -160,19 +118,6 @@ class SearchActivity(private val database: OtterDatabase, private val favoritesR
} }
} }
private suspend fun refreshDownloadedTrack(download: Download) {
if (download.state == Download.STATE_COMPLETED) {
/* download.getMetadata()?.let { info ->
adapter.tracks.withIndex().associate { it.value to it.index }.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match ->
withContext(Dispatchers.Main) {
adapter.tracks[match.second].downloaded = true
adapter.notifyItemChanged(adapter.getPositionOf(SearchAdapter.ResultType.Track, match.second))
}
}
} */
}
}
inner class SearchResultClickListener : SearchAdapter.OnSearchResultClickListener { inner class SearchResultClickListener : SearchAdapter.OnSearchResultClickListener {
override fun onArtistClick(holder: View?, artist: Artist) { override fun onArtistClick(holder: View?, artist: Artist) {
ArtistsFragment.openAlbums(this@SearchActivity, artist) ArtistsFragment.openAlbums(this@SearchActivity, artist)

View File

@ -4,24 +4,30 @@ import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
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.OtterAdapter import com.github.apognu.otter.models.domain.Artist
import com.github.apognu.otter.utils.maybeLoad import com.github.apognu.otter.utils.maybeLoad
import com.github.apognu.otter.utils.maybeNormalizeUrl import com.github.apognu.otter.utils.maybeNormalizeUrl
import com.github.apognu.otter.models.domain.Artist
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_artist.view.* import kotlinx.android.synthetic.main.row_artist.view.*
class ArtistsAdapter(val context: Context?, private val listener: OnArtistClickListener) : OtterAdapter<Artist, ArtistsAdapter.ViewHolder>() { val DIFF = object : DiffUtil.ItemCallback<Artist>() {
override fun areContentsTheSame(oldItem: Artist, newItem: Artist) = oldItem == newItem
override fun areItemsTheSame(oldItem: Artist, newItem: Artist) = oldItem.id == newItem.id
}
class ArtistsAdapter(val context: Context?, private val listener: OnArtistClickListener) : PagingDataAdapter<Artist, ArtistsAdapter.ViewHolder>(DIFF) {
interface OnArtistClickListener { interface OnArtistClickListener {
fun onClick(holder: View?, artist: Artist) fun onClick(holder: View?, artist: Artist)
} }
override fun getItemCount() = data.size // override fun getItemCount() = data.size
override fun getItemId(position: Int) = data[position].id.toLong() // override fun getItemId(position: Int) = data[position].id.toLong()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(context).inflate(R.layout.row_artist, parent, false) val view = LayoutInflater.from(context).inflate(R.layout.row_artist, parent, false)
@ -32,7 +38,7 @@ class ArtistsAdapter(val context: Context?, private val listener: OnArtistClickL
} }
override fun onBindViewHolder(holder: ViewHolder, position: Int) { override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val artist = data[position] val artist = getItem(position)!!
Picasso.get() Picasso.get()
.maybeLoad(maybeNormalizeUrl(artist.album_cover)) .maybeLoad(maybeNormalizeUrl(artist.album_cover))
@ -50,7 +56,7 @@ class ArtistsAdapter(val context: Context?, private val listener: OnArtistClickL
val albums = view.albums val albums = view.albums
override fun onClick(view: View?) { override fun onClick(view: View?) {
data[layoutPosition].let { artist -> getItem(layoutPosition)?.let { artist ->
listener.onClick(view, artist) listener.onClick(view, artist)
} }
} }

View File

@ -8,6 +8,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R import com.github.apognu.otter.R
import com.github.apognu.otter.models.dao.PlaylistEntity import com.github.apognu.otter.models.dao.PlaylistEntity
import com.github.apognu.otter.fragments.OtterAdapter import com.github.apognu.otter.fragments.OtterAdapter
import com.github.apognu.otter.utils.maybeNormalizeUrl
import com.github.apognu.otter.utils.toDurationString import com.github.apognu.otter.utils.toDurationString
import com.squareup.picasso.Picasso import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
@ -54,7 +55,7 @@ class PlaylistsAdapter(val context: Context?, private val listener: OnPlaylistCl
} }
Picasso.get() Picasso.get()
.load(url) .load(maybeNormalizeUrl(url))
.transform(RoundedCornersTransformation(32, 0, corner)) .transform(RoundedCornersTransformation(32, 0, corner))
.into(imageView) .into(imageView)
} }

View File

@ -13,7 +13,6 @@ import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
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.models.api.FunkwhaleTrack
import com.github.apognu.otter.utils.* import com.github.apognu.otter.utils.*
import com.github.apognu.otter.models.domain.Album import com.github.apognu.otter.models.domain.Album
import com.github.apognu.otter.models.domain.Artist import com.github.apognu.otter.models.domain.Artist
@ -41,11 +40,9 @@ class SearchAdapter(private val context: Context?, private val listener: OnSearc
val SECTION_COUNT = 3 val SECTION_COUNT = 3
var artists: MutableList<Artist> = mutableListOf() var artists: MutableSet<Artist> = mutableSetOf()
var albums: MutableList<Album> = mutableListOf() var albums: MutableSet<Album> = mutableSetOf()
var tracks: MutableList<Track> = mutableListOf() var tracks: MutableSet<Track> = mutableSetOf()
var currentTrack: FunkwhaleTrack? = null
override fun getItemCount() = SECTION_COUNT + artists.size + albums.size + tracks.size override fun getItemCount() = SECTION_COUNT + artists.size + albums.size + tracks.size
@ -57,9 +54,9 @@ class SearchAdapter(private val context: Context?, private val listener: OnSearc
return -3 return -3
} }
ResultType.Artist.ordinal -> artists[position].id.toLong() ResultType.Artist.ordinal -> artists.elementAt(position).id.toLong()
ResultType.Artist.ordinal -> albums[position - artists.size - 2].id.toLong() ResultType.Artist.ordinal -> albums.elementAt(position - artists.size - 2).id.toLong()
ResultType.Track.ordinal -> tracks[position - artists.size - albums.size - SECTION_COUNT].id.toLong() ResultType.Track.ordinal -> tracks.elementAt(position - artists.size - albums.size - SECTION_COUNT).id.toLong()
else -> 0 else -> 0
} }
} }
@ -134,19 +131,19 @@ class SearchAdapter(private val context: Context?, private val listener: OnSearc
holder.actions.visibility = View.GONE holder.actions.visibility = View.GONE
holder.favorite.visibility = View.GONE holder.favorite.visibility = View.GONE
artists[position - 1] artists.elementAt(position - 1)
} }
ResultType.Album.ordinal -> { ResultType.Album.ordinal -> {
holder.actions.visibility = View.GONE holder.actions.visibility = View.GONE
holder.favorite.visibility = View.GONE holder.favorite.visibility = View.GONE
albums[position - artists.size - 2] albums.elementAt(position - artists.size - 2)
} }
ResultType.Track.ordinal -> tracks[position - artists.size - albums.size - SECTION_COUNT] ResultType.Track.ordinal -> tracks.elementAt(position - artists.size - albums.size - SECTION_COUNT)
else -> tracks[position] else -> tracks.elementAt(position)
} }
Picasso.get() Picasso.get()
@ -173,10 +170,11 @@ class SearchAdapter(private val context: Context?, private val listener: OnSearc
if (resultType == ResultType.Track.ordinal) { if (resultType == ResultType.Track.ordinal) {
(item as? Track)?.let { track -> (item as? Track)?.let { track ->
context?.let { context -> context?.let { context ->
/* if (track == currentTrack || track.current) { holder.itemView.background = context.getDrawable(R.drawable.ripple)
holder.title.setTypeface(holder.title.typeface, Typeface.BOLD)
holder.artist.setTypeface(holder.artist.typeface, Typeface.BOLD) if (track.current) {
} */ holder.itemView.background = context.getDrawable(R.drawable.current)
}
when (track.favorite) { when (track.favorite) {
true -> holder.favorite.setColorFilter(context.getColor(R.color.colorFavorite)) true -> holder.favorite.setColorFilter(context.getColor(R.color.colorFavorite))
@ -250,19 +248,19 @@ class SearchAdapter(private val context: Context?, private val listener: OnSearc
ResultType.Artist.ordinal -> { ResultType.Artist.ordinal -> {
val position = layoutPosition - 1 val position = layoutPosition - 1
listener?.onArtistClick(view, artists[position]) listener?.onArtistClick(view, artists.elementAt(position))
} }
ResultType.Album.ordinal -> { ResultType.Album.ordinal -> {
val position = layoutPosition - artists.size - 2 val position = layoutPosition - artists.size - 2
listener?.onAlbumClick(view, albums[position]) listener?.onAlbumClick(view, albums.elementAt(position))
} }
ResultType.Track.ordinal -> { ResultType.Track.ordinal -> {
val position = layoutPosition - artists.size - albums.size - SECTION_COUNT val position = layoutPosition - artists.size - albums.size - SECTION_COUNT
tracks.subList(position, tracks.size).plus(tracks.subList(0, position)).apply { tracks.toList().subList(position, tracks.size).plus(tracks.toList().subList(0, position)).apply {
CommandBus.send(Command.ReplaceQueue(this)) CommandBus.send(Command.ReplaceQueue(this))
context.toast("All tracks were added to your queue") context.toast("All tracks were added to your queue")

View File

@ -32,7 +32,7 @@ import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
class AlbumsFragment : LiveOtterFragment<FunkwhaleAlbum, Album, AlbumsAdapter>() { class AlbumsFragment : OtterFragment<FunkwhaleAlbum, Album, AlbumsAdapter>() {
override val repository by inject<AlbumsRepository> { parametersOf(null) } override val repository by inject<AlbumsRepository> { parametersOf(null) }
override val adapter by inject<AlbumsAdapter> { parametersOf(context, OnAlbumClickListener()) } override val adapter by inject<AlbumsAdapter> { parametersOf(context, OnAlbumClickListener()) }
override val viewModel by viewModel<AlbumsViewModel> { parametersOf(artistId) } override val viewModel by viewModel<AlbumsViewModel> { parametersOf(artistId) }

View File

@ -19,7 +19,7 @@ import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
class AlbumsGridFragment : LiveOtterFragment<FunkwhaleAlbum, Album, AlbumsGridAdapter>() { class AlbumsGridFragment : OtterFragment<FunkwhaleAlbum, Album, AlbumsGridAdapter>() {
override val repository by inject<AlbumsRepository> { parametersOf(null) } override val repository by inject<AlbumsRepository> { parametersOf(null) }
override val adapter by inject<AlbumsGridAdapter> { parametersOf(context, OnAlbumClickListener()) } override val adapter by inject<AlbumsGridAdapter> { parametersOf(context, OnAlbumClickListener()) }
override val viewModel by viewModel<AlbumsViewModel> { parametersOf(null) } override val viewModel by viewModel<AlbumsViewModel> { parametersOf(null) }
@ -30,6 +30,8 @@ class AlbumsGridFragment : LiveOtterFragment<FunkwhaleAlbum, Album, AlbumsGridAd
override val layoutManager get() = GridLayoutManager(context, 3) override val layoutManager get() = GridLayoutManager(context, 3)
override val alwaysRefresh = false override val alwaysRefresh = false
override val OFFSCREEN_PAGES = 5
inner class OnAlbumClickListener : AlbumsGridAdapter.OnAlbumClickListener { inner class OnAlbumClickListener : AlbumsGridAdapter.OnAlbumClickListener {
override fun onClick(view: View?, album: Album) { override fun onClick(view: View?, album: Album) {
(context as? MainActivity)?.let { activity -> (context as? MainActivity)?.let { activity ->

View File

@ -28,12 +28,12 @@ import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
class ArtistsFragment : LiveOtterFragment<FunkwhaleArtist, Artist, ArtistsAdapter>() { class ArtistsFragment : PagedOtterFragment<FunkwhaleArtist, Artist, ArtistsAdapter>() {
override val repository by inject<ArtistsRepository>() override val repository by inject<ArtistsRepository>()
override val adapter by inject<ArtistsAdapter> { parametersOf(context, OnArtistClickListener()) } override val adapter by inject<ArtistsAdapter> { parametersOf(context, OnArtistClickListener()) }
override val viewModel by viewModel<ArtistsViewModel>() override val viewModel by viewModel<ArtistsViewModel>()
override val liveData by lazy { viewModel.artists } override val liveData by lazy { viewModel.artistsPaged }
override val viewRes = R.layout.fragment_artists override val viewRes = R.layout.fragment_artists
override val recycler: RecyclerView get() = artists override val recycler: RecyclerView get() = artists
@ -77,21 +77,6 @@ class ArtistsFragment : LiveOtterFragment<FunkwhaleArtist, Artist, ArtistsAdapte
return inflater.inflate(R.layout.fragment_artists, container, false) return inflater.inflate(R.layout.fragment_artists, container, false)
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
artists.layoutManager = LinearLayoutManager(context)
(artists.itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false
artists.adapter = adapter
liveData.observe(viewLifecycleOwner) { result ->
adapter.data.size.let { position ->
adapter.data = result.toMutableList()
adapter.notifyItemInserted(position)
}
}
}
inner class OnArtistClickListener : ArtistsAdapter.OnArtistClickListener { inner class OnArtistClickListener : ArtistsAdapter.OnArtistClickListener {
override fun onClick(holder: View?, artist: Artist) { override fun onClick(holder: View?, artist: Artist) {
openAlbums(context, artist, this@ArtistsFragment, artist.album_cover) openAlbums(context, artist, this@ArtistsFragment, artist.album_cover)

View File

@ -23,7 +23,7 @@ import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
class FavoritesFragment : LiveOtterFragment<FunkwhaleTrack, Track, FavoritesAdapter>() { class FavoritesFragment : OtterFragment<FunkwhaleTrack, Track, FavoritesAdapter>() {
override val repository by inject<FavoritesRepository>() override val repository by inject<FavoritesRepository>()
override val adapter by inject<FavoritesAdapter> { parametersOf(context, FavoriteListener()) } override val adapter by inject<FavoritesAdapter> { parametersOf(context, FavoriteListener()) }
override val viewModel by viewModel<FavoritesViewModel>() override val viewModel by viewModel<FavoritesViewModel>()

View File

@ -9,6 +9,8 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.observe import androidx.lifecycle.observe
import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator import androidx.recyclerview.widget.SimpleItemAnimator
@ -16,14 +18,14 @@ 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.Event import com.github.apognu.otter.utils.Event
import com.github.apognu.otter.utils.EventBus import com.github.apognu.otter.utils.EventBus
import com.github.apognu.otter.utils.log
import com.github.apognu.otter.utils.untilNetwork import com.github.apognu.otter.utils.untilNetwork
import kotlinx.android.synthetic.main.fragment_artists.* import kotlinx.android.synthetic.main.fragment_artists.*
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import org.koin.ext.scope
import kotlinx.coroutines.withContext
abstract class OtterAdapter<D, VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() { abstract class OtterAdapter<D, VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() {
var data: MutableList<D> = mutableListOf() var data: MutableList<D> = mutableListOf()
@ -35,10 +37,8 @@ abstract class OtterAdapter<D, VH : RecyclerView.ViewHolder> : RecyclerView.Adap
abstract override fun getItemId(position: Int): Long abstract override fun getItemId(position: Int): Long
} }
abstract class LiveOtterFragment<DAO : Any, D : Any, A : OtterAdapter<D, *>> : Fragment() { abstract class OtterFragment<DAO : Any, D : Any, A : OtterAdapter<D, *>> : Fragment() {
companion object { open val OFFSCREEN_PAGES = 10
const val OFFSCREEN_PAGES = 20
}
abstract val repository: Repository<DAO> abstract val repository: Repository<DAO>
abstract val adapter: A abstract val adapter: A
@ -152,3 +152,82 @@ abstract class LiveOtterFragment<DAO : Any, D : Any, A : OtterAdapter<D, *>> : F
return false return false
} }
} }
abstract class PagedOtterFragment<DAO : Any, D : Any, A : PagingDataAdapter<D, *>> : Fragment() {
open val OFFSCREEN_PAGES = 10
abstract val repository: Repository<DAO>
abstract val adapter: A
open val viewModel: ViewModel? = null
abstract val liveData: LiveData<PagingData<D>>
abstract val viewRes: Int
abstract val recycler: RecyclerView
open val layoutManager: RecyclerView.LayoutManager get() = LinearLayoutManager(context)
open val alwaysRefresh = true
private var moreLoading = false
private var listener: Job? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(viewRes, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
liveData.observe(viewLifecycleOwner) {
viewLifecycleOwner.lifecycleScope.launch(IO) {
adapter.submitData(it)
}
}
recycler.layoutManager = layoutManager
recycler.adapter = adapter
if (listener == null) {
listener = lifecycleScope.launch(IO) {
EventBus.get().collect { event ->
if (event is Event.ListingsChanged) {
withContext(Main) {
swiper?.isRefreshing = true
fetch()
}
}
}
}
}
}
open fun onDataFetched(data: List<DAO>) {}
private fun fetch(size: Int = 0) {
moreLoading = true
repository.fetch(size).untilNetwork(lifecycleScope, IO) { data, _, hasMore ->
lifecycleScope.launch(Main) {
onDataFetched(data)
(repository.upstream as? HttpUpstream<*>)?.let { upstream ->
when (upstream.behavior) {
HttpUpstream.Behavior.Progressive -> if (!hasMore || !moreLoading) swiper?.isRefreshing = false
HttpUpstream.Behavior.AtOnce -> if (!hasMore) swiper?.isRefreshing = false
HttpUpstream.Behavior.Single -> if (!hasMore) swiper?.isRefreshing = false
}
}
}
}
}
private fun needsMoreOffscreenPages(): Boolean {
view?.let {
val offset = recycler.computeVerticalScrollOffset()
val left = recycler.computeVerticalScrollRange() - recycler.height - offset
return left < (recycler.height * OFFSCREEN_PAGES)
}
return false
}
}

View File

@ -25,7 +25,7 @@ import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
class PlaylistTracksFragment : LiveOtterFragment<FunkwhalePlaylistTrack, Track, PlaylistTracksAdapter>() { class PlaylistTracksFragment : OtterFragment<FunkwhalePlaylistTrack, Track, PlaylistTracksAdapter>() {
private val favoritesRepository by inject<FavoritesRepository>() private val favoritesRepository by inject<FavoritesRepository>()
override val repository by inject<PlaylistTracksRepository> { parametersOf(playlistId) } override val repository by inject<PlaylistTracksRepository> { parametersOf(playlistId) }
override val adapter by inject<PlaylistTracksAdapter> { parametersOf(context, FavoriteListener()) } override val adapter by inject<PlaylistTracksAdapter> { parametersOf(context, FavoriteListener()) }

View File

@ -19,7 +19,7 @@ import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
class PlaylistsFragment : LiveOtterFragment<FunkwhalePlaylist, PlaylistEntity, PlaylistsAdapter>() { class PlaylistsFragment : OtterFragment<FunkwhalePlaylist, PlaylistEntity, PlaylistsAdapter>() {
override val repository by inject<PlaylistsRepository>() override val repository by inject<PlaylistsRepository>()
override val adapter by inject<PlaylistsAdapter> { parametersOf(context, OnPlaylistClickListener()) } override val adapter by inject<PlaylistsAdapter> { parametersOf(context, OnPlaylistClickListener()) }
override val viewModel by viewModel<PlaylistsViewModel>() override val viewModel by viewModel<PlaylistsViewModel>()

View File

@ -8,7 +8,10 @@ import com.github.apognu.otter.adapters.RadiosAdapter
import com.github.apognu.otter.models.api.FunkwhaleRadio import com.github.apognu.otter.models.api.FunkwhaleRadio
import com.github.apognu.otter.models.dao.RadioEntity import com.github.apognu.otter.models.dao.RadioEntity
import com.github.apognu.otter.repositories.RadiosRepository import com.github.apognu.otter.repositories.RadiosRepository
import com.github.apognu.otter.utils.* import com.github.apognu.otter.utils.Command
import com.github.apognu.otter.utils.CommandBus
import com.github.apognu.otter.utils.Event
import com.github.apognu.otter.utils.EventBus
import com.github.apognu.otter.viewmodels.RadiosViewModel import com.github.apognu.otter.viewmodels.RadiosViewModel
import kotlinx.android.synthetic.main.fragment_radios.* import kotlinx.android.synthetic.main.fragment_radios.*
import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Dispatchers.Main
@ -17,7 +20,7 @@ import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
class RadiosFragment : LiveOtterFragment<FunkwhaleRadio, RadioEntity, RadiosAdapter>() { class RadiosFragment : OtterFragment<FunkwhaleRadio, RadioEntity, RadiosAdapter>() {
override val repository by inject<RadiosRepository>() override val repository by inject<RadiosRepository>()
override val adapter by inject<RadiosAdapter> { parametersOf(context, lifecycleScope, RadioClickListener()) } override val adapter by inject<RadiosAdapter> { parametersOf(context, lifecycleScope, RadioClickListener()) }
override val viewModel by inject<RadiosViewModel>() override val viewModel by inject<RadiosViewModel>()

View File

@ -3,36 +3,32 @@ package com.github.apognu.otter.fragments
import android.os.Bundle import android.os.Bundle
import android.view.Gravity import android.view.Gravity
import android.view.View import android.view.View
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.lifecycle.lifecycleScope
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.adapters.TracksAdapter import com.github.apognu.otter.adapters.TracksAdapter
import com.github.apognu.otter.models.api.FunkwhaleTrack import com.github.apognu.otter.models.api.FunkwhaleTrack
import com.github.apognu.otter.models.domain.Album import com.github.apognu.otter.models.domain.Album
import com.github.apognu.otter.models.domain.Track import com.github.apognu.otter.models.domain.Track
import com.github.apognu.otter.repositories.FavoritedRepository
import com.github.apognu.otter.repositories.FavoritesRepository import com.github.apognu.otter.repositories.FavoritesRepository
import com.github.apognu.otter.repositories.TracksRepository import com.github.apognu.otter.repositories.TracksRepository
import com.github.apognu.otter.utils.* import com.github.apognu.otter.utils.Command
import com.github.apognu.otter.utils.CommandBus
import com.github.apognu.otter.utils.maybeLoad
import com.github.apognu.otter.utils.toast
import com.github.apognu.otter.viewmodels.TracksViewModel import com.github.apognu.otter.viewmodels.TracksViewModel
import com.google.android.exoplayer2.offline.Download
import com.preference.PowerPreference import com.preference.PowerPreference
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.fragment_tracks.* import kotlinx.android.synthetic.main.fragment_tracks.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
class TracksFragment : LiveOtterFragment<FunkwhaleTrack, Track, TracksAdapter>() { class TracksFragment : OtterFragment<FunkwhaleTrack, Track, TracksAdapter>() {
private val favoritesRepository by inject<FavoritesRepository>()
override val repository by inject<TracksRepository> { parametersOf(albumId) } override val repository by inject<TracksRepository> { parametersOf(albumId) }
override val adapter by inject<TracksAdapter> { parametersOf(context, FavoriteListener()) } override val adapter by inject<TracksAdapter> { parametersOf(context, FavoriteListener()) }
override val viewModel by viewModel<TracksViewModel> { parametersOf(albumId) } override val viewModel by viewModel<TracksViewModel> { parametersOf(albumId) }
@ -41,8 +37,6 @@ class TracksFragment : LiveOtterFragment<FunkwhaleTrack, Track, TracksAdapter>()
override val viewRes = R.layout.fragment_tracks override val viewRes = R.layout.fragment_tracks
override val recycler: RecyclerView get() = tracks override val recycler: RecyclerView get() = tracks
private val favoritesRepository by inject<FavoritesRepository>()
private var albumId = 0 private var albumId = 0
companion object { companion object {
@ -59,8 +53,6 @@ class TracksFragment : LiveOtterFragment<FunkwhaleTrack, Track, TracksAdapter>()
arguments?.apply { arguments?.apply {
albumId = getInt("albumId") albumId = getInt("albumId")
} }
watchEventBus()
} }
override fun onResume() { override fun onResume() {
@ -149,42 +141,6 @@ class TracksFragment : LiveOtterFragment<FunkwhaleTrack, Track, TracksAdapter>()
} }
} }
private fun watchEventBus() {
lifecycleScope.launch(IO) {
EventBus.get().collect { message ->
when (message) {
is Event.DownloadChanged -> refreshDownloadedTrack(message.download)
}
}
}
}
private suspend fun refreshDownloadedTracks() {
val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
withContext(Main) {
/* adapter.data = adapter.data.map {
it.downloaded = downloaded.contains(it.id)
it
}.toMutableList() */
adapter.notifyDataSetChanged()
}
}
private suspend fun refreshDownloadedTrack(download: Download) {
if (download.state == Download.STATE_COMPLETED) {
download.getMetadata()?.let { info ->
adapter.data.withIndex().associate { it.value to it.index }.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match ->
/* withContext(Main) {
adapter.data[match.second].downloaded = true
adapter.notifyItemChanged(match.second)
} */
}
}
}
}
inner class FavoriteListener : TracksAdapter.OnFavoriteListener { inner class FavoriteListener : TracksAdapter.OnFavoriteListener {
override fun onToggleFavorite(id: Int, state: Boolean) { override fun onToggleFavorite(id: Int, state: Boolean) {
when (state) { when (state) {

View File

@ -0,0 +1,58 @@
package com.github.apognu.otter.models
import android.content.Context
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import androidx.room.withTransaction
import com.github.apognu.otter.models.dao.DecoratedArtistEntity
import com.github.apognu.otter.models.dao.OtterDatabase
import com.github.apognu.otter.models.dao.toDao
import com.github.apognu.otter.models.domain.Artist
import com.github.apognu.otter.repositories.ArtistsRepository
import com.github.apognu.otter.utils.AppContext
import com.github.apognu.otter.utils.Cache
import com.github.apognu.otter.utils.log
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.take
import org.koin.core.KoinComponent
@OptIn(ExperimentalPagingApi::class)
class Mediator(private val context: Context, private val database: OtterDatabase, private val repository: ArtistsRepository) : RemoteMediator<Int, DecoratedArtistEntity>(), KoinComponent {
override suspend fun load(loadType: LoadType, state: PagingState<Int, DecoratedArtistEntity>): MediatorResult {
return try {
val key = when (loadType) {
LoadType.REFRESH -> 1
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND -> {
Cache.get(context, "key")?.readLine()?.toInt() ?: return MediatorResult.Success(endOfPaginationReached = true)
}
}
key.log("fetching page")
val response = repository.fetch((key - 1) * AppContext.PAGE_SIZE).take(1).first()
database.withTransaction {
if (loadType == LoadType.REFRESH) {
Cache.delete(context, "key")
database.artists().deleteAll()
}
Cache.set(context, "key", (key + 1).toString().toByteArray())
response.data.forEach {
database.artists().insert(it.toDao())
}
}
return MediatorResult.Success(endOfPaginationReached = !response.hasMore)
} catch (e: Exception) {
MediatorResult.Error(e)
}
}
}

View File

@ -21,4 +21,5 @@ data class FunkwhaleAlbum(
data class Covers(val urls: CoverUrls?) data class Covers(val urls: CoverUrls?)
@Serializable @Serializable
data class CoverUrls(val original: String?) data class CoverUrls(val original: String?)

View File

@ -9,6 +9,7 @@ import com.github.apognu.otter.models.api.FunkwhaleArtist
data class AlbumEntity( data class AlbumEntity(
@PrimaryKey @PrimaryKey
val id: Int, val id: Int,
@ColumnInfo(collate = ColumnInfo.UNICODE, index = true)
val title: String, val title: String,
@ForeignKey(entity = ArtistEntity::class, parentColumns = ["id"], childColumns = ["artist_id"], onDelete = ForeignKey.CASCADE) @ForeignKey(entity = ArtistEntity::class, parentColumns = ["id"], childColumns = ["artist_id"], onDelete = ForeignKey.CASCADE)
val artist_id: Int, val artist_id: Int,
@ -18,13 +19,13 @@ data class AlbumEntity(
@androidx.room.Dao @androidx.room.Dao
interface Dao { interface Dao {
@Query("SELECT * FROM DecoratedAlbumEntity") @Query("SELECT * FROM DecoratedAlbumEntity ORDER BY title")
fun allDecorated(): LiveData<List<DecoratedAlbumEntity>> fun allDecorated(): LiveData<List<DecoratedAlbumEntity>>
@Query("SELECT * FROM DecoratedAlbumEntity ORDER BY release_date") @Query("SELECT * FROM DecoratedAlbumEntity ORDER BY title")
fun allSync(): List<DecoratedAlbumEntity> fun allSync(): List<DecoratedAlbumEntity>
@Query("SELECT * FROM DecoratedAlbumEntity WHERE id IN ( :ids ) ORDER BY release_date") @Query("SELECT * FROM DecoratedAlbumEntity WHERE id IN ( :ids ) ORDER BY title")
fun findAllDecorated(ids: List<Int>): LiveData<List<DecoratedAlbumEntity>> fun findAllDecorated(ids: List<Int>): LiveData<List<DecoratedAlbumEntity>>
@Query("SELECT * FROM DecoratedAlbumEntity WHERE id == :id") @Query("SELECT * FROM DecoratedAlbumEntity WHERE id == :id")
@ -33,7 +34,7 @@ data class AlbumEntity(
@Query("SELECT * FROM DecoratedAlbumEntity WHERE id == :id") @Query("SELECT * FROM DecoratedAlbumEntity WHERE id == :id")
fun getDecoratedBlocking(id: Int): DecoratedAlbumEntity fun getDecoratedBlocking(id: Int): DecoratedAlbumEntity
@Query("SELECT * FROM DecoratedAlbumEntity WHERE artist_id = :artistId") @Query("SELECT * FROM DecoratedAlbumEntity WHERE artist_id = :artistId ORDER BY release_date")
fun forArtistDecorated(artistId: Int): LiveData<List<DecoratedAlbumEntity>> fun forArtistDecorated(artistId: Int): LiveData<List<DecoratedAlbumEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)

View File

@ -1,6 +1,7 @@
package com.github.apognu.otter.models.dao package com.github.apognu.otter.models.dao
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.paging.DataSource
import androidx.room.* import androidx.room.*
import com.github.apognu.otter.models.api.FunkwhaleArtist import com.github.apognu.otter.models.api.FunkwhaleArtist
import io.realm.RealmObject import io.realm.RealmObject
@ -10,12 +11,15 @@ import io.realm.annotations.Required
data class ArtistEntity( data class ArtistEntity(
@PrimaryKey @PrimaryKey
val id: Int, val id: Int,
@ColumnInfo(collate = ColumnInfo.LOCALIZED, index = true) @ColumnInfo(collate = ColumnInfo.UNICODE, index = true)
val name: String val name: String
) { ) {
@androidx.room.Dao @androidx.room.Dao
interface Dao { interface Dao {
@Query("SELECT * FROM DecoratedArtistEntity")
fun allPaged(): DataSource.Factory<Int, DecoratedArtistEntity>
@Query("SELECT * FROM DecoratedArtistEntity") @Query("SELECT * FROM DecoratedArtistEntity")
fun allDecorated(): LiveData<List<DecoratedArtistEntity>> fun allDecorated(): LiveData<List<DecoratedArtistEntity>>
@ -25,6 +29,9 @@ data class ArtistEntity(
@Query("SELECT * FROM DecoratedArtistEntity WHERE id == :id") @Query("SELECT * FROM DecoratedArtistEntity WHERE id == :id")
fun getDecoratedBlocking(id: Int): DecoratedArtistEntity fun getDecoratedBlocking(id: Int): DecoratedArtistEntity
@Query("SELECT * FROM DecoratedArtistEntity WHERE id IN ( :ids )")
fun findDecorated(ids: List<Int>): LiveData<List<DecoratedArtistEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(artist: ArtistEntity) fun insert(artist: ArtistEntity)
@ -34,6 +41,7 @@ data class ArtistEntity(
} }
fun FunkwhaleArtist.toDao() = run { fun FunkwhaleArtist.toDao() = run {
ArtistEntity(id, name) ArtistEntity(id, name)
} }
@ -43,7 +51,7 @@ fun FunkwhaleArtist.toDao() = run {
INNER JOIN albums INNER JOIN albums
ON albums.artist_id = artists.id ON albums.artist_id = artists.id
GROUP BY albums.artist_id GROUP BY albums.artist_id
ORDER BY name ORDER BY artists.id
""") """)
data class DecoratedArtistEntity( data class DecoratedArtistEntity(
val id: Int, val id: Int,

View File

@ -8,6 +8,7 @@ import com.github.apognu.otter.models.api.FunkwhalePlaylist
data class PlaylistEntity( data class PlaylistEntity(
@PrimaryKey @PrimaryKey
val id: Int, val id: Int,
@ColumnInfo(collate = ColumnInfo.UNICODE, index = true)
val name: String, val name: String,
val album_covers: List<String>, val album_covers: List<String>,
val tracks_count: Int, val tracks_count: Int,

View File

@ -9,6 +9,7 @@ data class RadioEntity(
@PrimaryKey @PrimaryKey
val id: Int, val id: Int,
var radio_type: String?, var radio_type: String?,
@ColumnInfo(collate = ColumnInfo.UNICODE, index = true)
val name: String, val name: String,
val description: String, val description: String,
var related_object_id: String? = null var related_object_id: String? = null

View File

@ -3,19 +3,19 @@ package com.github.apognu.otter.models.dao
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.room.* import androidx.room.*
import androidx.room.ForeignKey.CASCADE import androidx.room.ForeignKey.CASCADE
import com.github.apognu.otter.Otter
import com.github.apognu.otter.models.api.FunkwhaleTrack import com.github.apognu.otter.models.api.FunkwhaleTrack
import org.koin.java.KoinJavaComponent.inject
@Entity(tableName = "tracks") @Entity(tableName = "tracks")
data class TrackEntity( data class TrackEntity(
@PrimaryKey @PrimaryKey
val id: Int, val id: Int,
@ColumnInfo(collate = ColumnInfo.UNICODE, index = true)
val title: String, val title: String,
@ForeignKey(entity = ArtistEntity::class, parentColumns = ["id"], childColumns = ["artist_id"], onDelete = CASCADE) @ForeignKey(entity = ArtistEntity::class, parentColumns = ["id"], childColumns = ["artist_id"], onDelete = CASCADE)
val artist_id: Int, val artist_id: Int,
@ForeignKey(entity = AlbumEntity::class, parentColumns = ["id"], childColumns = ["album_id"], onDelete = CASCADE) @ForeignKey(entity = AlbumEntity::class, parentColumns = ["id"], childColumns = ["album_id"], onDelete = CASCADE)
val album_id: Int?, val album_id: Int?,
@ColumnInfo(index = true)
val position: Int?, val position: Int?,
val copyright: String?, val copyright: String?,
val license: String? val license: String?

View File

@ -25,13 +25,15 @@ class AlbumsRepository(override val context: Context, private val database: Otte
override fun onDataFetched(data: List<FunkwhaleAlbum>): List<FunkwhaleAlbum> { override fun onDataFetched(data: List<FunkwhaleAlbum>): List<FunkwhaleAlbum> {
data.forEach { data.forEach {
database.albums().insert(it.toDao()) insert(it)
} }
return super.onDataFetched(data) return super.onDataFetched(data)
} }
fun insert(album: FunkwhaleAlbum) = database.albums().insert(album.toDao())
fun all() = database.albums().allDecorated() fun all() = database.albums().allDecorated()
fun find(ids: List<Int>) = database.albums().findAllDecorated(ids)
fun ofArtist(id: Int): LiveData<List<DecoratedAlbumEntity>> { fun ofArtist(id: Int): LiveData<List<DecoratedAlbumEntity>> {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {

View File

@ -14,7 +14,7 @@ import kotlinx.coroutines.launch
class ArtistsRepository(override val context: Context, private val database: OtterDatabase) : Repository<FunkwhaleArtist>() { class ArtistsRepository(override val context: Context, private val database: OtterDatabase) : Repository<FunkwhaleArtist>() {
override val upstream = override val upstream =
HttpUpstream(HttpUpstream.Behavior.Progressive, "/api/v1/artists/?playable=true&ordering=name", FunkwhaleArtist.serializer()) HttpUpstream(HttpUpstream.Behavior.Progressive, "/api/v1/artists/?playable=true&ordering=id", FunkwhaleArtist.serializer())
override fun onDataFetched(data: List<FunkwhaleArtist>): List<FunkwhaleArtist> { override fun onDataFetched(data: List<FunkwhaleArtist>): List<FunkwhaleArtist> {
scope.launch(IO) { scope.launch(IO) {
@ -34,6 +34,10 @@ class ArtistsRepository(override val context: Context, private val database: Ott
return super.onDataFetched(data) return super.onDataFetched(data)
} }
fun insert(artist: FunkwhaleArtist) = database.artists().insert(artist.toDao())
fun allPaged() = database.artists().allPaged()
fun all(): LiveData<List<DecoratedArtistEntity>> { fun all(): LiveData<List<DecoratedArtistEntity>> {
scope.launch(IO) { scope.launch(IO) {
fetch().collect() fetch().collect()
@ -41,5 +45,7 @@ class ArtistsRepository(override val context: Context, private val database: Ott
return database.artists().allDecorated() return database.artists().allDecorated()
} }
fun get(id: Int) = database.artists().getDecorated(id) fun get(id: Int) = database.artists().getDecorated(id)
fun find(ids: List<Int>) = database.artists().findDecorated(ids)
} }

View File

@ -10,10 +10,7 @@ import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
import com.github.kittinunf.fuel.coroutines.awaitObjectResult import com.github.kittinunf.fuel.coroutines.awaitObjectResult
import com.github.kittinunf.result.Result import com.github.kittinunf.result.Result
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flowOn
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
import kotlin.math.ceil import kotlin.math.ceil
@ -22,8 +19,8 @@ class HttpUpstream<D : Any>(val behavior: Behavior, private val url: String, pri
Single, AtOnce, Progressive Single, AtOnce, Progressive
} }
override fun fetch(size: Int): Flow<Repository.Response<D>> = channelFlow { override fun fetch(size: Int): Flow<Repository.Response<D>> = flow {
if (behavior == Behavior.Single && size != 0) return@channelFlow if (behavior == Behavior.Single && size != 0) return@flow
val page = ceil(size / AppContext.PAGE_SIZE.toDouble()).toInt() + 1 val page = ceil(size / AppContext.PAGE_SIZE.toDouble()).toInt() + 1
@ -41,20 +38,22 @@ class HttpUpstream<D : Any>(val behavior: Behavior, private val url: String, pri
val data = response.results val data = response.results
when (behavior) { when (behavior) {
Behavior.Single -> send(Repository.Response(data, page, false)) Behavior.Single -> emit(Repository.Response(data, page, false))
Behavior.Progressive -> send(Repository.Response(data, page, response.next != null)) Behavior.Progressive -> emit(Repository.Response(data, page, response.next != null))
else -> { else -> {
send(Repository.Response(data, page, response.next != null)) emit(Repository.Response(data, page, response.next != null))
if (response.next != null) fetch(size + data.size).collect { send(it) } if (response.next != null) fetch(size + data.size).collect { emit(it) }
} }
} }
}, },
{ error -> { error ->
error.log()
when (error.exception) { when (error.exception) {
is RefreshError -> EventBus.send(Event.LogOut) is RefreshError -> EventBus.send(Event.LogOut)
else -> send(Repository.Response(listOf(), page, false)) else -> emit(Repository.Response(listOf(), page, false))
} }
} }
) )

View File

@ -1,41 +1,117 @@
package com.github.apognu.otter.repositories package com.github.apognu.otter.repositories
import android.content.Context import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.map
import com.github.apognu.otter.models.api.FunkwhaleAlbum import com.github.apognu.otter.models.api.FunkwhaleAlbum
import com.github.apognu.otter.models.api.FunkwhaleArtist import com.github.apognu.otter.models.api.FunkwhaleArtist
import com.github.apognu.otter.models.api.FunkwhaleTrack import com.github.apognu.otter.models.api.FunkwhaleTrack
import kotlinx.coroutines.runBlocking import com.github.apognu.otter.models.domain.Album
import com.github.apognu.otter.models.domain.Artist
import com.github.apognu.otter.models.domain.Track
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class TracksSearchRepository(override val context: Context?, var query: String) : Repository<FunkwhaleTrack>() { class ArtistsSearchRepository(override val context: Context?, private val repository: ArtistsRepository, var query: String = "") : Repository<FunkwhaleArtist>() {
override val upstream: Upstream<FunkwhaleTrack> override val upstream: Upstream<FunkwhaleArtist>
get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&q=$query", FunkwhaleTrack.serializer()) get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/artists/?playable=true&q=$query", FunkwhaleArtist.serializer())
override fun onDataFetched(data: List<FunkwhaleTrack>): List<FunkwhaleTrack> = runBlocking { private val ids: MutableList<Int> = mutableListOf()
/* val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
data.map { track -> private val _ids: MutableLiveData<List<Int>> = MutableLiveData()
track.favorite = favorites.contains(track.id)
track.downloaded = downloaded.contains(track.id)
track.bestUpload()?.let { upload -> val results: LiveData<List<Artist>> = Transformations.switchMap(_ids) {
val url = mustNormalizeUrl(upload.listen_url) repository.find(it).map { artists -> artists.map { artist -> Artist.fromDecoratedEntity(artist) } }
}
track.cached = Otter.get().exoCache.isCached(url, 0, upload.duration * 1000L) override fun onDataFetched(data: List<FunkwhaleArtist>): List<FunkwhaleArtist> {
} data.forEach {
repository.insert(it)
}
track ids.addAll(data.map { it.id })
} */ _ids.postValue(ids)
data return super.onDataFetched(data)
}
fun search(term: String) {
ids.clear()
_ids.postValue(listOf())
query = term
scope.launch(IO) {
fetch().collect()
}
} }
} }
class ArtistsSearchRepository(override val context: Context?, var query: String) : Repository<FunkwhaleArtist>() { class AlbumsSearchRepository(override val context: Context?, private val repository: AlbumsRepository, var query: String = "") : Repository<FunkwhaleAlbum>() {
override val upstream: Upstream<FunkwhaleArtist>
get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/artists/?playable=true&q=$query", FunkwhaleArtist.serializer())
}
class AlbumsSearchRepository(override val context: Context?, var query: String) : Repository<FunkwhaleAlbum>() {
override val upstream: Upstream<FunkwhaleAlbum> override val upstream: Upstream<FunkwhaleAlbum>
get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/albums/?playable=true&q=$query", FunkwhaleAlbum.serializer()) get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/albums/?playable=true&q=$query", FunkwhaleAlbum.serializer())
private val ids: MutableList<Int> = mutableListOf()
private val _ids: MutableLiveData<List<Int>> = MutableLiveData()
val results: LiveData<List<Album>> = Transformations.switchMap(_ids) {
repository.find(it).map { albums -> albums.map { album -> Album.fromDecoratedEntity(album) } }
}
override fun onDataFetched(data: List<FunkwhaleAlbum>): List<FunkwhaleAlbum> {
data.forEach {
repository.insert(it)
}
ids.addAll(data.map { it.id })
_ids.postValue(ids)
return super.onDataFetched(data)
}
fun search(term: String) {
ids.clear()
_ids.postValue(listOf())
query = term
scope.launch(IO) {
fetch().collect()
}
}
}
class TracksSearchRepository(override val context: Context?, private val repository: TracksRepository, var query: String = "") : Repository<FunkwhaleTrack>() {
override val upstream: Upstream<FunkwhaleTrack>
get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&q=$query", FunkwhaleTrack.serializer())
private val ids: MutableList<Int> = mutableListOf()
private val _ids: MutableLiveData<List<Int>> = MutableLiveData()
val results: LiveData<List<Track>> = Transformations.switchMap(_ids) {
repository.find(it).map { tracks -> tracks.map { track -> Track.fromDecoratedEntity(track) } }
}
override fun onDataFetched(data: List<FunkwhaleTrack>): List<FunkwhaleTrack> {
data.forEach {
repository.insert(it)
}
ids.addAll(data.map { it.id })
_ids.postValue(ids)
return super.onDataFetched(data)
}
fun search(term: String) {
ids.clear()
_ids.postValue(listOf())
query = term
scope.launch(IO) {
fetch().collect()
}
}
} }

View File

@ -46,6 +46,10 @@ class TracksRepository(override val context: Context, private val database: Otte
data.sortedWith(compareBy({ it.disc_number }, { it.position })) data.sortedWith(compareBy({ it.disc_number }, { it.position }))
} }
fun insert(track: FunkwhaleTrack) {
database.tracks().insertWithAssocs(database.artists(), database.albums(), database.uploads(), track)
}
fun find(ids: List<Int>) = database.tracks().findAllDecorated(ids) fun find(ids: List<Int>) = database.tracks().findAllDecorated(ids)
suspend fun ofArtistBlocking(id: Int) = database.tracks().ofArtistBlocking(id) suspend fun ofArtistBlocking(id: Int) = database.tracks().ofArtistBlocking(id)

View File

@ -39,24 +39,6 @@ object AppContext {
fun init(context: Activity) { fun init(context: Activity) {
setupNotificationChannels(context) setupNotificationChannels(context)
// CastContext.getSharedInstance(context)
FuelManager.instance.addResponseInterceptor { next ->
{ request, response ->
if (request.method == Method.GET && response.statusCode == 200) {
var cacheId = request.url.path.toString()
request.url.query?.let {
cacheId = "$cacheId?$it"
}
Cache.set(context, cacheId, response.body().toByteArray())
}
next(request, response)
}
}
} }
@SuppressLint("NewApi") @SuppressLint("NewApi")

View File

@ -1,12 +1,29 @@
package com.github.apognu.otter.viewmodels package com.github.apognu.otter.viewmodels
import androidx.lifecycle.LiveData import androidx.lifecycle.*
import androidx.lifecycle.ViewModel import androidx.paging.Pager
import androidx.lifecycle.map import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import androidx.paging.map
import com.github.apognu.otter.models.Mediator
import com.github.apognu.otter.models.domain.Artist import com.github.apognu.otter.models.domain.Artist
import com.github.apognu.otter.repositories.ArtistsRepository import com.github.apognu.otter.repositories.ArtistsRepository
import com.github.apognu.otter.utils.AppContext
import kotlinx.coroutines.flow.map
class ArtistsViewModel(repository: ArtistsRepository, mediator: Mediator) : ViewModel() {
private val pager = Pager(
config = PagingConfig(pageSize = AppContext.PAGE_SIZE, initialLoadSize = AppContext.PAGE_SIZE * 5, prefetchDistance = 10 * AppContext.PAGE_SIZE, maxSize = 25 * AppContext.PAGE_SIZE, enablePlaceholders = false),
pagingSourceFactory = repository.allPaged().asPagingSourceFactory(),
remoteMediator = mediator
)
val artistsPaged = pager
.flow
.map { artists -> artists.map { Artist.fromDecoratedEntity(it) } }
.cachedIn(viewModelScope)
.asLiveData()
class ArtistsViewModel(private val repository: ArtistsRepository) : ViewModel() {
val artists: LiveData<List<Artist>> = repository.all().map { artists -> val artists: LiveData<List<Artist>> = repository.all().map { artists ->
artists.map { Artist.fromDecoratedEntity(it) } artists.map { Artist.fromDecoratedEntity(it) }
} }

View File

@ -0,0 +1,18 @@
package com.github.apognu.otter.viewmodels
import androidx.lifecycle.ViewModel
import com.github.apognu.otter.repositories.AlbumsSearchRepository
import com.github.apognu.otter.repositories.ArtistsSearchRepository
import com.github.apognu.otter.repositories.TracksSearchRepository
class SearchViewModel(private val artistsRepository: ArtistsSearchRepository, private val albumsRepository: AlbumsSearchRepository, private val tracksRepository: TracksSearchRepository) : ViewModel() {
val artists = artistsRepository.results
val albums = albumsRepository.results
val tracks = tracksRepository.results
fun search(term: String) {
artistsRepository.search(term)
albumsRepository.search(term)
tracksRepository.search(term)
}
}