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.recyclerview:recyclerview: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.google.android.exoplayer:exoplayer:2.10.5")

View File

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

View File

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

View File

@ -44,7 +44,7 @@ class SearchActivity : AppCompatActivity() {
adapter.data.clear()
adapter.notifyDataSetChanged()
repository.fetch(Repository.Origin.Network.origin).untilNetwork { tracks ->
repository.fetch(Repository.Origin.Network.origin).untilNetwork { tracks, _ ->
search_spinner.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 {
when (preference?.key) {
"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" -> {
context?.let { 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.FavoritesFragment
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) {
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 {
tabs.getOrNull(position)?.let {

View File

@ -29,7 +29,7 @@ class AlbumsFragment : FunkwhaleFragment<Album, AlbumsAdapter>() {
arguments = bundleOf(
"artistId" to artist.id,
"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
override var fetchOnCreate = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

View File

@ -4,13 +4,19 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.widget.NestedScrollView
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.repositories.HttpUpstream
import com.github.apognu.otter.repositories.Repository
import com.github.apognu.otter.utils.Cache
import com.github.apognu.otter.utils.untilNetwork
import com.google.gson.Gson
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>() {
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 adapter: A
open var fetchOnCreate = true
private var initialFetched = false
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.adapter = adapter
scroller?.setOnScrollChangeListener { _: NestedScrollView?, _: Int, _: Int, _: Int, _: Int ->
if (!scroller.canScrollVertically(1)) {
repository.fetch(Repository.Origin.Network.origin, adapter.data).untilNetwork {
swiper?.isRefreshing = false
onDataFetched(it)
adapter.data = it.toMutableList()
adapter.notifyDataSetChanged()
(repository.upstream as? HttpUpstream<*, *>)?.let { upstream ->
if (upstream.behavior == HttpUpstream.Behavior.Progressive) {
recycler.setOnScrollChangeListener { _, _, _, _, _ ->
if (!recycler.canScrollVertically(1)) {
fetch(Repository.Origin.Network.origin, adapter.data.size)
}
}
}
}
swiper?.isRefreshing = true
if (fetchOnCreate) fetch()
fetch()
}
override fun onResume() {
super.onResume()
recycler.adapter = adapter
swiper?.setOnRefreshListener {
repository.fetch(Repository.Origin.Network.origin, listOf()).untilNetwork {
swiper?.isRefreshing = false
onDataFetched(it)
adapter.data = it.toMutableList()
adapter.notifyDataSetChanged()
}
fetch(Repository.Origin.Network.origin)
}
if (!fetchOnCreate) fetch()
}
open fun onDataFetched(data: List<D>) {}
private fun fetch() {
if (!initialFetched) {
initialFetched = true
private fun fetch(upstreams: Int = (Repository.Origin.Network.origin and Repository.Origin.Cache.origin), size: Int = 0) {
var cleared = false
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
onDataFetched(it)
repository.cacheId?.let { cacheId ->
Cache.set(
context,
cacheId,
Gson().toJson(repository.cache(adapter.data)).toByteArray()
)
}
}
adapter.data = it.toMutableList()
adapter.notifyDataSetChanged()
GlobalScope.launch(Main) {
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
}
Picasso.get()
.maybeLoad(maybeNormalizeUrl(url))
.into(imageView)
GlobalScope.launch(Main) {
Picasso.get()
.maybeLoad(maybeNormalizeUrl(url))
.into(imageView)
}
}
}

View File

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

View File

@ -3,7 +3,6 @@ package com.github.apognu.otter.playback
import android.content.Context
import android.net.Uri
import com.github.apognu.otter.R
import com.github.apognu.otter.repositories.FavoritesRepository
import com.github.apognu.otter.utils.*
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.android.exoplayer2.source.ConcatenatingMediaSource
@ -94,8 +93,6 @@ class QueueManager(val context: Context) {
val sources = tracks.map { track ->
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
log(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 {
val url =
if (artistId == null) "/api/v1/albums?playable=true"
else "/api/v1/albums?playable=true&artist=$artistId"
if (artistId == null) "/api/v1/albums/?playable=true"
else "/api/v1/albums/?playable=true&artist=$artistId"
HttpUpstream<Album, FunkwhaleResponse<Album>>(
HttpUpstream.Behavior.Progressive,

View File

@ -11,7 +11,7 @@ import java.io.BufferedReader
class ArtistsRepository(override val context: Context?) : Repository<Artist, ArtistsCache>() {
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 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>() {
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 uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
@ -30,7 +30,7 @@ class FavoritesRepository(override val context: Context?) : Repository<Track, Tr
runBlocking(IO) {
Fuel
.post(mustNormalizeUrl("/api/v1/favorites/tracks"))
.post(mustNormalizeUrl("/api/v1/favorites/tracks/"))
.header("Authorization", "Bearer $token")
.header("Content-Type", "application/json")
.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>() {
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 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 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 {
Single, AtOnce, Progressive
}
@ -33,10 +33,10 @@ class HttpUpstream<D : Any, R : FunkwhaleResponse<D>>(private val behavior: Beha
return _channel!!
}
override fun fetch(data: List<D>): Channel<Repository.Response<D>>? {
if (behavior == Behavior.Single && data.isNotEmpty()) return null
override fun fetch(size: Int): Channel<Repository.Response<D>>? {
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) {
val offsetUrl =
@ -49,19 +49,22 @@ class HttpUpstream<D : Any, R : FunkwhaleResponse<D>>(private val behavior: Beha
get(offsetUrl).fold(
{ response ->
val data = data.plus(response.getData())
log(data.size.toString())
val data = response.getData()
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 {
fetch(data)
channel.offer(Repository.Response(Repository.Origin.Network, data, true))
fetch(size + data.size)
}
},
{ error ->
log(error.toString())
when (error.exception) {
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> {
log(url)
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
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>() {
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 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>() {
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 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.google.gson.Gson
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import java.io.BufferedReader
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>> {
@ -19,7 +21,7 @@ abstract class Repository<D : Any, C : CacheItem<D>> {
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 cacheId: String?
@ -35,29 +37,31 @@ abstract class Repository<D : Any, C : CacheItem<D>> {
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
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.Network.origin and upstreams == upstreams) fromNetwork(from)
if (Origin.Network.origin and upstreams == upstreams) fromNetwork(size)
return channel
}
private fun fromCache() {
cacheId?.let { cacheId ->
Cache.get(context, cacheId)?.let { reader ->
uncache(reader)?.let { cache ->
channel.offer(Response(Origin.Cache, cache.data))
GlobalScope.launch(IO) {
cacheId?.let { cacheId ->
Cache.get(context, cacheId)?.let { reader ->
uncache(reader)?.let { cache ->
channel.offer(Response(Origin.Cache, cache.data, false))
}
}
}
}
}
private fun fromNetwork(from: List<D>) {
upstream.fetch(data = from)?.untilNetwork(IO) {
val data = onDataFetched(it)
private fun fromNetwork(size: Int) {
upstream.fetch(size)?.untilNetwork(IO) { data, hasMore ->
val data = onDataFetched(data)
cacheId?.let { cacheId ->
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>() {
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 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>() {
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 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 kotlinx.coroutines.Dispatchers.Main
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
sealed class Command {

View File

@ -1,6 +1,5 @@
package com.github.apognu.otter.utils
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
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) {
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()
}
}

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"?>
<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"
android:id="@+id/swiper"
style="@style/AppTheme.Fragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="false"
android:clipToPadding="false">
android:layout_height="match_parent">
<androidx.core.widget.NestedScrollView
android:id="@+id/scroller"
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swiper"
style="@style/AppTheme.Fragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="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_height="wrap_content"
android:orientation="vertical">
android:layout_height="match_parent"
tools:itemCount="10"
tools:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
tools:listitem="@layout/row_album_grid"
tools:spanCount="3" />
<TextView
style="@style/AppTheme.Title"
</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">
<LinearLayout
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/albums" />
android:orientation="vertical">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/albums"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:itemCount="10"
tools:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
tools:listitem="@layout/row_album_grid"
tools:spanCount="3" />
<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/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"?>
<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"
android:id="@+id/swiper"
style="@style/AppTheme.Fragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="false"
android:clipToPadding="false">
android:layout_height="match_parent">
<androidx.core.widget.NestedScrollView
android:id="@+id/scroller"
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swiper"
style="@style/AppTheme.Fragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="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_height="match_parent"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical">
tools:listitem="@layout/row_artist" />
<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.swiperefreshlayout.widget.SwipeRefreshLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/artists"
<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_height="match_parent"
android:clipChildren="false"
android:clipToPadding="false"
tools:itemCount="10"
tools:listitem="@layout/row_artist" />
android:orientation="vertical">
</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"?>
<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"
android:id="@+id/swiper"
style="@style/AppTheme.Fragment"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.core.widget.NestedScrollView
android:id="@+id/scroller"
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swiper"
style="@style/AppTheme.Fragment"
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_height="wrap_content"
android:clipChildren="false"
android:orientation="vertical">
android:layout_height="match_parent"
tools:listitem="@layout/row_track" />
</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
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false">
android:clipChildren="false"
app:layout_collapseMode="parallax">
<TextView
style="@style/AppTheme.Title"
@ -48,13 +64,8 @@
</RelativeLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/favorites"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:listitem="@layout/row_track" />
</LinearLayout>
</com.google.android.material.appbar.CollapsingToolbarLayout>
</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"?>
<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"
android:id="@+id/swiper"
style="@style/AppTheme.Fragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="false"
android:clipToPadding="false">
android:layout_height="match_parent">
<androidx.core.widget.NestedScrollView
android:id="@+id/scroller"
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swiper"
style="@style/AppTheme.Fragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="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_height="match_parent"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical">
tools:itemCount="10"
tools:listitem="@layout/row_playlist" />
<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.swiperefreshlayout.widget.SwipeRefreshLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/playlists"
<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_height="match_parent"
android:clipChildren="false"
android:clipToPadding="false"
tools:itemCount="10"
tools:listitem="@layout/row_playlist" />
android:orientation="vertical">
</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_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_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="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_system">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="artists">Artists</string>

View File

@ -39,6 +39,12 @@
android:key="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
android:icon="@drawable/logout"
android:key="logout"