Added experimental radios support. Fixed linter and fastlane metadata.

This commit is contained in:
Antoine POPINEAU 2020-05-30 21:16:28 +02:00
parent 3fb0bb55a4
commit fd1741ca53
No known key found for this signature in database
GPG Key ID: A78AC64694F84063
26 changed files with 403 additions and 34 deletions

2
.editorconfig Normal file
View File

@ -0,0 +1,2 @@
[*.{kt,kts}]
indent_size=2

View File

@ -54,7 +54,7 @@ class LoginActivity : AppCompatActivity() {
throw Exception(getString(R.string.login_error_hostname_https))
}
if (scheme == null) hostname = "https://${hostname}"
if (scheme == null) hostname = "https://$hostname"
}
} catch (e: Exception) {
val message =

View File

@ -15,7 +15,7 @@ import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.row_album.view.*
import kotlinx.android.synthetic.main.row_artist.view.art
class AlbumsAdapter(val context: Context?, val listener: OnAlbumClickListener) : FunkwhaleAdapter<Album, AlbumsAdapter.ViewHolder>() {
class AlbumsAdapter(val context: Context?, private val listener: OnAlbumClickListener) : FunkwhaleAdapter<Album, AlbumsAdapter.ViewHolder>() {
interface OnAlbumClickListener {
fun onClick(view: View?, album: Album)
}

View File

@ -4,16 +4,14 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentPagerAdapter
import com.github.apognu.otter.R
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.github.apognu.otter.fragments.*
import com.github.apognu.otter.utils.Settings
class BrowseTabsAdapter(val context: Fragment, manager: FragmentManager) : FragmentPagerAdapter(manager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
var tabs = mutableListOf<Fragment>()
override fun getCount(): Int {
return 4
return if (Settings.areExperimentsEnabled()) 5 else 4
}
override fun getItem(position: Int): Fragment {
@ -21,13 +19,25 @@ class BrowseTabsAdapter(val context: Fragment, manager: FragmentManager) : Fragm
return it
}
val fragment = when (position) {
0 -> ArtistsFragment()
1 -> AlbumsGridFragment()
2 -> PlaylistsFragment()
3 -> FavoritesFragment()
else -> ArtistsFragment()
}
val fragment =
if (Settings.areExperimentsEnabled()) {
when (position) {
0 -> ArtistsFragment()
1 -> AlbumsGridFragment()
2 -> PlaylistsFragment()
3 -> RadiosFragment()
4 -> FavoritesFragment()
else -> ArtistsFragment()
}
} else {
when (position) {
0 -> ArtistsFragment()
1 -> AlbumsGridFragment()
2 -> PlaylistsFragment()
3 -> FavoritesFragment()
else -> ArtistsFragment()
}
}
tabs.add(position, fragment)
@ -35,12 +45,23 @@ class BrowseTabsAdapter(val context: Fragment, manager: FragmentManager) : Fragm
}
override fun getPageTitle(position: Int): String {
return when (position) {
0 -> context.getString(R.string.artists)
1 -> context.getString(R.string.albums)
2 -> context.getString(R.string.playlists)
3 -> context.getString(R.string.favorites)
else -> ""
return if (Settings.areExperimentsEnabled()) {
when (position) {
0 -> context.getString(R.string.artists)
1 -> context.getString(R.string.albums)
2 -> context.getString(R.string.playlists)
3 -> context.getString(R.string.radios)
4 -> context.getString(R.string.favorites)
else -> ""
}
} else {
when (position) {
0 -> context.getString(R.string.artists)
1 -> context.getString(R.string.albums)
2 -> context.getString(R.string.playlists)
3 -> context.getString(R.string.favorites)
else -> ""
}
}
}
}

View File

@ -75,7 +75,6 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
holder.artist.setTypeface(holder.artist.typeface, Typeface.NORMAL)
})
if (track.track == currentTrack) {
holder.title.setTypeface(holder.title.typeface, Typeface.BOLD)
holder.artist.setTypeface(holder.artist.typeface, Typeface.BOLD)
@ -145,7 +144,7 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
CommandBus.send(Command.MoveFromQueue(oldPosition, newPosition))
}
inner class ViewHolder(view: View, val context: Context?) : RecyclerView.ViewHolder(view), View.OnClickListener {
inner class ViewHolder(view: View, val context: Context?) : RecyclerView.ViewHolder(view), View.OnClickListener {
val handle = view.handle
val cover = view.cover
val title = view.title

View File

@ -0,0 +1,45 @@
package com.github.apognu.otter.adapters
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.FunkwhaleAdapter
import com.github.apognu.otter.utils.Radio
import kotlinx.android.synthetic.main.row_radio.view.*
class RadiosAdapter(val context: Context?, private val listener: OnRadioClickListener) : FunkwhaleAdapter<Radio, RadiosAdapter.ViewHolder>() {
interface OnRadioClickListener {
fun onClick(holder: View?, radio: Radio)
}
override fun getItemCount() = data.size
override fun getItemId(position: Int) = data[position].id.toLong()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RadiosAdapter.ViewHolder {
val view = LayoutInflater.from(context).inflate(R.layout.row_radio, parent, false)
return ViewHolder(view, listener).also {
view.setOnClickListener(it)
}
}
override fun onBindViewHolder(holder: RadiosAdapter.ViewHolder, position: Int) {
val radio = data[position]
holder.name.text = radio.name
holder.description.text = radio.description
}
inner class ViewHolder(view: View, val listener: OnRadioClickListener) : RecyclerView.ViewHolder(view), View.OnClickListener {
val name = view.name
val description = view.description
override fun onClick(view: View?) {
listener.onClick(view, data[layoutPosition])
}
}
}

View File

@ -108,7 +108,7 @@ class SearchAdapter(private val context: Context?, private val listener: OnSearc
}
}
if (position == (artists.size + albums.size + 2)){
if (position == (artists.size + albums.size + 2)) {
holder.title.text = context.getString(R.string.tracks)
holder.itemView.visibility = View.VISIBLE

View File

@ -74,7 +74,6 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
holder.artist.typeface = Typeface.create(holder.artist.typeface, Typeface.NORMAL)
})
if (track == currentTrack || track.current) {
holder.title.setTypeface(holder.title.typeface, Typeface.BOLD)
holder.artist.setTypeface(holder.artist.typeface, Typeface.BOLD)

View File

@ -0,0 +1,30 @@
package com.github.apognu.otter.fragments
import android.os.Bundle
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.adapters.RadiosAdapter
import com.github.apognu.otter.repositories.RadiosRepository
import com.github.apognu.otter.utils.Command
import com.github.apognu.otter.utils.CommandBus
import com.github.apognu.otter.utils.Radio
import kotlinx.android.synthetic.main.fragment_radios.*
class RadiosFragment : FunkwhaleFragment<Radio, RadiosAdapter>() {
override val viewRes = R.layout.fragment_radios
override val recycler: RecyclerView get() = radios
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
adapter = RadiosAdapter(context, RadioClickListener())
repository = RadiosRepository(context)
}
inner class RadioClickListener : RadiosAdapter.OnRadioClickListener {
override fun onClick(holder: View?, radio: Radio) {
CommandBus.send(Command.PlayRadio(radio))
}
}
}

View File

@ -18,6 +18,7 @@ import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.source.TrackGroupArray
import com.google.android.exoplayer2.trackselection.TrackSelectionArray
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
@ -43,6 +44,8 @@ class PlayerService : Service() {
private var progressCache = Triple(0, 0, 0)
private lateinit var radioPlayer: RadioPlayer
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
watchEventBus()
@ -53,6 +56,7 @@ class PlayerService : Service() {
super.onCreate()
queue = QueueManager(this)
radioPlayer = RadioPlayer(this)
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
@ -142,6 +146,8 @@ class PlayerService : Service() {
}
is Command.ReplaceQueue -> {
if (!message.fromRadio) radioPlayer.stop()
queue.replace(message.queue)
player.prepare(queue.datasources, true, true)
@ -188,6 +194,11 @@ class PlayerService : Service() {
is Command.ClearQueue -> queue.clear()
is Command.PlayRadio -> {
queue.clear()
radioPlayer.play(message.radio)
}
is Command.SetRepeatMode -> player.repeatMode = message.mode
}
@ -415,6 +426,15 @@ class PlayerService : Service() {
queue.current = player.currentWindowIndex
mediaControlsManager.updateNotification(queue.current(), player.playWhenReady)
if (queue.get().isNotEmpty() && queue.current() == queue.get().last() && radioPlayer.isActive()) {
GlobalScope.launch(IO) {
if (radioPlayer.lock.tryAcquire()) {
radioPlayer.prepareNextTrack()
radioPlayer.lock.release()
}
}
}
Cache.set(
this@PlayerService,
"current",

View File

@ -0,0 +1,92 @@
package com.github.apognu.otter.playback
import android.content.Context
import com.github.apognu.otter.R
import com.github.apognu.otter.utils.*
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitObjectResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.withContext
data class RadioSessionBody(val radio_type: String, val custom_radio: Int)
data class RadioSession(val id: Int)
data class RadioTrackBody(val session: Int)
data class RadioTrack(val position: Int, val track: RadioTrackID)
data class RadioTrackID(val id: Int)
class RadioPlayer(val context: Context) {
val lock = Semaphore(1)
private var currentRadio: Radio? = null
private var session: Int? = null
fun play(radio: Radio) {
currentRadio = radio
session = null
GlobalScope.launch(IO) {
createSession()
}
}
fun stop() {
currentRadio = null
session = null
}
fun isActive() = currentRadio != null && session != null
private suspend fun createSession() {
currentRadio?.let { radio ->
try {
val body = Gson().toJson(RadioSessionBody("custom", radio.id))
val result = Fuel.post(mustNormalizeUrl("/api/v1/radios/sessions/"))
.authorize()
.header("Content-Type", "application/json")
.body(body)
.awaitObjectResult(gsonDeserializerOf(RadioSession::class.java))
session = result.get().id
prepareNextTrack(true)
} catch (e: Exception) {
withContext(Main) {
context.toast(context.getString(R.string.radio_playback_error))
}
}
}
}
suspend fun prepareNextTrack(first: Boolean = false) {
session?.let { session ->
try {
val body = Gson().toJson(RadioTrackBody(session))
val result = Fuel.post(mustNormalizeUrl("/api/v1/radios/tracks/"))
.authorize()
.header("Content-Type", "application/json")
.body(body)
.awaitObjectResult(gsonDeserializerOf(RadioTrack::class.java))
val track = Fuel.get(mustNormalizeUrl("/api/v1/tracks/${result.get().track.id}/"))
.authorize()
.awaitObjectResult(gsonDeserializerOf(Track::class.java))
if (first) {
CommandBus.send(Command.ReplaceQueue(listOf(track.get()), true))
} else {
CommandBus.send(Command.AddToQueue(listOf(track.get())))
}
} catch (e: Exception) {
withContext(Main) {
context.toast(context.getString(R.string.radio_playback_error))
}
}
}
}
}

View File

@ -0,0 +1,18 @@
package com.github.apognu.otter.repositories
import android.content.Context
import com.github.apognu.otter.utils.FunkwhaleResponse
import com.github.apognu.otter.utils.Radio
import com.github.apognu.otter.utils.RadiosCache
import com.github.apognu.otter.utils.RadiosResponse
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import java.io.BufferedReader
class RadiosRepository(override val context: Context?) : Repository<Radio, RadiosCache>() {
override val cacheId = "radios"
override val upstream = HttpUpstream<Radio, FunkwhaleResponse<Radio>>(HttpUpstream.Behavior.Progressive, "/api/v1/radios/radios/", object : TypeToken<RadiosResponse>() {}.type)
override fun cache(data: List<Radio>) = RadiosCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(RadiosCache::class.java).deserialize(reader)
}

View File

@ -1,7 +1,6 @@
package com.github.apognu.otter.utils
import android.content.Context
import android.util.Log
import com.github.apognu.otter.activities.FwCredentials
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.core.FuelError

View File

@ -20,10 +20,11 @@ sealed class Command {
class AddToQueue(val tracks: List<Track>) : Command()
class PlayNext(val track: Track) : Command()
class ReplaceQueue(val queue: List<Track>) : Command()
class ReplaceQueue(val queue: List<Track>, val fromRadio: Boolean = false) : Command()
class RemoveFromQueue(val track: Track) : Command()
class MoveFromQueue(val oldPosition: Int, val newPosition: Int) : Command()
object ClearQueue : Command()
class PlayRadio(val radio: Radio) : Command()
class SetRepeatMode(val mode: Int) : Command()

View File

@ -7,6 +7,7 @@ import androidx.fragment.app.Fragment
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.BrowseFragment
import com.github.apognu.otter.repositories.Repository
import com.github.kittinunf.fuel.core.Request
import com.squareup.picasso.Picasso
import com.squareup.picasso.RequestCreator
import kotlinx.coroutines.Dispatchers.Main
@ -69,4 +70,12 @@ fun <T> T.applyOnApi(api: Int, block: T.() -> T): T {
fun Picasso.maybeLoad(url: String?): RequestCreator {
if (url == null) return load(R.drawable.cover)
else return load(url)
}
}
fun Request.authorize(): Request {
return this.apply {
if (!Settings.isAnonymous()) {
header("Authorization", "Bearer ${Settings.getAccessToken()}")
}
}
}

View File

@ -8,6 +8,7 @@ class AlbumsCache(data: List<Album>) : CacheItem<Album>(data)
class TracksCache(data: List<Track>) : CacheItem<Track>(data)
class PlaylistsCache(data: List<Playlist>) : CacheItem<Playlist>(data)
class PlaylistTracksCache(data: List<PlaylistTrack>) : CacheItem<PlaylistTrack>(data)
class RadiosCache(data: List<Radio>) : CacheItem<Radio>(data)
class FavoritedCache(data: List<Int>) : CacheItem<Int>(data)
class QueueCache(data: List<Track>) : CacheItem<Track>(data)
@ -46,6 +47,10 @@ data class PlaylistTracksResponse(override val count: Int, override val next: St
override fun getData() = results
}
data class RadiosResponse(override val count: Int, override val next: String?, val results: List<Radio>) : FunkwhaleResponse<Radio>() {
override fun getData() = results
}
data class Covers(val original: String)
typealias AlbumList = List<Album>
@ -133,4 +138,10 @@ data class Playlist(
val duration: Int
)
data class PlaylistTrack(val track: Track)
data class PlaylistTrack(val track: Track)
data class Radio(
val id: Int,
val name: String,
val description: String
)

View File

@ -16,6 +16,10 @@ fun Any.log(message: String) {
Log.d("FUNKWHALE", "${this.javaClass.simpleName}: $message")
}
fun Any.log() {
Log.d("FUNKWHALE", this.toString())
}
fun maybeNormalizeUrl(url: String?): String? {
if (url == null || url.isEmpty()) return null
@ -56,4 +60,5 @@ object Settings {
fun hasAccessToken() = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).contains("access_token")
fun getAccessToken(): String = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token", "")
fun isAnonymous() = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getBoolean("anonymous", false)
}
fun areExperimentsEnabled() = PowerPreference.getDefaultFile().getBoolean("experiments", false)
}

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<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:layout_width="match_parent"
android:layout_height="match_parent">
<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"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/radios"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="false"
android:clipToPadding="false"
tools:itemCount="10"
tools:listitem="@layout/row_playlist" />
</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="match_parent"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical">
<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/radios" />
</LinearLayout>
</com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -1,8 +1,7 @@
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@layout/fragment_queue">
android:layout_height="wrap_content">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/queue"
@ -15,7 +14,7 @@
android:id="@+id/placeholder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_gravity="center"
android:layout_marginTop="64dp"
android:layout_marginBottom="64dp"
android:drawableTop="@drawable/ottericon"

View File

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingTop="12dp"
android:paddingEnd="16dp"
android:paddingBottom="12dp"
android:transitionGroup="true"
tools:showIn="@layout/fragment_radios">
<com.github.apognu.otter.views.SquareImageView
android:id="@+id/art"
android:layout_width="48dp"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:scaleType="centerCrop"
android:src="@drawable/cover"
tools:src="@tools:sample/avatars" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/name"
style="@style/AppTheme.ItemTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:ellipsize="end"
android:lines="1"
tools:text="Hard Rock" />
<TextView
android:id="@+id/description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:lines="1"
tools:text="Lorem ipsum dolor sit amet" />
</LinearLayout>
</LinearLayout>

View File

@ -55,6 +55,7 @@
<string name="albums">Albums</string>
<string name="tracks">Pistes</string>
<string name="playlists">Playlists</string>
<string name="radios">Radios</string>
<string name="favorites">Favoris</string>
<string name="playback_media_controls">Contrôle de lecture</string>
@ -99,6 +100,8 @@
<string name="track_info_details_track_bitrate">Bitrate</string>
<string name="track_info_details_track_instance">Instance Funkwhale</string>
<string name="radio_playback_error">Une erreur s\'est produite lors de la lecture de cette radio</string>
<string name="logout_title">Déconnexion</string>
<string name="logout_content">Etes-vous certains de vouloir vous déconnecter de votre instance Funkwhale ?</string>

View File

@ -55,6 +55,7 @@
<string name="albums">Albums</string>
<string name="tracks">Tracks</string>
<string name="playlists">Playlists</string>
<string name="radios">Radios</string>
<string name="favorites">Favorites</string>
<string name="playback_media_controls">Media controls</string>
@ -99,6 +100,8 @@
<string name="track_info_details_track_bitrate">Bitrate</string>
<string name="track_info_details_track_instance">Funkwhale instance</string>
<string name="radio_playback_error">There was an error while trying to play this radio</string>
<string name="logout_title">Sign out</string>
<string name="logout_content">Are you sure you want to sign out of your Funkwhale instance?</string>

View File

@ -5,7 +5,7 @@ buildscript {
}
dependencies {
classpath("com.android.tools.build:gradle:3.5.1")
classpath("com.android.tools.build:gradle:3.6.3")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.50")
}
}

View File

@ -1,5 +1,6 @@
#Sat May 30 20:25:10 CEST 2020
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.3-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-all.zip