Put buggy features behind an experiments gate (favorites, for now). Optimized layouts to be able to load lots of content. Fixed Funkwhale API URLs to try and be backward compatible.

This commit is contained in:
Antoine POPINEAU 2019-10-29 23:41:44 +01:00
parent a63f3f7e68
commit 7c9a71d6d7
No known key found for this signature in database
GPG Key ID: A78AC64694F84063
31 changed files with 295 additions and 192 deletions

View File

@ -97,7 +97,7 @@ dependencies {
implementation("androidx.preference:preference:1.1.0") implementation("androidx.preference:preference:1.1.0")
implementation("androidx.recyclerview:recyclerview:1.0.0") implementation("androidx.recyclerview:recyclerview:1.0.0")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.0.0") implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.0.0")
implementation("com.google.android.material:material:1.1.0-beta01") implementation("com.google.android.material:material:1.2.0-alpha01")
implementation("com.android.support.constraint:constraint-layout:1.1.3") implementation("com.android.support.constraint:constraint-layout:1.1.3")
implementation("com.google.android.exoplayer:exoplayer:2.10.5") implementation("com.google.android.exoplayer:exoplayer:2.10.5")

View File

@ -64,7 +64,7 @@ class LoginActivity : AppCompatActivity() {
GlobalScope.launch(Main) { GlobalScope.launch(Main) {
try { try {
val result = Fuel.post("$hostname/api/v1/token", body) val result = Fuel.post("$hostname/api/v1/token/", body)
.awaitObjectResult(gsonDeserializerOf(FwCredentials::class.java)) .awaitObjectResult(gsonDeserializerOf(FwCredentials::class.java))
result.fold( result.fold(

View File

@ -256,7 +256,7 @@ class MainActivity : AppCompatActivity() {
.centerCrop() .centerCrop()
.into(now_playing_details_cover) .into(now_playing_details_cover)
favoriteCheckRepository.fetch().untilNetwork(IO) { favorites -> favoriteCheckRepository.fetch().untilNetwork(IO) { favorites, _ ->
GlobalScope.launch(Main) { GlobalScope.launch(Main) {
track.favorite = favorites.contains(track.id) track.favorite = favorites.contains(track.id)

View File

@ -44,7 +44,7 @@ class SearchActivity : AppCompatActivity() {
adapter.data.clear() adapter.data.clear()
adapter.notifyDataSetChanged() adapter.notifyDataSetChanged()
repository.fetch(Repository.Origin.Network.origin).untilNetwork { tracks -> repository.fetch(Repository.Origin.Network.origin).untilNetwork { tracks, _ ->
search_spinner.visibility = View.GONE search_spinner.visibility = View.GONE
search_empty.visibility = View.GONE search_empty.visibility = View.GONE

View File

@ -48,6 +48,17 @@ class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedP
override fun onPreferenceTreeClick(preference: Preference?): Boolean { override fun onPreferenceTreeClick(preference: Preference?): Boolean {
when (preference?.key) { when (preference?.key) {
"oss_licences" -> startActivity(Intent(activity, LicencesActivity::class.java)) "oss_licences" -> startActivity(Intent(activity, LicencesActivity::class.java))
"experiments" -> {
context?.let { context ->
AlertDialog.Builder(context)
.setTitle(context.getString(R.string.settings_experiments_restart_title))
.setMessage(context.getString(R.string.settings_experiments_restart_content))
.setPositiveButton(android.R.string.yes) { _, _ -> }
.show()
}
}
"logout" -> { "logout" -> {
context?.let { context -> context?.let { context ->
AlertDialog.Builder(context) AlertDialog.Builder(context)

View File

@ -8,11 +8,17 @@ import com.github.apognu.otter.fragments.AlbumsGridFragment
import com.github.apognu.otter.fragments.ArtistsFragment import com.github.apognu.otter.fragments.ArtistsFragment
import com.github.apognu.otter.fragments.FavoritesFragment import com.github.apognu.otter.fragments.FavoritesFragment
import com.github.apognu.otter.fragments.PlaylistsFragment import com.github.apognu.otter.fragments.PlaylistsFragment
import com.preference.PowerPreference
class BrowseTabsAdapter(val context: Fragment, manager: FragmentManager) : FragmentPagerAdapter(manager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { class BrowseTabsAdapter(val context: Fragment, manager: FragmentManager) : FragmentPagerAdapter(manager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
var tabs = mutableListOf<Fragment>() var tabs = mutableListOf<Fragment>()
override fun getCount() = 4 override fun getCount(): Int {
return when (PowerPreference.getDefaultFile().getBoolean("experiments", false)) {
true -> 4
false -> 3
}
}
override fun getItem(position: Int): Fragment { override fun getItem(position: Int): Fragment {
tabs.getOrNull(position)?.let { tabs.getOrNull(position)?.let {

View File

@ -29,7 +29,7 @@ class AlbumsFragment : FunkwhaleFragment<Album, AlbumsAdapter>() {
arguments = bundleOf( arguments = bundleOf(
"artistId" to artist.id, "artistId" to artist.id,
"artistName" to artist.name, "artistName" to artist.name,
"artistArt" to artist.albums!![0].cover.original "artistArt" to if (artist.albums?.isNotEmpty() == true) artist.albums[0].cover.original else ""
) )
} }
} }

View File

@ -17,8 +17,6 @@ class FavoritesFragment : FunkwhaleFragment<Track, FavoritesAdapter>() {
lateinit var favoritesRepository: FavoritesRepository lateinit var favoritesRepository: FavoritesRepository
override var fetchOnCreate = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)

View File

@ -4,13 +4,19 @@ import android.os.Bundle
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.core.widget.NestedScrollView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
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.Cache
import com.github.apognu.otter.utils.untilNetwork import com.github.apognu.otter.utils.untilNetwork
import com.google.gson.Gson
import kotlinx.android.synthetic.main.fragment_artists.* import kotlinx.android.synthetic.main.fragment_artists.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
abstract class FunkwhaleAdapter<D, VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() { abstract class FunkwhaleAdapter<D, VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() {
var data: MutableList<D> = mutableListOf() var data: MutableList<D> = mutableListOf()
@ -24,7 +30,6 @@ abstract class FunkwhaleFragment<D : Any, A : FunkwhaleAdapter<D, *>> : Fragment
lateinit var repository: Repository<D, *> lateinit var repository: Repository<D, *>
lateinit var adapter: A lateinit var adapter: A
open var fetchOnCreate = true
private var initialFetched = false private var initialFetched = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
@ -37,56 +42,64 @@ abstract class FunkwhaleFragment<D : Any, A : FunkwhaleAdapter<D, *>> : Fragment
recycler.layoutManager = layoutManager recycler.layoutManager = layoutManager
recycler.adapter = adapter recycler.adapter = adapter
scroller?.setOnScrollChangeListener { _: NestedScrollView?, _: Int, _: Int, _: Int, _: Int -> (repository.upstream as? HttpUpstream<*, *>)?.let { upstream ->
if (!scroller.canScrollVertically(1)) { if (upstream.behavior == HttpUpstream.Behavior.Progressive) {
repository.fetch(Repository.Origin.Network.origin, adapter.data).untilNetwork { recycler.setOnScrollChangeListener { _, _, _, _, _ ->
swiper?.isRefreshing = false if (!recycler.canScrollVertically(1)) {
fetch(Repository.Origin.Network.origin, adapter.data.size)
onDataFetched(it) }
adapter.data = it.toMutableList()
adapter.notifyDataSetChanged()
} }
} }
} }
swiper?.isRefreshing = true fetch()
if (fetchOnCreate) fetch()
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
recycler.adapter = adapter
swiper?.setOnRefreshListener { swiper?.setOnRefreshListener {
repository.fetch(Repository.Origin.Network.origin, listOf()).untilNetwork { fetch(Repository.Origin.Network.origin)
swiper?.isRefreshing = false
onDataFetched(it)
adapter.data = it.toMutableList()
adapter.notifyDataSetChanged()
}
} }
if (!fetchOnCreate) fetch()
} }
open fun onDataFetched(data: List<D>) {} open fun onDataFetched(data: List<D>) {}
private fun fetch() { private fun fetch(upstreams: Int = (Repository.Origin.Network.origin and Repository.Origin.Cache.origin), size: Int = 0) {
if (!initialFetched) { var cleared = false
initialFetched = true
repository.fetch().untilNetwork { swiper?.isRefreshing = true
if (size == 0) {
cleared = true
adapter.data.clear()
}
repository.fetch(upstreams, size).untilNetwork(IO) { data, hasMore ->
onDataFetched(data)
if (!hasMore) {
swiper?.isRefreshing = false swiper?.isRefreshing = false
onDataFetched(it) repository.cacheId?.let { cacheId ->
Cache.set(
context,
cacheId,
Gson().toJson(repository.cache(adapter.data)).toByteArray()
)
}
}
adapter.data = it.toMutableList() GlobalScope.launch(Main) {
adapter.notifyDataSetChanged() adapter.data.addAll(data)
when (cleared) {
true -> {
adapter.notifyDataSetChanged()
cleared = false
}
false -> adapter.notifyItemRangeInserted(adapter.data.size, data.size)
}
} }
} }
} }

View File

@ -99,9 +99,11 @@ class PlaylistTracksFragment : FunkwhaleFragment<PlaylistTrack, PlaylistTracksAd
else -> cover_top_left else -> cover_top_left
} }
Picasso.get() GlobalScope.launch(Main) {
.maybeLoad(maybeNormalizeUrl(url)) Picasso.get()
.into(imageView) .maybeLoad(maybeNormalizeUrl(url))
.into(imageView)
}
} }
} }

View File

@ -403,7 +403,6 @@ class PlayerService : Service() {
} }
override fun onPlayerError(error: ExoPlaybackException?) { override fun onPlayerError(error: ExoPlaybackException?) {
log(error.toString())
EventBus.send( EventBus.send(
Event.PlaybackError( Event.PlaybackError(
getString(R.string.error_playback) getString(R.string.error_playback)

View File

@ -3,7 +3,6 @@ package com.github.apognu.otter.playback
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import com.github.apognu.otter.R import com.github.apognu.otter.R
import com.github.apognu.otter.repositories.FavoritesRepository
import com.github.apognu.otter.utils.* import com.github.apognu.otter.utils.*
import com.github.kittinunf.fuel.gson.gsonDeserializerOf import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.android.exoplayer2.source.ConcatenatingMediaSource import com.google.android.exoplayer2.source.ConcatenatingMediaSource
@ -94,8 +93,6 @@ class QueueManager(val context: Context) {
val sources = tracks.map { track -> val sources = tracks.map { track ->
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "") val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
log(url)
ProgressiveMediaSource.Factory(factory).createMediaSource(Uri.parse(url)) ProgressiveMediaSource.Factory(factory).createMediaSource(Uri.parse(url))
} }

View File

@ -17,8 +17,8 @@ class AlbumsRepository(override val context: Context?, artistId: Int? = null) :
override val upstream: Upstream<Album> by lazy { override val upstream: Upstream<Album> by lazy {
val url = val url =
if (artistId == null) "/api/v1/albums?playable=true" if (artistId == null) "/api/v1/albums/?playable=true"
else "/api/v1/albums?playable=true&artist=$artistId" else "/api/v1/albums/?playable=true&artist=$artistId"
HttpUpstream<Album, FunkwhaleResponse<Album>>( HttpUpstream<Album, FunkwhaleResponse<Album>>(
HttpUpstream.Behavior.Progressive, HttpUpstream.Behavior.Progressive,

View File

@ -11,7 +11,7 @@ import java.io.BufferedReader
class ArtistsRepository(override val context: Context?) : Repository<Artist, ArtistsCache>() { class ArtistsRepository(override val context: Context?) : Repository<Artist, ArtistsCache>() {
override val cacheId = "artists" override val cacheId = "artists"
override val upstream = HttpUpstream<Artist, FunkwhaleResponse<Artist>>(HttpUpstream.Behavior.Progressive, "/api/v1/artists?playable=true", object : TypeToken<ArtistsResponse>() {}.type) override val upstream = HttpUpstream<Artist, FunkwhaleResponse<Artist>>(HttpUpstream.Behavior.Progressive, "/api/v1/artists/?playable=true", object : TypeToken<ArtistsResponse>() {}.type)
override fun cache(data: List<Artist>) = ArtistsCache(data) override fun cache(data: List<Artist>) = ArtistsCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader) override fun uncache(reader: BufferedReader) = gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader)

View File

@ -14,7 +14,7 @@ import java.io.BufferedReader
class FavoritesRepository(override val context: Context?) : Repository<Track, TracksCache>() { class FavoritesRepository(override val context: Context?) : Repository<Track, TracksCache>() {
override val cacheId = "favorites.v2" override val cacheId = "favorites.v2"
override val upstream = HttpUpstream<Track, FunkwhaleResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks?favorites=true&playable=true", object : TypeToken<TracksResponse>() {}.type) override val upstream = HttpUpstream<Track, FunkwhaleResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?favorites=true&playable=true", object : TypeToken<TracksResponse>() {}.type)
override fun cache(data: List<Track>) = TracksCache(data) override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader) override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
@ -30,7 +30,7 @@ class FavoritesRepository(override val context: Context?) : Repository<Track, Tr
runBlocking(IO) { runBlocking(IO) {
Fuel Fuel
.post(mustNormalizeUrl("/api/v1/favorites/tracks")) .post(mustNormalizeUrl("/api/v1/favorites/tracks/"))
.header("Authorization", "Bearer $token") .header("Authorization", "Bearer $token")
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.body(Gson().toJson(body)) .body(Gson().toJson(body))
@ -55,7 +55,7 @@ class FavoritesRepository(override val context: Context?) : Repository<Track, Tr
class FavoritedRepository(override val context: Context?) : Repository<Int, FavoritedCache>() { class FavoritedRepository(override val context: Context?) : Repository<Int, FavoritedCache>() {
override val cacheId = "favorited" override val cacheId = "favorited"
override val upstream = HttpUpstream<Int, FunkwhaleResponse<Int>>(HttpUpstream.Behavior.Single, "/api/v1/favorites/tracks/all?playable=true", object : TypeToken<FavoritedResponse>() {}.type) override val upstream = HttpUpstream<Int, FunkwhaleResponse<Int>>(HttpUpstream.Behavior.Single, "/api/v1/favorites/tracks/all/?playable=true", object : TypeToken<FavoritedResponse>() {}.type)
override fun cache(data: List<Int>) = FavoritedCache(data) override fun cache(data: List<Int>) = FavoritedCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(FavoritedCache::class.java).deserialize(reader) override fun uncache(reader: BufferedReader) = gsonDeserializerOf(FavoritedCache::class.java).deserialize(reader)

View File

@ -18,7 +18,7 @@ import java.io.Reader
import java.lang.reflect.Type import java.lang.reflect.Type
import kotlin.math.ceil import kotlin.math.ceil
class HttpUpstream<D : Any, R : FunkwhaleResponse<D>>(private val behavior: Behavior, private val url: String, private val type: Type) : Upstream<D> { class HttpUpstream<D : Any, R : FunkwhaleResponse<D>>(val behavior: Behavior, private val url: String, private val type: Type) : Upstream<D> {
enum class Behavior { enum class Behavior {
Single, AtOnce, Progressive Single, AtOnce, Progressive
} }
@ -33,10 +33,10 @@ class HttpUpstream<D : Any, R : FunkwhaleResponse<D>>(private val behavior: Beha
return _channel!! return _channel!!
} }
override fun fetch(data: List<D>): Channel<Repository.Response<D>>? { override fun fetch(size: Int): Channel<Repository.Response<D>>? {
if (behavior == Behavior.Single && data.isNotEmpty()) return null if (behavior == Behavior.Single && size != 0) return null
val page = ceil(data.size / AppContext.PAGE_SIZE.toDouble()).toInt() + 1 val page = ceil(size / AppContext.PAGE_SIZE.toDouble()).toInt() + 1
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
val offsetUrl = val offsetUrl =
@ -49,19 +49,22 @@ class HttpUpstream<D : Any, R : FunkwhaleResponse<D>>(private val behavior: Beha
get(offsetUrl).fold( get(offsetUrl).fold(
{ response -> { response ->
val data = data.plus(response.getData()) val data = response.getData()
log(data.size.toString())
if (behavior == Behavior.Progressive || response.next == null) { if (behavior == Behavior.Progressive || response.next == null) {
channel.offer(Repository.Response(Repository.Origin.Network, data)) channel.offer(Repository.Response(Repository.Origin.Network, data, false))
} else { } else {
fetch(data) channel.offer(Repository.Response(Repository.Origin.Network, data, true))
fetch(size + data.size)
} }
}, },
{ error -> { error ->
log(error.toString())
when (error.exception) { when (error.exception) {
is RefreshError -> EventBus.send(Event.LogOut) is RefreshError -> EventBus.send(Event.LogOut)
else -> channel.offer(Repository.Response(Repository.Origin.Network, listOf(), false))
} }
} }
) )
@ -77,8 +80,6 @@ class HttpUpstream<D : Any, R : FunkwhaleResponse<D>>(private val behavior: Beha
} }
suspend fun get(url: String): Result<R, FuelError> { suspend fun get(url: String): Result<R, FuelError> {
log(url)
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token") val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
val (_, response, result) = Fuel val (_, response, result) = Fuel

View File

@ -12,7 +12,7 @@ import java.io.BufferedReader
class PlaylistTracksRepository(override val context: Context?, playlistId: Int) : Repository<PlaylistTrack, PlaylistTracksCache>() { class PlaylistTracksRepository(override val context: Context?, playlistId: Int) : Repository<PlaylistTrack, PlaylistTracksCache>() {
override val cacheId = "tracks-playlist-$playlistId" override val cacheId = "tracks-playlist-$playlistId"
override val upstream = HttpUpstream<PlaylistTrack, FunkwhaleResponse<PlaylistTrack>>(HttpUpstream.Behavior.Single, "/api/v1/playlists/$playlistId/tracks?playable=true", object : TypeToken<PlaylistTracksResponse>() {}.type) override val upstream = HttpUpstream<PlaylistTrack, FunkwhaleResponse<PlaylistTrack>>(HttpUpstream.Behavior.Single, "/api/v1/playlists/$playlistId/tracks/?playable=true", object : TypeToken<PlaylistTracksResponse>() {}.type)
override fun cache(data: List<PlaylistTrack>) = PlaylistTracksCache(data) override fun cache(data: List<PlaylistTrack>) = PlaylistTracksCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(PlaylistTracksCache::class.java).deserialize(reader) override fun uncache(reader: BufferedReader) = gsonDeserializerOf(PlaylistTracksCache::class.java).deserialize(reader)

View File

@ -11,7 +11,7 @@ import java.io.BufferedReader
class PlaylistsRepository(override val context: Context?) : Repository<Playlist, PlaylistsCache>() { class PlaylistsRepository(override val context: Context?) : Repository<Playlist, PlaylistsCache>() {
override val cacheId = "tracks-playlists" override val cacheId = "tracks-playlists"
override val upstream = HttpUpstream<Playlist, FunkwhaleResponse<Playlist>>(HttpUpstream.Behavior.Progressive, "/api/v1/playlists?playable=true", object : TypeToken<PlaylistsResponse>() {}.type) override val upstream = HttpUpstream<Playlist, FunkwhaleResponse<Playlist>>(HttpUpstream.Behavior.Progressive, "/api/v1/playlists/?playable=true", object : TypeToken<PlaylistsResponse>() {}.type)
override fun cache(data: List<Playlist>) = PlaylistsCache(data) override fun cache(data: List<Playlist>) = PlaylistsCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(PlaylistsCache::class.java).deserialize(reader) override fun uncache(reader: BufferedReader) = gsonDeserializerOf(PlaylistsCache::class.java).deserialize(reader)

View File

@ -6,11 +6,13 @@ import com.github.apognu.otter.utils.CacheItem
import com.github.apognu.otter.utils.untilNetwork import com.github.apognu.otter.utils.untilNetwork
import com.google.gson.Gson import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import java.io.BufferedReader import java.io.BufferedReader
interface Upstream<D> { interface Upstream<D> {
fun fetch(data: List<D> = listOf()): Channel<Repository.Response<D>>? fun fetch(size: Int = 0): Channel<Repository.Response<D>>?
} }
abstract class Repository<D : Any, C : CacheItem<D>> { abstract class Repository<D : Any, C : CacheItem<D>> {
@ -19,7 +21,7 @@ abstract class Repository<D : Any, C : CacheItem<D>> {
Network(0b10) Network(0b10)
} }
data class Response<D>(val origin: Origin, val data: List<D>) data class Response<D>(val origin: Origin, val data: List<D>, val hasMore: Boolean)
abstract val context: Context? abstract val context: Context?
abstract val cacheId: String? abstract val cacheId: String?
@ -35,29 +37,31 @@ abstract class Repository<D : Any, C : CacheItem<D>> {
return _channel!! return _channel!!
} }
protected open fun cache(data: List<D>): C? = null open fun cache(data: List<D>): C? = null
protected open fun uncache(reader: BufferedReader): C? = null protected open fun uncache(reader: BufferedReader): C? = null
fun fetch(upstreams: Int = Origin.Cache.origin and Origin.Network.origin, from: List<D> = listOf()): Channel<Response<D>> { fun fetch(upstreams: Int = Origin.Cache.origin and Origin.Network.origin, size: Int = 0): Channel<Response<D>> {
if (Origin.Cache.origin and upstreams == upstreams) fromCache() if (Origin.Cache.origin and upstreams == upstreams) fromCache()
if (Origin.Network.origin and upstreams == upstreams) fromNetwork(from) if (Origin.Network.origin and upstreams == upstreams) fromNetwork(size)
return channel return channel
} }
private fun fromCache() { private fun fromCache() {
cacheId?.let { cacheId -> GlobalScope.launch(IO) {
Cache.get(context, cacheId)?.let { reader -> cacheId?.let { cacheId ->
uncache(reader)?.let { cache -> Cache.get(context, cacheId)?.let { reader ->
channel.offer(Response(Origin.Cache, cache.data)) uncache(reader)?.let { cache ->
channel.offer(Response(Origin.Cache, cache.data, false))
}
} }
} }
} }
} }
private fun fromNetwork(from: List<D>) { private fun fromNetwork(size: Int) {
upstream.fetch(data = from)?.untilNetwork(IO) { upstream.fetch(size)?.untilNetwork(IO) { data, hasMore ->
val data = onDataFetched(it) val data = onDataFetched(data)
cacheId?.let { cacheId -> cacheId?.let { cacheId ->
Cache.set( Cache.set(
@ -67,7 +71,7 @@ abstract class Repository<D : Any, C : CacheItem<D>> {
) )
} }
channel.offer(Response(Origin.Network, data)) channel.offer(Response(Origin.Network, data, hasMore))
} }
} }

View File

@ -12,7 +12,7 @@ import java.io.BufferedReader
class SearchRepository(override val context: Context?, query: String) : Repository<Track, TracksCache>() { class SearchRepository(override val context: Context?, query: String) : Repository<Track, TracksCache>() {
override val cacheId: String? = null override val cacheId: String? = null
override val upstream = HttpUpstream<Track, FunkwhaleResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks?playable=true&q=$query", object : TypeToken<TracksResponse>() {}.type) override val upstream = HttpUpstream<Track, FunkwhaleResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&q=$query", object : TypeToken<TracksResponse>() {}.type)
override fun cache(data: List<Track>) = TracksCache(data) override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader) override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)

View File

@ -12,7 +12,7 @@ import java.io.BufferedReader
class TracksRepository(override val context: Context?, albumId: Int) : Repository<Track, TracksCache>() { class TracksRepository(override val context: Context?, albumId: Int) : Repository<Track, TracksCache>() {
override val cacheId = "tracks-album-$albumId" override val cacheId = "tracks-album-$albumId"
override val upstream = HttpUpstream<Track, FunkwhaleResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks?playable=true&album=$albumId", object : TypeToken<TracksResponse>() {}.type) override val upstream = HttpUpstream<Track, FunkwhaleResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&album=$albumId", object : TypeToken<TracksResponse>() {}.type)
override fun cache(data: List<Track>) = TracksCache(data) override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader) override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)

View File

@ -3,7 +3,10 @@ package com.github.apognu.otter.utils
import com.github.apognu.otter.Otter import com.github.apognu.otter.Otter
import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.* import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.filter
import kotlinx.coroutines.channels.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
sealed class Command { sealed class Command {

View File

@ -1,6 +1,5 @@
package com.github.apognu.otter.utils package com.github.apognu.otter.utils
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@ -29,12 +28,12 @@ inline fun <D> Channel<Repository.Response<D>>.await(context: CoroutineContext =
} }
} }
inline fun <D> Channel<Repository.Response<D>>.untilNetwork(context: CoroutineContext = Main, crossinline callback: (data: List<D>) -> Unit) { inline fun <D> Channel<Repository.Response<D>>.untilNetwork(context: CoroutineContext = Main, crossinline callback: (data: List<D>, hasMore: Boolean) -> Unit) {
GlobalScope.launch(context) { GlobalScope.launch(context) {
for (data in this@untilNetwork) { for (data in this@untilNetwork) {
callback(data.data) callback(data.data, data.hasMore)
if (data.origin == Repository.Origin.Network) { if (data.origin == Repository.Origin.Network && !data.hasMore) {
close() close()
} }
} }

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5c-0.49,0 -0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8L4,8v2h2.09c-0.05,0.33 -0.09,0.66 -0.09,1v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10L20,8zM14,16h-4v-2h4v2zM14,12h-4v-2h4v2z"/>
</vector>

View File

@ -1,47 +1,60 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/swiper"
style="@style/AppTheme.Fragment"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent">
android:clipChildren="false"
android:clipToPadding="false">
<androidx.core.widget.NestedScrollView <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/scroller" android:id="@+id/swiper"
style="@style/AppTheme.Fragment"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:clipChildren="false" android:clipChildren="false"
android:clipToPadding="false" android:clipToPadding="false"
android:fillViewport="true"> app:layout_behavior="@string/appbar_scrolling_view_behavior">
<LinearLayout <androidx.recyclerview.widget.RecyclerView
android:id="@+id/albums"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:orientation="vertical"> tools:itemCount="10"
tools:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
tools:listitem="@layout/row_album_grid"
tools:spanCount="3" />
<TextView </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
style="@style/AppTheme.Title"
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/transparent">
<com.google.android.material.appbar.CollapsingToolbarLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
app:layout_scrollFlags="scroll|exitUntilCollapsed">
<LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="16dp" android:orientation="vertical">
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:text="@string/albums" />
<androidx.recyclerview.widget.RecyclerView <TextView
android:id="@+id/albums" style="@style/AppTheme.Title"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="wrap_content"
tools:itemCount="10" android:layout_marginStart="16dp"
tools:layoutManager="androidx.recyclerview.widget.GridLayoutManager" android:layout_marginTop="16dp"
tools:listitem="@layout/row_album_grid" android:layout_marginEnd="16dp"
tools:spanCount="3" /> android:layout_marginBottom="16dp"
android:text="@string/albums" />
</LinearLayout> </LinearLayout>
</androidx.core.widget.NestedScrollView> </com.google.android.material.appbar.CollapsingToolbarLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout> </com.google.android.material.appbar.AppBarLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -1,49 +1,59 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/swiper"
style="@style/AppTheme.Fragment"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent">
android:clipChildren="false"
android:clipToPadding="false">
<androidx.core.widget.NestedScrollView <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/scroller" android:id="@+id/swiper"
style="@style/AppTheme.Fragment"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:clipChildren="false" android:clipChildren="false"
android:clipToPadding="false" android:clipToPadding="false"
android:fillViewport="true"> app:layout_behavior="@string/appbar_scrolling_view_behavior">
<LinearLayout <androidx.recyclerview.widget.RecyclerView
android:id="@+id/artists"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:clipChildren="false" tools:listitem="@layout/row_artist" />
android:clipToPadding="false"
android:orientation="vertical">
<TextView </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
style="@style/AppTheme.Title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:text="@string/artists" />
<androidx.recyclerview.widget.RecyclerView <com.google.android.material.appbar.AppBarLayout
android:id="@+id/artists" android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/transparent">
<com.google.android.material.appbar.CollapsingToolbarLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
app:layout_scrollFlags="scroll|exitUntilCollapsed">
<LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:clipChildren="false" android:clipChildren="false"
android:clipToPadding="false" android:clipToPadding="false"
tools:itemCount="10" android:orientation="vertical">
tools:listitem="@layout/row_artist" />
</LinearLayout> <TextView
style="@style/AppTheme.Title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:text="@string/artists" />
</androidx.core.widget.NestedScrollView> </LinearLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout> </com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -1,27 +1,43 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/swiper"
style="@style/AppTheme.Fragment"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<androidx.core.widget.NestedScrollView <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/scroller" android:id="@+id/swiper"
style="@style/AppTheme.Fragment"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent"
android:clipChildren="false"
android:clipToPadding="false"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<LinearLayout <androidx.recyclerview.widget.RecyclerView
android:id="@+id/favorites"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:clipChildren="false" tools:listitem="@layout/row_track" />
android:orientation="vertical">
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/transparent">
<com.google.android.material.appbar.CollapsingToolbarLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
app:layout_scrollFlags="scroll|exitUntilCollapsed">
<RelativeLayout <RelativeLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:clipChildren="false"> android:clipChildren="false"
app:layout_collapseMode="parallax">
<TextView <TextView
style="@style/AppTheme.Title" style="@style/AppTheme.Title"
@ -48,13 +64,8 @@
</RelativeLayout> </RelativeLayout>
<androidx.recyclerview.widget.RecyclerView </com.google.android.material.appbar.CollapsingToolbarLayout>
android:id="@+id/favorites"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:listitem="@layout/row_track" />
</LinearLayout>
</androidx.core.widget.NestedScrollView> </com.google.android.material.appbar.AppBarLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -1,49 +1,62 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/swiper"
style="@style/AppTheme.Fragment"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent">
android:clipChildren="false"
android:clipToPadding="false">
<androidx.core.widget.NestedScrollView <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/scroller" android:id="@+id/swiper"
style="@style/AppTheme.Fragment"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:clipChildren="false" android:clipChildren="false"
android:clipToPadding="false" android:clipToPadding="false"
android:fillViewport="true"> app:layout_behavior="@string/appbar_scrolling_view_behavior">
<LinearLayout <androidx.recyclerview.widget.RecyclerView
android:id="@+id/playlists"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:clipChildren="false" android:clipChildren="false"
android:clipToPadding="false" android:clipToPadding="false"
android:orientation="vertical"> tools:itemCount="10"
tools:listitem="@layout/row_playlist" />
<TextView </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
style="@style/AppTheme.Title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:text="@string/playlists" />
<androidx.recyclerview.widget.RecyclerView <com.google.android.material.appbar.AppBarLayout
android:id="@+id/playlists" android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/transparent">
<com.google.android.material.appbar.CollapsingToolbarLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
app:layout_scrollFlags="scroll|exitUntilCollapsed">
<LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:clipChildren="false" android:clipChildren="false"
android:clipToPadding="false" android:clipToPadding="false"
tools:itemCount="10" android:orientation="vertical">
tools:listitem="@layout/row_playlist" />
</LinearLayout> <TextView
style="@style/AppTheme.Title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:text="@string/playlists" />
</androidx.core.widget.NestedScrollView> </LinearLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout> </com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -38,6 +38,10 @@
<string name="settings_night_mode_off_summary">Le mode jour sera toujours préféré</string> <string name="settings_night_mode_off_summary">Le mode jour sera toujours préféré</string>
<string name="settings_night_mode_system">Suivre les préférences du système</string> <string name="settings_night_mode_system">Suivre les préférences du système</string>
<string name="settings_night_mode_system_summary">Le mode nuit suivra les préférence système</string> <string name="settings_night_mode_system_summary">Le mode nuit suivra les préférence système</string>
<string name="settings_experiments">Activer les fonctionnalité expérimentales</string>
<string name="settings_experiments_description">Utiliser à vos risques et périls, peut potentiellement ralentir ou crasher l\'application</string>
<string name="settings_experiments_restart_title">Relancement requis</string>
<string name="settings_experiments_restart_content">Veuillez tuer puis relancer l\'application afin que ce changement soit pris en compte</string>
<string name="settings_logout">Déconnexion</string> <string name="settings_logout">Déconnexion</string>
<string name="artists">Artistes</string> <string name="artists">Artistes</string>

View File

@ -38,6 +38,10 @@
<string name="settings_night_mode_off_summary">Light mode will always be preferred</string> <string name="settings_night_mode_off_summary">Light mode will always be preferred</string>
<string name="settings_night_mode_system">Follow system settings</string> <string name="settings_night_mode_system">Follow system settings</string>
<string name="settings_night_mode_system_summary">Night mode will follow system settings</string> <string name="settings_night_mode_system_summary">Night mode will follow system settings</string>
<string name="settings_experiments">Enable experimental features</string>
<string name="settings_experiments_description">Use at your own risks, may freeze or crash the app</string>
<string name="settings_experiments_restart_title">Restart required</string>
<string name="settings_experiments_restart_content">Please kill and restart the app in order for this change to take effect</string>
<string name="settings_logout">Sign out</string> <string name="settings_logout">Sign out</string>
<string name="artists">Artists</string> <string name="artists">Artists</string>

View File

@ -39,6 +39,12 @@
android:key="oss_licences" android:key="oss_licences"
android:title="@string/title_oss_licences" /> android:title="@string/title_oss_licences" />
<CheckBoxPreference
android:icon="@drawable/experimental"
android:key="experiments"
android:title="@string/settings_experiments"
android:summary="@string/settings_experiments_description"/>
<Preference <Preference
android:icon="@drawable/logout" android:icon="@drawable/logout"
android:key="logout" android:key="logout"