WIP - Integrate Room and LiveData.

This commit is contained in:
Antoine POPINEAU 2020-07-13 23:32:42 +02:00
parent e60814d28f
commit 567a7476f9
No known key found for this signature in database
GPG Key ID: A78AC64694F84063
84 changed files with 2032 additions and 1004 deletions

View File

@ -6,10 +6,14 @@ plugins {
id("com.android.application")
id("kotlin-android")
id("kotlin-android-extensions")
id("kotlin-kapt")
id("realm-android")
id("org.jlleitschuh.gradle.ktlint") version "8.1.0"
id("com.gladed.androidgitversion") version "0.4.10"
id("com.github.triplet.play") version "2.4.2"
kotlin("plugin.serialization") version "1.3.70"
}
val props = Properties().apply {
@ -63,9 +67,9 @@ android {
buildTypes {
getByName("debug") {
isDebuggable = true
applicationIdSuffix = ".dev"
applicationIdSuffix = ".dev.livedata"
manifestPlaceholders = mapOf(
"app_name" to "Otter (develop)"
"app_name" to "Otter (livedata)"
)
resValue("string", "debug.hostname", props.getProperty("debug.hostname", ""))
@ -118,10 +122,15 @@ dependencies {
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.3.72")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.7")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-runtime:0.20.0")
implementation("androidx.appcompat:appcompat:1.2.0")
implementation("androidx.core:core-ktx:1.5.0-alpha02")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.3.0-alpha07")
implementation("androidx.fragment:fragment-ktx:1.2.5")
implementation("androidx.room:room-runtime:2.2.5")
implementation("androidx.room:room-ktx:2.2.5")
implementation("androidx.paging:paging-runtime:3.0.0-alpha06")
implementation("androidx.coordinatorlayout:coordinatorlayout:1.1.0")
implementation("androidx.preference:preference:1.1.1")
implementation("androidx.recyclerview:recyclerview:1.1.0")
@ -134,11 +143,21 @@ dependencies {
implementation("com.google.android.exoplayer:extension-mediasession:2.11.5")
implementation("com.aliassadi:power-preference-lib:1.4.1")
implementation("com.github.kittinunf.fuel:fuel:2.1.0")
implementation("com.github.kittinunf.fuel:fuel:2.2.3")
implementation("com.github.kittinunf.fuel:fuel-coroutines:2.1.0")
implementation("com.github.kittinunf.fuel:fuel-android:2.1.0")
implementation("com.github.kittinunf.fuel:fuel-gson:2.1.0")
implementation("com.github.kittinunf.fuel:fuel-kotlinx-serialization:2.2.3")
implementation("com.google.code.gson:gson:2.8.6")
implementation("com.squareup.picasso:picasso:2.71828")
implementation("jp.wasabeef:picasso-transformations:2.2.1")
debugImplementation("com.amitshekhar.android:debug-db:1.0.6")
kapt("androidx.room:room-compiler:2.2.5")
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().all {
kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlinx.serialization.ImplicitReflectionSerializer"
kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlinx.serialization.UnstableDefault"
}

View File

@ -2,9 +2,14 @@ package com.github.apognu.otter
import android.app.Application
import androidx.appcompat.app.AppCompatDelegate
import androidx.room.Room
import com.github.apognu.otter.models.dao.OtterDatabase
import com.github.apognu.otter.playback.MediaSession
import com.github.apognu.otter.playback.QueueManager
import com.github.apognu.otter.utils.*
import com.github.apognu.otter.utils.AppContext
import com.github.apognu.otter.utils.Cache
import com.github.apognu.otter.utils.Command
import com.github.apognu.otter.utils.Event
import com.google.android.exoplayer2.database.ExoDatabaseProvider
import com.google.android.exoplayer2.offline.DefaultDownloadIndex
import com.google.android.exoplayer2.offline.DefaultDownloaderFactory
@ -14,8 +19,8 @@ import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvicto
import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor
import com.google.android.exoplayer2.upstream.cache.SimpleCache
import com.preference.PowerPreference
import io.realm.Realm
import kotlinx.coroutines.channels.BroadcastChannel
import kotlinx.coroutines.channels.ConflatedBroadcastChannel
import java.text.SimpleDateFormat
import java.util.*
@ -30,8 +35,12 @@ class Otter : Application() {
val eventBus: BroadcastChannel<Event> = BroadcastChannel(10)
val commandBus: BroadcastChannel<Command> = BroadcastChannel(10)
val requestBus: BroadcastChannel<Request> = BroadcastChannel(10)
val progressBus: BroadcastChannel<Triple<Int, Int, Int>> = ConflatedBroadcastChannel()
val database: OtterDatabase by lazy {
Room
.databaseBuilder(this, OtterDatabase::class.java, "otter")
.build()
}
private val exoDatabase: ExoDatabaseProvider by lazy { ExoDatabaseProvider(this) }
@ -66,6 +75,8 @@ class Otter : Application() {
override fun onCreate() {
super.onCreate()
Realm.init(this)
defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler(CrashReportHandler())

View File

@ -3,13 +3,16 @@ package com.github.apognu.otter.activities
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.observe
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator
import com.github.apognu.otter.Otter
import com.github.apognu.otter.R
import com.github.apognu.otter.adapters.DownloadsAdapter
import com.github.apognu.otter.utils.Event
import com.github.apognu.otter.utils.EventBus
import com.github.apognu.otter.utils.getMetadata
import com.github.apognu.otter.viewmodels.DownloadsViewModel
import com.google.android.exoplayer2.offline.Download
import kotlinx.android.synthetic.main.activity_downloads.*
import kotlinx.coroutines.Dispatchers.Default
@ -27,15 +30,20 @@ class DownloadsActivity : AppCompatActivity() {
setContentView(R.layout.activity_downloads)
downloads.itemAnimator = null
(downloads.itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false
adapter = DownloadsAdapter(this, DownloadChangedListener()).also {
adapter = DownloadsAdapter(this).also {
it.setHasStableIds(true)
downloads.layoutManager = LinearLayoutManager(this)
downloads.adapter = it
}
DownloadsViewModel.get().downloads.observe(this) { downloads ->
adapter.downloads = downloads.toMutableList()
adapter.notifyDataSetChanged()
}
lifecycleScope.launch(Default) {
while (true) {
delay(1000)
@ -54,40 +62,20 @@ class DownloadsActivity : AppCompatActivity() {
}
}
}
refresh()
}
private fun refresh() {
lifecycleScope.launch(Main) {
val cursor = Otter.get().exoDownloadManager.downloadIndex.getDownloads()
adapter.downloads.clear()
while (cursor.moveToNext()) {
val download = cursor.download
download.getMetadata()?.let { info ->
adapter.downloads.add(info.apply {
this.download = download
})
}
}
adapter.notifyDataSetChanged()
}
}
private suspend fun refreshTrack(download: Download) {
download.getMetadata()?.let { info ->
adapter.downloads.withIndex().associate { it.value to it.index }.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match ->
if (download.state != info.download?.state) {
withContext(Main) {
adapter.downloads[match.second] = info.apply {
this.download = download
}
adapter.downloads.getOrNull(match.second)?.let {
withContext(Main) {
adapter.downloads[match.second] = info.apply {
this.download = download
}
adapter.notifyItemChanged(match.second)
adapter.notifyItemChanged(match.second)
}
}
}
}
@ -115,11 +103,4 @@ class DownloadsActivity : AppCompatActivity() {
}
}
}
inner class DownloadChangedListener : DownloadsAdapter.OnDownloadChangedListener {
override fun onItemRemoved(index: Int) {
adapter.downloads.removeAt(index)
adapter.notifyDataSetChanged()
}
}
}

View File

@ -11,20 +11,21 @@ import androidx.core.view.doOnLayout
import androidx.lifecycle.lifecycleScope
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.LoginDialog
import com.github.apognu.otter.models.api.Credentials
import com.github.apognu.otter.utils.AppContext
import com.github.apognu.otter.utils.Userinfo
import com.github.apognu.otter.utils.log
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.github.kittinunf.result.Result
import com.google.gson.Gson
import com.preference.PowerPreference
import kotlinx.android.synthetic.main.activity_login.*
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.launch
import kotlinx.serialization.ImplicitReflectionSerializer
data class FwCredentials(val token: String, val non_field_errors: List<String>?)
@ImplicitReflectionSerializer
class LoginActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -102,11 +103,13 @@ class LoginActivity : AppCompatActivity() {
lifecycleScope.launch(Main) {
try {
val (_, response, result) = Fuel.post("$hostname/api/v1/token/", body)
.awaitObjectResponseResult(gsonDeserializerOf(FwCredentials::class.java))
val (_, response, result) =
Fuel
.post("$hostname/api/v1/token/", body)
.awaitObjectResponseResult<Credentials>(AppContext.deserializer())
when (result) {
is Result.Success -> {
is Result.Success<*> -> {
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply {
setString("hostname", hostname)
setBoolean("anonymous", false)
@ -128,7 +131,7 @@ class LoginActivity : AppCompatActivity() {
is Result.Failure -> {
dialog.dismiss()
val error = Gson().fromJson(String(response.data), FwCredentials::class.java)
val error = Gson().fromJson(String(response.data), Credentials::class.java)
hostname_field.error = null
username_field.error = null
@ -160,7 +163,7 @@ class LoginActivity : AppCompatActivity() {
lifecycleScope.launch(Main) {
try {
val (_, _, result) = Fuel.get("$hostname/api/v1/tracks/")
.awaitObjectResponseResult(gsonDeserializerOf(FwCredentials::class.java))
.awaitObjectResponseResult<Credentials>(AppContext.deserializer())
when (result) {
is Result.Success -> {

View File

@ -2,7 +2,6 @@ package com.github.apognu.otter.activities
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Bitmap
@ -10,7 +9,6 @@ import android.os.Build
import android.os.Bundle
import android.util.DisplayMetrics
import android.view.*
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.SeekBar
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.PopupMenu
@ -21,15 +19,19 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.lifecycleScope
import com.github.apognu.otter.Otter
import androidx.lifecycle.observe
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.*
import com.github.apognu.otter.models.dao.RealmArtist
import com.github.apognu.otter.models.domain.Track
import com.github.apognu.otter.playback.MediaControlsManager
import com.github.apognu.otter.playback.PinService
import com.github.apognu.otter.playback.PlayerService
import com.github.apognu.otter.repositories.FavoritedRepository
import com.github.apognu.otter.repositories.FavoritesRepository
import com.github.apognu.otter.repositories.Repository
import com.github.apognu.otter.utils.*
import com.github.apognu.otter.viewmodels.PlayerStateViewModel
import com.github.apognu.otter.viewmodels.QueueViewModel
import com.github.apognu.otter.views.DisableableFrameLayout
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitStringResponse
@ -38,6 +40,7 @@ import com.google.android.exoplayer2.offline.DownloadService
import com.google.gson.Gson
import com.preference.PowerPreference
import com.squareup.picasso.Picasso
import io.realm.Realm
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.main.partial_now_playing.*
@ -53,7 +56,8 @@ class MainActivity : AppCompatActivity() {
LOGOUT(1001)
}
private val favoriteRepository = FavoritesRepository(this)
private val queueViewModel = QueueViewModel.get()
private val favoritesRepository = FavoritesRepository(this)
private val favoritedRepository = FavoritedRepository(this)
private var menu: Menu? = null
@ -75,12 +79,51 @@ class MainActivity : AppCompatActivity() {
.commit()
watchEventBus()
PlayerStateViewModel.get().isPlaying.observe(this) { isPlaying ->
when (isPlaying) {
true -> {
now_playing_toggle.icon = getDrawable(R.drawable.pause)
now_playing_details_toggle.icon = getDrawable(R.drawable.pause)
}
false -> {
now_playing_toggle.icon = getDrawable(R.drawable.play)
now_playing_details_toggle.icon = getDrawable(R.drawable.play)
}
}
}
PlayerStateViewModel.get().isBuffering.observe(this) { isBuffering ->
when (isBuffering) {
true -> now_playing_buffering.visibility = View.VISIBLE
false -> now_playing_buffering.visibility = View.GONE
}
}
PlayerStateViewModel.get().track.observe(this) { track ->
refreshCurrentTrack(track)
}
PlayerStateViewModel.get().position.observe(this) { (current, duration, percent) ->
now_playing_progress.progress = percent
now_playing_details_progress.progress = percent
val currentMins = (current / 1000) / 60
val currentSecs = (current / 1000) % 60
val durationMins = duration / 60
val durationSecs = duration % 60
now_playing_details_progress_current.text = "%02d:%02d".format(currentMins, currentSecs)
now_playing_details_progress_duration.text = "%02d:%02d".format(durationMins, durationSecs)
}
}
override fun onResume() {
super.onResume()
(container as? DisableableFrameLayout)?.setShouldRegisterTouch { _ ->
container?.setShouldRegisterTouch { _ ->
if (now_playing.isOpened()) {
now_playing.close()
@ -90,7 +133,17 @@ class MainActivity : AppCompatActivity() {
true
}
favoritedRepository.update(this, lifecycleScope)
landscape_queue?.setShouldRegisterTouch { _ ->
if (now_playing.isOpened()) {
now_playing.close()
return@setShouldRegisterTouch false
}
true
}
favoritedRepository.update()
startService(Intent(this, PlayerService::class.java))
DownloadService.start(this, PinService::class.java)
@ -257,6 +310,11 @@ class MainActivity : AppCompatActivity() {
stopService(Intent(this@MainActivity, PlayerService::class.java))
startActivity(this)
databaseList().forEach {
deleteDatabase(it)
}
finish()
}
}
@ -300,13 +358,6 @@ class MainActivity : AppCompatActivity() {
is Event.PlaybackError -> toast(message.message)
is Event.Buffering -> {
when (message.value) {
true -> now_playing_buffering.visibility = View.VISIBLE
false -> now_playing_buffering.visibility = View.GONE
}
}
is Event.PlaybackStopped -> {
if (now_playing.visibility == View.VISIBLE) {
(container.layoutParams as? ViewGroup.MarginLayoutParams)?.let {
@ -332,30 +383,6 @@ class MainActivity : AppCompatActivity() {
}
is Event.TrackFinished -> incrementListenCount(message.track)
is Event.StateChanged -> {
when (message.playing) {
true -> {
now_playing_toggle.icon = getDrawable(R.drawable.pause)
now_playing_details_toggle.icon = getDrawable(R.drawable.pause)
}
false -> {
now_playing_toggle.icon = getDrawable(R.drawable.play)
now_playing_details_toggle.icon = getDrawable(R.drawable.play)
}
}
}
is Event.QueueChanged -> {
findViewById<View>(R.id.nav_queue)?.let { view ->
ObjectAnimator.ofFloat(view, View.ROTATION, 0f, 360f).let {
it.duration = 500
it.interpolator = AccelerateDecelerateInterpolator()
it.start()
}
}
}
}
}
}
@ -377,27 +404,9 @@ class MainActivity : AppCompatActivity() {
}
)
}
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
}
}
}
lifecycleScope.launch(Main) {
ProgressBus.get().collect { (current, duration, percent) ->
now_playing_progress.progress = percent
now_playing_details_progress.progress = percent
val currentMins = (current / 1000) / 60
val currentSecs = (current / 1000) % 60
val durationMins = duration / 60
val durationSecs = duration % 60
now_playing_details_progress_current.text = "%02d:%02d".format(currentMins, currentSecs)
now_playing_details_progress_duration.text = "%02d:%02d".format(durationMins, durationSecs)
}
}
}
private fun refreshCurrentTrack(track: Track?) {
@ -424,15 +433,15 @@ class MainActivity : AppCompatActivity() {
}
now_playing_title.text = track.title
now_playing_album.text = track.artist.name
now_playing_album.text = track.artist?.name
now_playing_toggle.icon = getDrawable(R.drawable.pause)
now_playing_details_title.text = track.title
now_playing_details_artist.text = track.artist.name
now_playing_details_artist.text = track.artist?.name
now_playing_details_toggle.icon = getDrawable(R.drawable.pause)
Picasso.get()
.maybeLoad(maybeNormalizeUrl(track.album?.cover?.urls?.original))
.maybeLoad(maybeNormalizeUrl(track.album?.cover))
.fit()
.centerCrop()
.into(now_playing_cover)
@ -499,34 +508,22 @@ class MainActivity : AppCompatActivity() {
}
}
now_playing_details_favorite?.let { now_playing_details_favorite ->
favoritedRepository.fetch().untilNetwork(lifecycleScope, IO) { favorites, _, _, _ ->
lifecycleScope.launch(Main) {
track.favorite = favorites.contains(track.id)
when (track.favorite) {
true -> now_playing_details_favorite.setColorFilter(getColor(R.color.colorFavorite))
false -> now_playing_details_favorite.setColorFilter(getColor(R.color.controlForeground))
}
when (track.favorite) {
true -> now_playing_details_favorite.setColorFilter(getColor(R.color.colorFavorite))
false -> now_playing_details_favorite.setColorFilter(getColor(R.color.controlForeground))
}
}
}
now_playing_details_favorite.setOnClickListener {
when (track.favorite) {
true -> {
favoriteRepository.deleteFavorite(track.id)
now_playing_details_favorite.setColorFilter(getColor(R.color.controlForeground))
}
false -> {
favoriteRepository.addFavorite(track.id)
now_playing_details_favorite.setColorFilter(getColor(R.color.colorFavorite))
}
now_playing_details_favorite.setOnClickListener {
when (track.favorite) {
true -> {
favoritesRepository.deleteFavorite(track.id)
// now_playing_details_favorite.setColorFilter(getColor(R.color.controlForeground))
}
track.favorite = !track.favorite
favoriteRepository.fetch(Repository.Origin.Network.origin)
false -> {
favoritesRepository.addFavorite(track.id)
// now_playing_details_favorite.setColorFilter(getColor(R.color.colorFavorite))
}
}
}
}

View File

@ -5,18 +5,24 @@ import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.apognu.otter.Otter
import com.github.apognu.otter.R
import com.github.apognu.otter.adapters.SearchAdapter
import com.github.apognu.otter.fragments.AlbumsFragment
import com.github.apognu.otter.fragments.ArtistsFragment
import com.github.apognu.otter.models.dao.toDao
import com.github.apognu.otter.repositories.*
import com.github.apognu.otter.utils.*
import com.github.apognu.otter.models.domain.Album
import com.github.apognu.otter.models.domain.Artist
import com.github.apognu.otter.models.domain.Track
import com.google.android.exoplayer2.offline.Download
import kotlinx.android.synthetic.main.activity_search.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.URLEncoder
import java.util.*
@ -47,7 +53,7 @@ class SearchActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
lifecycleScope.launch(Dispatchers.IO) {
lifecycleScope.launch(IO) {
EventBus.get().collect { message ->
when (message) {
is Event.DownloadChanged -> refreshDownloadedTrack(message.download)
@ -82,25 +88,52 @@ class SearchActivity : AppCompatActivity() {
adapter.tracks.clear()
adapter.notifyDataSetChanged()
artistsRepository.fetch(Repository.Origin.Network.origin).untilNetwork(lifecycleScope) { artists, _, _, _ ->
artistsRepository.fetch().untilNetwork(lifecycleScope, IO) { artists, _, _ ->
done++
adapter.artists.addAll(artists)
refresh()
artists.forEach {
Otter.get().database.artists().run {
insert(it.toDao())
adapter.artists.add(Artist.fromDecoratedEntity(getDecoratedBlocking(it.id)))
}
}
lifecycleScope.launch(Main) {
refresh()
}
}
albumsRepository.fetch(Repository.Origin.Network.origin).untilNetwork(lifecycleScope) { albums, _, _, _ ->
albumsRepository.fetch().untilNetwork(lifecycleScope, IO) { albums, _, _ ->
done++
adapter.albums.addAll(albums)
refresh()
albums.forEach {
Otter.get().database.albums().run {
insert(it.toDao())
adapter.albums.add(Album.fromDecoratedEntity(getDecoratedBlocking(it.id)))
}
}
lifecycleScope.launch(Main) {
refresh()
}
}
tracksRepository.fetch(Repository.Origin.Network.origin).untilNetwork(lifecycleScope) { tracks, _, _, _ ->
tracksRepository.fetch().untilNetwork(lifecycleScope, IO) { tracks, _, _ ->
done++
adapter.tracks.addAll(tracks)
refresh()
tracks.forEach {
Otter.get().database.tracks().run {
insertWithAssocs(it)
adapter.tracks.add(Track.fromDecoratedEntity(getDecoratedBlocking(it.id)))
}
}
lifecycleScope.launch(Main) {
refresh()
}
}
}
@ -127,14 +160,14 @@ class SearchActivity : AppCompatActivity() {
private suspend fun refreshDownloadedTrack(download: Download) {
if (download.state == Download.STATE_COMPLETED) {
download.getMetadata()?.let { info ->
/* download.getMetadata()?.let { info ->
adapter.tracks.withIndex().associate { it.value to it.index }.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match ->
withContext(Dispatchers.Main) {
adapter.tracks[match.second].downloaded = true
adapter.notifyItemChanged(adapter.getPositionOf(SearchAdapter.ResultType.Track, match.second))
}
}
}
} */
}
}

View File

@ -16,6 +16,7 @@ import com.github.apognu.otter.R
import com.github.apognu.otter.utils.Cache
import com.github.apognu.otter.utils.Command
import com.github.apognu.otter.utils.CommandBus
import kotlinx.serialization.ImplicitReflectionSerializer
class SettingsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@ -35,6 +36,7 @@ class SettingsActivity : AppCompatActivity() {
fun getThemeResId(): Int = R.style.AppTheme
}
@ImplicitReflectionSerializer
class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedPreferenceChangeListener {
override fun onResume() {
super.onResume()

View File

@ -7,9 +7,9 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.OtterAdapter
import com.github.apognu.otter.utils.Album
import com.github.apognu.otter.utils.maybeLoad
import com.github.apognu.otter.utils.maybeNormalizeUrl
import com.github.apognu.otter.models.domain.Album
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.row_album.view.*
@ -42,7 +42,7 @@ class AlbumsAdapter(val context: Context?, private val listener: OnAlbumClickLis
.into(holder.art)
holder.title.text = album.title
holder.artist.text = album.artist.name
holder.artist.text = album.artist_name
holder.release_date.visibility = View.GONE
album.release_date?.split('-')?.getOrNull(0)?.let { year ->

View File

@ -7,9 +7,9 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.OtterAdapter
import com.github.apognu.otter.utils.Album
import com.github.apognu.otter.utils.maybeLoad
import com.github.apognu.otter.utils.maybeNormalizeUrl
import com.github.apognu.otter.models.domain.Album
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.row_album_grid.view.*

View File

@ -7,39 +7,21 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.OtterAdapter
import com.github.apognu.otter.utils.Artist
import com.github.apognu.otter.utils.maybeLoad
import com.github.apognu.otter.utils.maybeNormalizeUrl
import com.github.apognu.otter.models.domain.Artist
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.row_artist.view.*
class ArtistsAdapter(val context: Context?, private val listener: OnArtistClickListener) : OtterAdapter<Artist, ArtistsAdapter.ViewHolder>() {
private var active: List<Artist> = mutableListOf()
interface OnArtistClickListener {
fun onClick(holder: View?, artist: Artist)
}
init {
registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onChanged() {
active = data.filter { it.albums?.isNotEmpty() ?: false }
override fun getItemCount() = data.size
super.onChanged()
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
active = data.filter { it.albums?.isNotEmpty() ?: false }
super.onItemRangeInserted(positionStart, itemCount)
}
})
}
override fun getItemCount() = active.size
override fun getItemId(position: Int) = active[position].id.toLong()
override fun getItemId(position: Int) = data[position].id.toLong()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(context).inflate(R.layout.row_artist, parent, false)
@ -50,24 +32,18 @@ class ArtistsAdapter(val context: Context?, private val listener: OnArtistClickL
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val artist = active[position]
val artist = data[position]
artist.albums?.let { albums ->
if (albums.isNotEmpty()) {
Picasso.get()
.maybeLoad(maybeNormalizeUrl(albums[0].cover?.urls?.original))
.fit()
.transform(RoundedCornersTransformation(8, 0))
.into(holder.art)
}
}
Picasso.get()
.maybeLoad(maybeNormalizeUrl(artist.album_cover))
.fit()
.transform(RoundedCornersTransformation(8, 0))
.into(holder.art)
holder.name.text = artist.name
artist.albums?.let {
context?.let {
holder.albums.text = context.resources.getQuantityString(R.plurals.album_count, artist.albums.size, artist.albums.size)
}
context?.let {
holder.albums.text = context.resources.getQuantityString(R.plurals.album_count, artist.album_count, artist.album_count)
}
}
@ -77,7 +53,9 @@ class ArtistsAdapter(val context: Context?, private val listener: OnArtistClickL
val albums = view.albums
override fun onClick(view: View?) {
listener.onClick(view, active[layoutPosition])
data[layoutPosition].let { artist ->
listener.onClick(view, artist)
}
}
}
}

View File

@ -7,23 +7,20 @@ import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.models.api.DownloadInfo
import com.github.apognu.otter.models.api.FunkwhaleTrack
import com.github.apognu.otter.playback.PinService
import com.github.apognu.otter.utils.*
import com.google.android.exoplayer2.offline.Download
import com.google.android.exoplayer2.offline.DownloadService
import kotlinx.android.synthetic.main.row_download.view.*
class DownloadsAdapter(private val context: Context, private val listener: OnDownloadChangedListener) : RecyclerView.Adapter<DownloadsAdapter.ViewHolder>() {
interface OnDownloadChangedListener {
fun onItemRemoved(index: Int)
}
class DownloadsAdapter(private val context: Context) : RecyclerView.Adapter<DownloadsAdapter.ViewHolder>() {
var downloads: MutableList<DownloadInfo> = mutableListOf()
override fun getItemCount() = downloads.size
override fun getItemId(position: Int) = downloads[position].id.toLong()
override fun getItemCount() = downloads.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(context).inflate(R.layout.row_download, parent, false)
@ -79,8 +76,8 @@ class DownloadsAdapter(private val context: Context, private val listener: OnDow
Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> DownloadService.sendSetStopReason(context, PinService::class.java, download.contentId, 1, false)
Download.STATE_FAILED -> {
Track.fromDownload(download).also {
PinService.download(context, it)
FunkwhaleTrack.fromDownload(download).also {
// PinService.download(context, it)
}
}
@ -89,7 +86,6 @@ class DownloadsAdapter(private val context: Context, private val listener: OnDow
}
holder.delete.setOnClickListener {
listener.onItemRemoved(position)
DownloadService.sendRemoveDownload(context, PinService::class.java, download.contentId, false)
}
}

View File

@ -13,6 +13,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.OtterAdapter
import com.github.apognu.otter.utils.*
import com.github.apognu.otter.models.domain.Track
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.row_track.view.*
@ -51,7 +52,7 @@ class FavoritesAdapter(private val context: Context?, private val favoriteListen
.into(holder.cover)
holder.title.text = favorite.title
holder.artist.text = favorite.artist.name
holder.artist.text = favorite.artist?.name
context?.let {
holder.itemView.background = context.getDrawable(R.drawable.ripple)

View File

@ -13,12 +13,13 @@ import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.OtterAdapter
import com.github.apognu.otter.utils.*
import com.github.apognu.otter.models.domain.Track
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.row_track.view.*
import java.util.*
class PlaylistTracksAdapter(private val context: Context?, private val favoriteListener: OnFavoriteListener? = null, val fromQueue: Boolean = false) : OtterAdapter<PlaylistTrack, PlaylistTracksAdapter.ViewHolder>() {
class PlaylistTracksAdapter(private val context: Context?, private val favoriteListener: OnFavoriteListener? = null, val fromQueue: Boolean = false) : OtterAdapter<Track, PlaylistTracksAdapter.ViewHolder>() {
interface OnFavoriteListener {
fun onToggleFavorite(id: Int, state: Boolean)
}
@ -30,7 +31,7 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
override fun getItemCount() = data.size
override fun getItemId(position: Int): Long {
return data[position].track.id.toLong()
return data[position].id.toLong()
}
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
@ -56,38 +57,33 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
val track = data[position]
Picasso.get()
.maybeLoad(maybeNormalizeUrl(track.track.album?.cover()))
.maybeLoad(maybeNormalizeUrl(track.album?.cover()))
.fit()
.placeholder(R.drawable.cover)
.transform(RoundedCornersTransformation(16, 0))
.into(holder.cover)
holder.title.text = track.track.title
holder.artist.text = track.track.artist.name
holder.title.text = track.title
holder.artist.text = track.artist?.name
context?.let {
holder.itemView.background = context.getDrawable(R.drawable.ripple)
}
if (track.track == currentTrack || track.track.current) {
if (track == currentTrack) {
context?.let {
holder.itemView.background = context.getDrawable(R.drawable.current)
}
}
context?.let {
when (track.track.favorite) {
when (track.favorite) {
true -> holder.favorite.setColorFilter(context.getColor(R.color.colorFavorite))
false -> holder.favorite.setColorFilter(context.getColor(R.color.colorSelected))
}
holder.favorite.setOnClickListener {
favoriteListener?.let {
favoriteListener.onToggleFavorite(track.track.id, !track.track.favorite)
track.track.favorite = !track.track.favorite
notifyItemChanged(position)
}
favoriteListener?.onToggleFavorite(track.id, !track.favorite)
}
}
@ -98,9 +94,9 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
setOnMenuItemClickListener {
when (it.itemId) {
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(track.track)))
R.id.track_play_next -> CommandBus.send(Command.PlayNext(track.track))
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(track.track))
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(track)))
R.id.track_play_next -> CommandBus.send(Command.PlayNext(track))
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(track))
}
true
@ -152,8 +148,8 @@ class PlaylistTracksAdapter(private val context: Context?, private val favoriteL
when (fromQueue) {
true -> CommandBus.send(Command.PlayTrack(layoutPosition))
false -> {
data.subList(layoutPosition, data.size).plus(data.subList(0, layoutPosition)).apply {
CommandBus.send(Command.ReplaceQueue(this.map { it.track }))
data.subList(layoutPosition, data.size).plus(data.subList(0, layoutPosition)).also { track ->
CommandBus.send(Command.ReplaceQueue(track))
context.toast("All tracks were added to your queue")
}

View File

@ -6,16 +6,16 @@ import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.models.dao.PlaylistEntity
import com.github.apognu.otter.fragments.OtterAdapter
import com.github.apognu.otter.utils.Playlist
import com.github.apognu.otter.utils.toDurationString
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.row_playlist.view.*
class PlaylistsAdapter(val context: Context?, private val listener: OnPlaylistClickListener) : OtterAdapter<Playlist, PlaylistsAdapter.ViewHolder>() {
class PlaylistsAdapter(val context: Context?, private val listener: OnPlaylistClickListener) : OtterAdapter<PlaylistEntity, PlaylistsAdapter.ViewHolder>() {
interface OnPlaylistClickListener {
fun onClick(holder: View?, playlist: Playlist)
fun onClick(holder: View?, playlist: PlaylistEntity)
}
override fun getItemCount() = data.size

View File

@ -6,11 +6,11 @@ import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.models.dao.RadioEntity
import com.github.apognu.otter.fragments.OtterAdapter
import com.github.apognu.otter.utils.AppContext
import com.github.apognu.otter.utils.Event
import com.github.apognu.otter.utils.EventBus
import com.github.apognu.otter.utils.Radio
import com.github.apognu.otter.views.LoadingImageView
import com.preference.PowerPreference
import kotlinx.android.synthetic.main.row_radio.view.*
@ -20,9 +20,9 @@ import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class RadiosAdapter(val context: Context?, val scope: CoroutineScope, private val listener: OnRadioClickListener) : OtterAdapter<Radio, RadiosAdapter.ViewHolder>() {
class RadiosAdapter(val context: Context?, val scope: CoroutineScope, private val listener: OnRadioClickListener) : OtterAdapter<RadioEntity, RadiosAdapter.ViewHolder>() {
interface OnRadioClickListener {
fun onClick(holder: ViewHolder, radio: Radio)
fun onClick(holder: ViewHolder, radio: RadioEntity)
}
enum class RowType {
@ -31,26 +31,26 @@ class RadiosAdapter(val context: Context?, val scope: CoroutineScope, private va
UserRadio
}
private val instanceRadios: List<Radio> by lazy {
private val instanceRadios: List<RadioEntity> by lazy {
context?.let {
return@lazy when (val username = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("actor_username")) {
"" -> listOf(
Radio(0, "random", context.getString(R.string.radio_random_title), context.getString(R.string.radio_random_description))
RadioEntity(0, "random", context.getString(R.string.radio_random_title), context.getString(R.string.radio_random_description))
)
else -> listOf(
Radio(0, "actor_content", context.getString(R.string.radio_your_content_title), context.getString(R.string.radio_your_content_description), username),
Radio(0, "random", context.getString(R.string.radio_random_title), context.getString(R.string.radio_random_description)),
Radio(0, "favorites", context.getString(R.string.favorites), context.getString(R.string.radio_favorites_description)),
Radio(0, "less-listened", context.getString(R.string.radio_less_listened_title), context.getString(R.string.radio_less_listened_description))
RadioEntity(0, "actor_content", context.getString(R.string.radio_your_content_title), context.getString(R.string.radio_your_content_description), username),
RadioEntity(0, "random", context.getString(R.string.radio_random_title), context.getString(R.string.radio_random_description)),
RadioEntity(0, "favorites", context.getString(R.string.favorites), context.getString(R.string.radio_favorites_description)),
RadioEntity(0, "less-listened", context.getString(R.string.radio_less_listened_title), context.getString(R.string.radio_less_listened_description))
)
}
}
listOf<Radio>()
listOf<RadioEntity>()
}
private fun getRadioAt(position: Int): Radio {
private fun getRadioAt(position: Int): RadioEntity {
return when (getItemViewType(position)) {
RowType.InstanceRadio.ordinal -> instanceRadios[position - 1]
else -> data[position - instanceRadios.size - 2]

View File

@ -13,7 +13,11 @@ import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.models.api.FunkwhaleTrack
import com.github.apognu.otter.utils.*
import com.github.apognu.otter.models.domain.Album
import com.github.apognu.otter.models.domain.Artist
import com.github.apognu.otter.models.domain.Track
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.row_track.view.*
@ -41,7 +45,7 @@ class SearchAdapter(private val context: Context?, private val listener: OnSearc
var albums: MutableList<Album> = mutableListOf()
var tracks: MutableList<Track> = mutableListOf()
var currentTrack: Track? = null
var currentTrack: FunkwhaleTrack? = null
override fun getItemCount() = SECTION_COUNT + artists.size + albums.size + tracks.size
@ -169,10 +173,10 @@ class SearchAdapter(private val context: Context?, private val listener: OnSearc
if (resultType == ResultType.Track.ordinal) {
(item as? Track)?.let { track ->
context?.let { context ->
if (track == currentTrack || track.current) {
/* if (track == currentTrack || track.current) {
holder.title.setTypeface(holder.title.typeface, Typeface.BOLD)
holder.artist.setTypeface(holder.artist.typeface, Typeface.BOLD)
}
} */
when (track.favorite) {
true -> holder.favorite.setColorFilter(context.getColor(R.color.colorFavorite))
@ -180,13 +184,7 @@ class SearchAdapter(private val context: Context?, private val listener: OnSearc
}
holder.favorite.setOnClickListener {
favoriteListener?.let {
favoriteListener.onToggleFavorite(track.id, !track.favorite)
tracks[position - artists.size - albums.size - SECTION_COUNT].favorite = !track.favorite
notifyItemChanged(position)
}
favoriteListener?.onToggleFavorite(track.id, !track.favorite)
}
when (track.cached || track.downloaded) {

View File

@ -2,7 +2,9 @@ package com.github.apognu.otter.adapters
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.*
import android.graphics.Color
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.ColorDrawable
import android.view.*
import androidx.appcompat.widget.PopupMenu
@ -11,6 +13,7 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.OtterAdapter
import com.github.apognu.otter.models.domain.Track
import com.github.apognu.otter.utils.*
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
@ -24,8 +27,6 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
private lateinit var touchHelper: ItemTouchHelper
var currentTrack: Track? = null
override fun getItemId(position: Int): Long = data[position].id.toLong()
override fun getItemCount() = data.size
@ -59,32 +60,22 @@ class TracksAdapter(private val context: Context?, private val favoriteListener:
.into(holder.cover)
holder.title.text = track.title
holder.artist.text = track.artist.name
holder.artist.text = track.artist?.name
context?.let {
holder.itemView.background = ContextCompat.getDrawable(context, R.drawable.ripple)
}
if (track == currentTrack || track.current) {
context?.let {
if (track.current) {
holder.itemView.background = ContextCompat.getDrawable(context, R.drawable.current)
}
}
context?.let {
when (track.favorite) {
true -> holder.favorite.setColorFilter(context.getColor(R.color.colorFavorite))
false -> holder.favorite.setColorFilter(context.getColor(R.color.colorSelected))
}
holder.favorite.setOnClickListener {
favoriteListener?.let {
favoriteListener.onToggleFavorite(track.id, !track.favorite)
data[position].favorite = !track.favorite
notifyItemChanged(position)
}
favoriteListener?.onToggleFavorite(track.id, !track.favorite)
}
when (track.cached || track.downloaded) {

View File

@ -8,6 +8,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.lifecycle.LiveData
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
@ -16,21 +17,21 @@ import androidx.transition.Slide
import com.github.apognu.otter.R
import com.github.apognu.otter.activities.MainActivity
import com.github.apognu.otter.adapters.AlbumsAdapter
import com.github.apognu.otter.models.api.FunkwhaleAlbum
import com.github.apognu.otter.repositories.AlbumsRepository
import com.github.apognu.otter.repositories.ArtistTracksRepository
import com.github.apognu.otter.repositories.Repository
import com.github.apognu.otter.utils.*
import com.github.apognu.otter.models.domain.Album
import com.github.apognu.otter.viewmodels.AlbumsViewModel
import com.github.apognu.otter.models.domain.Artist
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.fragment_albums.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class AlbumsFragment : OtterFragment<Album, AlbumsAdapter>() {
class AlbumsFragment : LiveOtterFragment<FunkwhaleAlbum, Album, AlbumsAdapter>() {
override lateinit var liveData: LiveData<List<Album>>
override val viewRes = R.layout.fragment_albums
override val recycler: RecyclerView get() = albums
override val alwaysRefresh = false
@ -92,14 +93,16 @@ class AlbumsFragment : OtterFragment<Album, AlbumsAdapter>() {
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.apply {
artistId = getInt("artistId")
artistName = getString("artistName") ?: ""
artistArt = getString("artistArt") ?: ""
}
liveData = AlbumsViewModel(artistId).albums
super.onCreate(savedInstanceState)
adapter = AlbumsAdapter(context, OnAlbumClickListener())
repository = AlbumsRepository(context, artistId)
artistTracksRepository = ArtistTracksRepository(context, artistId)
@ -132,19 +135,18 @@ class AlbumsFragment : OtterFragment<Album, AlbumsAdapter>() {
play.isClickable = false
lifecycleScope.launch(IO) {
artistTracksRepository.fetch(Repository.Origin.Network.origin)
.map { it.data }
.toList()
.flatten()
.shuffled()
.also {
CommandBus.send(Command.ReplaceQueue(it))
artistTracksRepository.fetch().untilNetwork(lifecycleScope) { _, _, _ ->
loader.stop()
withContext(Main) {
play.icon = requireContext().getDrawable(R.drawable.play)
play.isClickable = true
play.icon = requireContext().getDrawable(R.drawable.play)
play.isClickable = true
lifecycleScope.launch(IO) {
AlbumsViewModel(artistId).tracks().also {
CommandBus.send(Command.ReplaceQueue(it.shuffled()))
}
}
}
}
}
}

View File

@ -10,12 +10,15 @@ import androidx.transition.Slide
import com.github.apognu.otter.R
import com.github.apognu.otter.activities.MainActivity
import com.github.apognu.otter.adapters.AlbumsGridAdapter
import com.github.apognu.otter.models.api.FunkwhaleAlbum
import com.github.apognu.otter.repositories.AlbumsRepository
import com.github.apognu.otter.utils.Album
import com.github.apognu.otter.utils.AppContext
import com.github.apognu.otter.models.domain.Album
import com.github.apognu.otter.viewmodels.AlbumsViewModel
import kotlinx.android.synthetic.main.fragment_albums_grid.*
class AlbumsGridFragment : OtterFragment<Album, AlbumsGridAdapter>() {
class AlbumsGridFragment : LiveOtterFragment<FunkwhaleAlbum, Album, AlbumsGridAdapter>() {
override val liveData = AlbumsViewModel().albums
override val viewRes = R.layout.fragment_albums_grid
override val recycler: RecyclerView get() = albums
override val layoutManager get() = GridLayoutManager(context, 3)

View File

@ -2,57 +2,66 @@ package com.github.apognu.otter.fragments
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.lifecycle.observe
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.transition.Fade
import androidx.transition.Slide
import com.github.apognu.otter.R
import com.github.apognu.otter.activities.MainActivity
import com.github.apognu.otter.adapters.ArtistsAdapter
import com.github.apognu.otter.models.api.FunkwhaleArtist
import com.github.apognu.otter.repositories.ArtistsRepository
import com.github.apognu.otter.utils.AppContext
import com.github.apognu.otter.utils.Artist
import com.github.apognu.otter.utils.onViewPager
import com.github.apognu.otter.models.domain.Artist
import com.github.apognu.otter.viewmodels.ArtistsViewModel
import kotlinx.android.synthetic.main.fragment_artists.*
class ArtistsFragment : OtterFragment<Artist, ArtistsAdapter>() {
class ArtistsFragment : LiveOtterFragment<FunkwhaleArtist, Artist, ArtistsAdapter>() {
override val liveData = ArtistsViewModel.get().artists
override val viewRes = R.layout.fragment_artists
override val recycler: RecyclerView get() = artists
override val alwaysRefresh = false
companion object {
fun openAlbums(context: Context?, artist: Artist, fragment: Fragment? = null, art: String? = null) {
(context as? MainActivity)?.let {
fragment?.let { fragment ->
fragment.onViewPager {
exitTransition = Fade().apply {
duration = AppContext.TRANSITION_DURATION
interpolator = AccelerateDecelerateInterpolator()
fun openAlbums(context: Context?, artist: Artist?, fragment: Fragment? = null, art: String? = null) {
artist?.let {
(context as? MainActivity)?.let {
fragment?.let { fragment ->
fragment.onViewPager {
exitTransition = Fade().apply {
duration = AppContext.TRANSITION_DURATION
interpolator = AccelerateDecelerateInterpolator()
view?.let {
addTarget(it)
view?.let {
addTarget(it)
}
}
}
}
}
}
(context as? AppCompatActivity)?.let { activity ->
val nextFragment = AlbumsFragment.new(artist, art).apply {
enterTransition = Slide().apply {
duration = AppContext.TRANSITION_DURATION
interpolator = AccelerateDecelerateInterpolator()
(context as? AppCompatActivity)?.let { activity ->
val nextFragment = AlbumsFragment.new(artist, art).apply {
enterTransition = Slide().apply {
duration = AppContext.TRANSITION_DURATION
interpolator = AccelerateDecelerateInterpolator()
}
}
}
activity.supportFragmentManager
.beginTransaction()
.replace(R.id.container, nextFragment)
.addToBackStack(null)
.commit()
activity.supportFragmentManager
.beginTransaction()
.replace(R.id.container, nextFragment)
.addToBackStack(null)
.commit()
}
}
}
}
@ -64,9 +73,28 @@ class ArtistsFragment : OtterFragment<Artist, ArtistsAdapter>() {
repository = ArtistsRepository(context)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_artists, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
artists.layoutManager = LinearLayoutManager(context)
(artists.itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false
artists.adapter = adapter
liveData.observe(viewLifecycleOwner) { result ->
adapter.data.size.let { position ->
adapter.data = result.toMutableList()
adapter.notifyItemInserted(position)
}
}
}
inner class OnArtistClickListener : ArtistsAdapter.OnArtistClickListener {
override fun onClick(holder: View?, artist: Artist) {
openAlbums(context, artist, fragment = this@ArtistsFragment)
openAlbums(context, artist, this@ArtistsFragment, artist.album_cover)
}
}
}

View File

@ -2,21 +2,26 @@ package com.github.apognu.otter.fragments
import android.os.Bundle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.observe
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.adapters.FavoritesAdapter
import com.github.apognu.otter.models.api.FunkwhaleTrack
import com.github.apognu.otter.models.domain.Track
import com.github.apognu.otter.repositories.FavoritesRepository
import com.github.apognu.otter.repositories.TracksRepository
import com.github.apognu.otter.utils.*
import com.google.android.exoplayer2.offline.Download
import com.github.apognu.otter.utils.Command
import com.github.apognu.otter.utils.CommandBus
import com.github.apognu.otter.utils.EventBus
import com.github.apognu.otter.viewmodels.PlayerStateViewModel
import com.github.apognu.otter.viewmodels.TracksViewModel
import kotlinx.android.synthetic.main.fragment_favorites.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class FavoritesFragment : OtterFragment<Track, FavoritesAdapter>() {
class FavoritesFragment : LiveOtterFragment<FunkwhaleTrack, Track, FavoritesAdapter>() {
override val liveData = TracksViewModel(0).favorites
override val viewRes = R.layout.fragment_favorites
override val recycler: RecyclerView get() = favorites
override val alwaysRefresh = false
@ -27,23 +32,14 @@ class FavoritesFragment : OtterFragment<Track, FavoritesAdapter>() {
adapter = FavoritesAdapter(context, FavoriteListener())
repository = FavoritesRepository(context)
PlayerStateViewModel.get().track.observe(this) { refreshCurrentTrack(it) }
watchEventBus()
}
override fun onResume() {
super.onResume()
lifecycleScope.launch(IO) {
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
withContext(Main) {
adapter.currentTrack = response.track
adapter.notifyDataSetChanged()
}
}
refreshDownloadedTracks()
}
play.setOnClickListener {
CommandBus.send(Command.ReplaceQueue(adapter.data.shuffled()))
}
@ -53,15 +49,7 @@ class FavoritesFragment : OtterFragment<Track, FavoritesAdapter>() {
lifecycleScope.launch(Main) {
EventBus.get().collect { message ->
when (message) {
is Event.DownloadChanged -> refreshDownloadedTrack(message.download)
}
}
}
lifecycleScope.launch(Main) {
CommandBus.get().collect { command ->
when (command) {
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
// is Event.DownloadChanged -> refreshDownloadedTrack(message.download)
}
}
}
@ -70,17 +58,17 @@ class FavoritesFragment : OtterFragment<Track, FavoritesAdapter>() {
private suspend fun refreshDownloadedTracks() {
val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
withContext(Main) {
/* withContext(Main) {
adapter.data = adapter.data.map {
it.downloaded = downloaded.contains(it.id)
it
}.toMutableList()
adapter.notifyDataSetChanged()
}
} */
}
private suspend fun refreshDownloadedTrack(download: Download) {
/* private suspend fun refreshDownloadedTrack(download: Download) {
if (download.state == Download.STATE_COMPLETED) {
download.getMetadata()?.let { info ->
adapter.data.withIndex().associate { it.value to it.index }.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match ->
@ -91,14 +79,11 @@ class FavoritesFragment : OtterFragment<Track, FavoritesAdapter>() {
}
}
}
}
} */
private fun refreshCurrentTrack(track: Track?) {
track?.let {
adapter.currentTrack?.current = false
adapter.currentTrack = track.apply {
current = true
}
adapter.currentTrack = track
adapter.notifyDataSetChanged()
}
}

View File

@ -5,29 +5,35 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.observe
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.apognu.otter.R
import com.github.apognu.otter.adapters.TracksAdapter
import com.github.apognu.otter.utils.*
import com.github.apognu.otter.models.domain.Track
import com.github.apognu.otter.repositories.FavoritesRepository
import com.github.apognu.otter.viewmodels.QueueViewModel
import kotlinx.android.synthetic.main.partial_queue.*
import kotlinx.android.synthetic.main.partial_queue.view.*
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class LandscapeQueueFragment : Fragment() {
private var adapter: TracksAdapter? = null
private val viewModel = QueueViewModel.get()
lateinit var favoritesRepository: FavoritesRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
watchEventBus()
favoritesRepository = FavoritesRepository(context)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
viewModel.queue.observe(viewLifecycleOwner) {
refresh(it)
}
return inflater.inflate(R.layout.partial_queue, container, false).apply {
adapter = TracksAdapter(context, fromQueue = true).also {
adapter = TracksAdapter(context, FavoriteListener(), fromQueue = true).also {
queue.layoutManager = LinearLayoutManager(context)
queue.adapter = it
}
@ -39,43 +45,28 @@ class LandscapeQueueFragment : Fragment() {
queue?.visibility = View.GONE
placeholder?.visibility = View.VISIBLE
refresh()
}
private fun refresh() {
activity?.lifecycleScope?.launch(Main) {
RequestBus.send(Request.GetQueue).wait<Response.Queue>()?.let { response ->
adapter?.let {
it.data = response.queue.toMutableList()
it.notifyDataSetChanged()
private fun refresh(tracks: List<Track>) {
adapter?.let {
it.data = tracks.toMutableList()
it.notifyDataSetChanged()
if (it.data.isEmpty()) {
queue?.visibility = View.GONE
placeholder?.visibility = View.VISIBLE
} else {
queue?.visibility = View.VISIBLE
placeholder?.visibility = View.GONE
}
}
if (it.data.isEmpty()) {
queue?.visibility = View.GONE
placeholder?.visibility = View.VISIBLE
} else {
queue?.visibility = View.VISIBLE
placeholder?.visibility = View.GONE
}
}
}
private fun watchEventBus() {
activity?.lifecycleScope?.launch(Main) {
EventBus.get().collect { message ->
when (message) {
is Event.QueueChanged -> refresh()
}
}
}
activity?.lifecycleScope?.launch(Main) {
CommandBus.get().collect { command ->
when (command) {
is Command.RefreshTrack -> refresh()
}
inner class FavoriteListener : TracksAdapter.OnFavoriteListener {
override fun onToggleFavorite(id: Int, state: Boolean) {
when (state) {
true -> favoritesRepository.addFavorite(id)
false -> favoritesRepository.deleteFavorite(id)
}
}
}

View File

@ -5,14 +5,17 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.LiveData
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.observe
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import com.github.apognu.otter.repositories.HttpUpstream
import com.github.apognu.otter.repositories.Repository
import com.github.apognu.otter.utils.*
import com.google.gson.Gson
import com.github.apognu.otter.utils.Event
import com.github.apognu.otter.utils.EventBus
import com.github.apognu.otter.utils.untilNetwork
import kotlinx.android.synthetic.main.fragment_artists.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
@ -31,17 +34,18 @@ abstract class OtterAdapter<D, VH : RecyclerView.ViewHolder> : RecyclerView.Adap
abstract override fun getItemId(position: Int): Long
}
abstract class OtterFragment<D : Any, A : OtterAdapter<D, *>> : Fragment() {
abstract class LiveOtterFragment<D : Any, DAO : Any, A : OtterAdapter<DAO, *>> : Fragment() {
companion object {
const val OFFSCREEN_PAGES = 20
}
abstract val liveData: LiveData<List<DAO>>
abstract val viewRes: Int
abstract val recycler: RecyclerView
open val layoutManager: RecyclerView.LayoutManager get() = LinearLayoutManager(context)
open val alwaysRefresh = true
lateinit var repository: Repository<D, *>
lateinit var repository: Repository<D>
lateinit var adapter: A
private var moreLoading = false
@ -58,7 +62,7 @@ abstract class OtterFragment<D : Any, A : OtterAdapter<D, *>> : Fragment() {
(recycler.itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false
recycler.adapter = adapter
(repository.upstream as? HttpUpstream<*, *>)?.let { upstream ->
(repository.upstream as? HttpUpstream<*>)?.let { upstream ->
if (upstream.behavior == HttpUpstream.Behavior.Progressive) {
recycler.setOnScrollChangeListener { _, _, _, _, _ ->
val offset = recycler.computeVerticalScrollOffset()
@ -66,7 +70,7 @@ abstract class OtterFragment<D : Any, A : OtterAdapter<D, *>> : Fragment() {
if (!moreLoading && offset > 0 && needsMoreOffscreenPages()) {
moreLoading = true
fetch(Repository.Origin.Network.origin, adapter.data.size)
fetch(adapter.data.size)
}
}
}
@ -78,17 +82,22 @@ abstract class OtterFragment<D : Any, A : OtterAdapter<D, *>> : Fragment() {
if (event is Event.ListingsChanged) {
withContext(Main) {
swiper?.isRefreshing = true
fetch(Repository.Origin.Network.origin)
fetch()
}
}
}
}
}
fetch(Repository.Origin.Cache.origin)
fetch()
}
if (alwaysRefresh && adapter.data.isEmpty()) {
fetch(Repository.Origin.Network.origin)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
liveData.observe(this) {
adapter.data = it.toMutableList()
adapter.notifyDataSetChanged()
}
}
@ -96,91 +105,38 @@ abstract class OtterFragment<D : Any, A : OtterAdapter<D, *>> : Fragment() {
super.onResume()
swiper?.setOnRefreshListener {
fetch(Repository.Origin.Network.origin)
fetch()
}
}
open fun onDataFetched(data: List<D>) {}
private fun fetch(upstreams: Int = Repository.Origin.Network.origin, size: Int = 0) {
var first = size == 0
if (!moreLoading && upstreams == Repository.Origin.Network.origin) {
lifecycleScope.launch(Main) {
swiper?.isRefreshing = true
}
}
private fun fetch(size: Int = 0) {
moreLoading = true
repository.fetch(upstreams, size).untilNetwork(lifecycleScope, IO) { data, isCache, _, hasMore ->
if (isCache && data.isEmpty()) {
moreLoading = false
return@untilNetwork fetch(Repository.Origin.Network.origin)
}
repository.fetch(size).untilNetwork(lifecycleScope, IO) { data, _, hasMore ->
lifecycleScope.launch(Main) {
if (isCache) {
moreLoading = false
adapter.data = data.toMutableList()
adapter.notifyDataSetChanged()
return@launch
}
if (first) {
adapter.data.clear()
}
onDataFetched(data)
adapter.data.addAll(data)
withContext(IO) {
try {
repository.cacheId?.let { cacheId ->
Cache.set(
context,
cacheId,
Gson().toJson(repository.cache(adapter.data)).toByteArray()
)
}
} catch (e: ConcurrentModificationException) {
}
}
if (hasMore) {
(repository.upstream as? HttpUpstream<*, *>)?.let { upstream ->
if (!isCache && upstream.behavior == HttpUpstream.Behavior.Progressive) {
if (first || needsMoreOffscreenPages()) {
fetch(Repository.Origin.Network.origin, adapter.data.size)
(repository.upstream as? HttpUpstream<*>)?.let { upstream ->
if (upstream.behavior == HttpUpstream.Behavior.Progressive) {
if (size == 0 || needsMoreOffscreenPages()) {
fetch(size + data.size)
} else {
moreLoading = false
}
} else {
moreLoading = false
}
}
}
(repository.upstream as? HttpUpstream<*, *>)?.let { upstream ->
(repository.upstream as? HttpUpstream<*>)?.let { upstream ->
when (upstream.behavior) {
HttpUpstream.Behavior.Progressive -> if (!hasMore || !moreLoading) swiper?.isRefreshing = false
HttpUpstream.Behavior.AtOnce -> if (!hasMore) swiper?.isRefreshing = false
HttpUpstream.Behavior.Single -> if (!hasMore) swiper?.isRefreshing = false
}
}
when (first) {
true -> {
adapter.notifyDataSetChanged()
first = false
}
false -> adapter.notifyItemRangeInserted(adapter.data.size, data.size)
}
}
}
}

View File

@ -5,59 +5,65 @@ import android.view.Gravity
import android.view.View
import androidx.appcompat.widget.PopupMenu
import androidx.core.os.bundleOf
import androidx.lifecycle.LiveData
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.observe
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.adapters.PlaylistTracksAdapter
import com.github.apognu.otter.models.api.FunkwhalePlaylistTrack
import com.github.apognu.otter.models.dao.PlaylistEntity
import com.github.apognu.otter.repositories.FavoritesRepository
import com.github.apognu.otter.repositories.PlaylistTracksRepository
import com.github.apognu.otter.utils.*
import com.github.apognu.otter.viewmodels.PlayerStateViewModel
import com.github.apognu.otter.viewmodels.PlaylistViewModel
import com.github.apognu.otter.models.domain.Track
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.fragment_tracks.*
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class PlaylistTracksFragment : OtterFragment<PlaylistTrack, PlaylistTracksAdapter>() {
class PlaylistTracksFragment : LiveOtterFragment<FunkwhalePlaylistTrack, Track, PlaylistTracksAdapter>() {
override lateinit var liveData: LiveData<List<Track>>
override val viewRes = R.layout.fragment_tracks
override val recycler: RecyclerView get() = tracks
lateinit var favoritesRepository: FavoritesRepository
var albumId = 0
var albumArtist = ""
var albumTitle = ""
var albumCover = ""
var playlistId = 0
var playlistName = ""
companion object {
fun new(playlist: Playlist): PlaylistTracksFragment {
fun new(playlist: PlaylistEntity): PlaylistTracksFragment {
return PlaylistTracksFragment().apply {
arguments = bundleOf(
"albumId" to playlist.id,
"albumArtist" to "N/A",
"albumTitle" to playlist.name,
"albumCover" to ""
"playlistId" to playlist.id,
"playlistName" to playlist.name
)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.apply {
albumId = getInt("albumId")
albumArtist = getString("albumArtist") ?: ""
albumTitle = getString("albumTitle") ?: ""
albumCover = getString("albumCover") ?: ""
playlistId = getInt("playlistId")
playlistName = getString("playlistName") ?: "N/A"
}
liveData = PlaylistViewModel(playlistId).tracks
super.onCreate(savedInstanceState)
adapter = PlaylistTracksAdapter(context, FavoriteListener())
repository = PlaylistTracksRepository(context, albumId)
repository = PlaylistTracksRepository(context, playlistId)
favoritesRepository = FavoritesRepository(context)
watchEventBus()
PlayerStateViewModel.get().track.observe(this) { track ->
adapter.currentTrack = track
adapter.notifyDataSetChanged()
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@ -67,19 +73,12 @@ class PlaylistTracksFragment : OtterFragment<PlaylistTrack, PlaylistTracksAdapte
covers.visibility = View.VISIBLE
artist.text = "Playlist"
title.text = albumTitle
title.text = playlistName
}
override fun onResume() {
super.onResume()
lifecycleScope.launch(Main) {
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
adapter.currentTrack = response.track
adapter.notifyDataSetChanged()
}
}
var coverHeight: Float? = null
scroller.setOnScrollChangeListener { _: View?, _: Int, scrollY: Int, _: Int, _: Int ->
@ -95,7 +94,7 @@ class PlaylistTracksFragment : OtterFragment<PlaylistTrack, PlaylistTracksAdapte
}
play.setOnClickListener {
CommandBus.send(Command.ReplaceQueue(adapter.data.map { it.track }.shuffled()))
CommandBus.send(Command.ReplaceQueue(adapter.data.shuffled()))
context.toast("All tracks were added to your queue")
}
@ -108,12 +107,12 @@ class PlaylistTracksFragment : OtterFragment<PlaylistTrack, PlaylistTracksAdapte
setOnMenuItemClickListener {
when (it.itemId) {
R.id.add_to_queue -> {
CommandBus.send(Command.AddToQueue(adapter.data.map { it.track }))
CommandBus.send(Command.AddToQueue(adapter.data))
context.toast("All tracks were added to your queue")
}
R.id.download -> CommandBus.send(Command.PinTracks(adapter.data.map { it.track }))
R.id.download -> CommandBus.send(Command.PinTracks(adapter.data))
}
true
@ -125,8 +124,8 @@ class PlaylistTracksFragment : OtterFragment<PlaylistTrack, PlaylistTracksAdapte
}
}
override fun onDataFetched(data: List<PlaylistTrack>) {
data.map { it.track.album }.toSet().map { it?.cover() }.take(4).forEachIndexed { index, url ->
override fun onDataFetched(data: List<FunkwhalePlaylistTrack>) {
data.map { it.track.album }.toSet().map { it?.cover?.urls?.original }.take(4).forEachIndexed { index, url ->
val imageView = when (index) {
0 -> cover_top_left
1 -> cover_top_right
@ -156,23 +155,6 @@ class PlaylistTracksFragment : OtterFragment<PlaylistTrack, PlaylistTracksAdapte
}
}
private fun watchEventBus() {
lifecycleScope.launch(Main) {
CommandBus.get().collect { command ->
when (command) {
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
}
}
}
}
private fun refreshCurrentTrack(track: Track?) {
track?.let {
adapter.currentTrack = track
adapter.notifyDataSetChanged()
}
}
inner class FavoriteListener : PlaylistTracksAdapter.OnFavoriteListener {
override fun onToggleFavorite(id: Int, state: Boolean) {
when (state) {

View File

@ -9,12 +9,15 @@ import androidx.transition.Slide
import com.github.apognu.otter.R
import com.github.apognu.otter.activities.MainActivity
import com.github.apognu.otter.adapters.PlaylistsAdapter
import com.github.apognu.otter.models.api.FunkwhalePlaylist
import com.github.apognu.otter.models.dao.PlaylistEntity
import com.github.apognu.otter.repositories.PlaylistsRepository
import com.github.apognu.otter.utils.AppContext
import com.github.apognu.otter.utils.Playlist
import com.github.apognu.otter.viewmodels.PlaylistsViewModel
import kotlinx.android.synthetic.main.fragment_playlists.*
class PlaylistsFragment : OtterFragment<Playlist, PlaylistsAdapter>() {
class PlaylistsFragment : LiveOtterFragment<FunkwhalePlaylist, PlaylistEntity, PlaylistsAdapter>() {
override val liveData = PlaylistsViewModel().playlists
override val viewRes = R.layout.fragment_playlists
override val recycler: RecyclerView get() = playlists
override val alwaysRefresh = false
@ -27,7 +30,7 @@ class PlaylistsFragment : OtterFragment<Playlist, PlaylistsAdapter>() {
}
inner class OnPlaylistClickListener : PlaylistsAdapter.OnPlaylistClickListener {
override fun onClick(holder: View?, playlist: Playlist) {
override fun onClick(holder: View?, playlist: PlaylistEntity) {
(context as? MainActivity)?.let { activity ->
exitTransition = Fade().apply {
duration = AppContext.TRANSITION_DURATION

View File

@ -6,25 +6,26 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.observe
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.apognu.otter.R
import com.github.apognu.otter.adapters.TracksAdapter
import com.github.apognu.otter.models.domain.Track
import com.github.apognu.otter.repositories.FavoritesRepository
import com.github.apognu.otter.utils.*
import com.github.apognu.otter.utils.Command
import com.github.apognu.otter.utils.CommandBus
import com.github.apognu.otter.viewmodels.QueueViewModel
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kotlinx.android.synthetic.main.fragment_queue.*
import kotlinx.android.synthetic.main.fragment_queue.view.*
import kotlinx.android.synthetic.main.partial_queue.*
import kotlinx.android.synthetic.main.partial_queue.view.*
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class QueueFragment : BottomSheetDialogFragment() {
private var adapter: TracksAdapter? = null
private val viewModel = QueueViewModel.get()
lateinit var favoritesRepository: FavoritesRepository
override fun onCreate(savedInstanceState: Bundle?) {
@ -33,8 +34,6 @@ class QueueFragment : BottomSheetDialogFragment() {
favoritesRepository = FavoritesRepository(context)
setStyle(DialogFragment.STYLE_NORMAL, R.style.AppTheme_FloatingBottomSheet)
watchEventBus()
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
@ -48,6 +47,10 @@ class QueueFragment : BottomSheetDialogFragment() {
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
viewModel.queue.observe(viewLifecycleOwner) {
refresh(it)
}
return inflater.inflate(R.layout.fragment_queue, container, false).apply {
adapter = TracksAdapter(context, FavoriteListener(), fromQueue = true).also {
included.queue.layoutManager = LinearLayoutManager(context)
@ -69,44 +72,20 @@ class QueueFragment : BottomSheetDialogFragment() {
queue_clear.setOnClickListener {
CommandBus.send(Command.ClearQueue)
}
refresh()
}
private fun refresh() {
lifecycleScope.launch(Main) {
RequestBus.send(Request.GetQueue).wait<Response.Queue>()?.let { response ->
included?.let { included ->
adapter?.let {
it.data = response.queue.toMutableList()
it.notifyDataSetChanged()
private fun refresh(tracks: List<Track>) {
included?.let { included ->
adapter?.let {
it.data = tracks.toMutableList()
it.notifyDataSetChanged()
if (it.data.isEmpty()) {
included.queue?.visibility = View.GONE
placeholder?.visibility = View.VISIBLE
} else {
included.queue?.visibility = View.VISIBLE
placeholder?.visibility = View.GONE
}
}
}
}
}
}
private fun watchEventBus() {
lifecycleScope.launch(Main) {
EventBus.get().collect { message ->
when (message) {
is Event.QueueChanged -> refresh()
}
}
}
lifecycleScope.launch(Main) {
CommandBus.get().collect { command ->
when (command) {
is Command.RefreshTrack -> refresh()
if (it.data.isEmpty()) {
included.queue?.visibility = View.GONE
placeholder?.visibility = View.VISIBLE
} else {
included.queue?.visibility = View.VISIBLE
placeholder?.visibility = View.GONE
}
}
}

View File

@ -6,14 +6,18 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.adapters.RadiosAdapter
import com.github.apognu.otter.models.api.FunkwhaleRadio
import com.github.apognu.otter.models.dao.RadioEntity
import com.github.apognu.otter.repositories.RadiosRepository
import com.github.apognu.otter.utils.*
import com.github.apognu.otter.viewmodels.RadiosViewModel
import kotlinx.android.synthetic.main.fragment_radios.*
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class RadiosFragment : OtterFragment<Radio, RadiosAdapter>() {
class RadiosFragment : LiveOtterFragment<FunkwhaleRadio, RadioEntity, RadiosAdapter>() {
override val liveData = RadiosViewModel().radios
override val viewRes = R.layout.fragment_radios
override val recycler: RecyclerView get() = radios
override val alwaysRefresh = false
@ -26,13 +30,14 @@ class RadiosFragment : OtterFragment<Radio, RadiosAdapter>() {
}
inner class RadioClickListener : RadiosAdapter.OnRadioClickListener {
override fun onClick(holder: RadiosAdapter.ViewHolder, radio: Radio) {
override fun onClick(holder: RadiosAdapter.ViewHolder, radio: RadioEntity) {
holder.spin()
recycler.forEach {
it.isEnabled = false
it.isClickable = false
}
// TOBEREDONE
CommandBus.send(Command.PlayRadio(radio))
lifecycleScope.launch(Main) {

View File

@ -11,9 +11,9 @@ import android.widget.TextView
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import com.github.apognu.otter.R
import com.github.apognu.otter.utils.Track
import com.github.apognu.otter.utils.mustNormalizeUrl
import com.github.apognu.otter.utils.toDurationString
import com.github.apognu.otter.models.domain.Track
import kotlinx.android.synthetic.main.fragment_track_info_details.*
class TrackInfoDetailsFragment : DialogFragment() {
@ -21,7 +21,7 @@ class TrackInfoDetailsFragment : DialogFragment() {
fun new(track: Track): TrackInfoDetailsFragment {
return TrackInfoDetailsFragment().apply {
arguments = bundleOf(
"artistName" to track.artist.name,
"artistName" to track.artist?.name,
"albumTitle" to track.album?.title,
"trackTitle" to track.title,
"trackCopyright" to track.copyright,

View File

@ -6,14 +6,20 @@ import android.view.View
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.widget.PopupMenu
import androidx.core.os.bundleOf
import androidx.lifecycle.LiveData
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.observe
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.adapters.TracksAdapter
import com.github.apognu.otter.models.api.FunkwhaleTrack
import com.github.apognu.otter.models.domain.Album
import com.github.apognu.otter.models.domain.Track
import com.github.apognu.otter.repositories.FavoritedRepository
import com.github.apognu.otter.repositories.FavoritesRepository
import com.github.apognu.otter.repositories.TracksRepository
import com.github.apognu.otter.utils.*
import com.github.apognu.otter.viewmodels.*
import com.google.android.exoplayer2.offline.Download
import com.preference.PowerPreference
import com.squareup.picasso.Picasso
@ -25,7 +31,8 @@ import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class TracksFragment : OtterFragment<Track, TracksAdapter>() {
class TracksFragment : LiveOtterFragment<FunkwhaleTrack, Track, TracksAdapter>() {
override lateinit var liveData: LiveData<List<Track>>
override val viewRes = R.layout.fragment_tracks
override val recycler: RecyclerView get() = tracks
@ -40,26 +47,36 @@ class TracksFragment : OtterFragment<Track, TracksAdapter>() {
companion object {
fun new(album: Album): TracksFragment {
return TracksFragment().apply {
arguments = bundleOf(
"albumId" to album.id,
"albumArtist" to album.artist.name,
"albumTitle" to album.title,
"albumCover" to album.cover()
)
arguments = bundleOf("albumId" to album.id)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.apply {
albumId = getInt("albumId")
albumArtist = getString("albumArtist") ?: ""
albumTitle = getString("albumTitle") ?: ""
albumCover = getString("albumCover") ?: ""
}
liveData = TracksViewModel(albumId).tracks
AlbumViewModel(albumId).album.observe(this) {
title.text = it.title
Picasso.get()
.maybeLoad(maybeNormalizeUrl(it.cover))
.noFade()
.fit()
.centerCrop()
.transform(RoundedCornersTransformation(16, 0))
.into(cover)
ArtistViewModel(it.artist_id).artist.observe(this) {
artist.text = it.name
}
}
super.onCreate(savedInstanceState)
adapter = TracksAdapter(context, FavoriteListener())
repository = TracksRepository(context, albumId)
favoritesRepository = FavoritesRepository(context)
@ -68,33 +85,9 @@ class TracksFragment : OtterFragment<Track, TracksAdapter>() {
watchEventBus()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Picasso.get()
.maybeLoad(maybeNormalizeUrl(albumCover))
.noFade()
.fit()
.centerCrop()
.transform(RoundedCornersTransformation(16, 0))
.into(cover)
artist.text = albumArtist
title.text = albumTitle
}
override fun onResume() {
super.onResume()
lifecycleScope.launch(Main) {
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
adapter.currentTrack = response.track
adapter.notifyDataSetChanged()
}
refreshDownloadedTracks()
}
var coverHeight: Float? = null
scroller.setOnScrollChangeListener { _: View?, _: Int, scrollY: Int, _: Int, _: Int ->
@ -171,24 +164,16 @@ class TracksFragment : OtterFragment<Track, TracksAdapter>() {
}
}
}
lifecycleScope.launch(Main) {
CommandBus.get().collect { command ->
when (command) {
is Command.RefreshTrack -> refreshCurrentTrack(command.track)
}
}
}
}
private suspend fun refreshDownloadedTracks() {
val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
withContext(Main) {
adapter.data = adapter.data.map {
/* adapter.data = adapter.data.map {
it.downloaded = downloaded.contains(it.id)
it
}.toMutableList()
}.toMutableList() */
adapter.notifyDataSetChanged()
}
@ -198,26 +183,15 @@ class TracksFragment : OtterFragment<Track, TracksAdapter>() {
if (download.state == Download.STATE_COMPLETED) {
download.getMetadata()?.let { info ->
adapter.data.withIndex().associate { it.value to it.index }.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match ->
withContext(Main) {
/* withContext(Main) {
adapter.data[match.second].downloaded = true
adapter.notifyItemChanged(match.second)
}
} */
}
}
}
}
private fun refreshCurrentTrack(track: Track?) {
track?.let {
adapter.currentTrack?.current = false
adapter.currentTrack = track.apply {
current = true
}
adapter.notifyDataSetChanged()
}
}
inner class FavoriteListener : TracksAdapter.OnFavoriteListener {
override fun onToggleFavorite(id: Int, state: Boolean) {
when (state) {

View File

@ -0,0 +1,24 @@
package com.github.apognu.otter.models.api
import kotlinx.serialization.Serializable
@Serializable
data class FunkwhaleAlbum(
val id: Int,
val artist: Artist,
val title: String,
val cover: Covers?,
val release_date: String?
) {
@Serializable
data class Artist(val id: Int, val name: String)
fun cover() = cover?.urls?.original
}
@Serializable
data class Covers(val urls: CoverUrls?)
@Serializable
data class CoverUrls(val original: String?)

View File

@ -0,0 +1,19 @@
package com.github.apognu.otter.models.api
import kotlinx.serialization.Serializable
@Serializable
data class FunkwhaleArtist(
val id: Int,
val name: String,
val albums: List<Album>? = null
) {
@Serializable
data class Album(
val id: Int,
val title: String,
val cover: Covers?,
val release_date: String?
)
}

View File

@ -0,0 +1,27 @@
package com.github.apognu.otter.models.api
import kotlinx.serialization.*
@Serializable
data class OtterResponse<D : Any>(
val count: Int,
val next: String? = null,
val results: List<D>
)
@Serializer(forClass = OtterResponse::class)
class OtterResponseSerializer<T : Any>(private val dataSerializer: KSerializer<T>) : KSerializer<OtterResponse<T>> {
override val descriptor = PrimitiveDescriptor("OtterResponse", kind = PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: OtterResponse<T>) {}
override fun deserialize(decoder: Decoder): OtterResponse<T> {
return OtterResponse.serializer(dataSerializer).deserialize(decoder)
}
}
@Serializable
data class Credentials(val token: String, val non_field_errors: List<String>? = null)
@Serializable
data class User(val full_username: String)

View File

@ -0,0 +1,15 @@
package com.github.apognu.otter.models.api
import kotlinx.serialization.Serializable
@Serializable
data class FunkwhalePlaylist(
val id: Int,
val name: String,
val album_covers: List<String>,
val tracks_count: Int,
val duration: Int?
)
@Serializable
data class FunkwhalePlaylistTrack(val track: FunkwhaleTrack)

View File

@ -0,0 +1,12 @@
package com.github.apognu.otter.models.api
import kotlinx.serialization.Serializable
@Serializable
data class FunkwhaleRadio(
val id: Int,
var radio_type: String? = null,
val name: String,
val description: String,
var related_object_id: String? = null
)

View File

@ -0,0 +1,64 @@
package com.github.apognu.otter.models.api
import com.github.apognu.otter.models.domain.SearchResult
import com.google.android.exoplayer2.offline.Download
import kotlinx.serialization.Serializable
@Serializable
data class FunkwhaleTrack(
val id: Int = 0,
val title: String,
val artist: FunkwhaleArtist,
val album: FunkwhaleAlbum?,
val disc_number: Int? = null,
val position: Int = 0,
val uploads: List<FunkwhaleUpload> = listOf(),
val copyright: String? = null,
val license: String? = null
) : SearchResult {
var current: Boolean = false
var favorite: Boolean = false
var cached: Boolean = false
var downloaded: Boolean = false
companion object {
fun fromDownload(download: DownloadInfo): FunkwhaleTrack = FunkwhaleTrack(
id = download.id,
title = download.title,
artist = FunkwhaleArtist(0, download.artist, listOf()),
album = FunkwhaleAlbum(0, FunkwhaleAlbum.Artist(0, ""), "", Covers(CoverUrls("")), ""),
uploads = listOf(FunkwhaleUpload(download.contentId, 0, 0))
)
}
@Serializable
data class FunkwhaleUpload(
val listen_url: String,
val duration: Int,
val bitrate: Int
)
override fun hashCode() = id
override fun equals(other: Any?): Boolean {
return when (other) {
is FunkwhaleTrack -> other.id == id
else -> false
}
}
override fun cover() = album?.cover()
override fun title() = title
override fun subtitle() = artist.name
}
@Serializable
data class Favorited(val track: Int)
data class DownloadInfo(
val id: Int,
val contentId: String,
val title: String,
val artist: String,
var download: Download?
)

View File

@ -0,0 +1,66 @@
package com.github.apognu.otter.models.dao
import androidx.lifecycle.LiveData
import androidx.room.*
import com.github.apognu.otter.models.api.FunkwhaleAlbum
import com.github.apognu.otter.models.api.FunkwhaleArtist
@Entity(tableName = "albums")
data class AlbumEntity(
@PrimaryKey
val id: Int,
val title: String,
@ForeignKey(entity = ArtistEntity::class, parentColumns = ["id"], childColumns = ["artist_id"], onDelete = ForeignKey.CASCADE)
val artist_id: Int,
val cover: String?,
val release_date: String?
) {
@androidx.room.Dao
interface Dao {
@Query("SELECT * FROM DecoratedAlbumEntity")
fun allDecorated(): LiveData<List<DecoratedAlbumEntity>>
@Query("SELECT * FROM DecoratedAlbumEntity ORDER BY release_date")
fun allSync(): List<DecoratedAlbumEntity>
@Query("SELECT * FROM DecoratedAlbumEntity WHERE id IN ( :ids ) ORDER BY release_date")
fun findAllDecorated(ids: List<Int>): LiveData<List<DecoratedAlbumEntity>>
@Query("SELECT * FROM DecoratedAlbumEntity WHERE id == :id")
fun getDecorated(id: Int): LiveData<DecoratedAlbumEntity>
@Query("SELECT * FROM DecoratedAlbumEntity WHERE id == :id")
fun getDecoratedBlocking(id: Int): DecoratedAlbumEntity
@Query("SELECT * FROM DecoratedAlbumEntity WHERE artist_id = :artistId")
fun forArtistDecorated(artistId: Int): LiveData<List<DecoratedAlbumEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(album: AlbumEntity)
}
}
fun FunkwhaleAlbum.toDao() = run {
AlbumEntity(id, title, artist.id, cover(), release_date)
}
fun FunkwhaleArtist.Album.toDao(artistId: Int) = run {
AlbumEntity(id, title, artistId, cover?.urls?.original, release_date)
}
@DatabaseView("""
SELECT albums.*, artists.name AS artist_name
FROM albums
INNER JOIN artists
ON artists.id = albums.artist_id
ORDER BY albums.release_date
""")
data class DecoratedAlbumEntity(
val id: Int,
val title: String,
val artist_id: Int,
val cover: String?,
val release_date: String?,
val artist_name: String
)

View File

@ -0,0 +1,64 @@
package com.github.apognu.otter.models.dao
import androidx.lifecycle.LiveData
import androidx.room.*
import com.github.apognu.otter.models.api.FunkwhaleArtist
import io.realm.RealmObject
import io.realm.annotations.Required
@Entity(tableName = "artists")
data class ArtistEntity(
@PrimaryKey
val id: Int,
@ColumnInfo(collate = ColumnInfo.LOCALIZED, index = true)
val name: String
) {
@androidx.room.Dao
interface Dao {
@Query("SELECT * FROM DecoratedArtistEntity")
fun allDecorated(): LiveData<List<DecoratedArtistEntity>>
@Query("SELECT * FROM DecoratedArtistEntity WHERE id == :id")
fun getDecorated(id: Int): LiveData<DecoratedArtistEntity>
@Query("SELECT * FROM DecoratedArtistEntity WHERE id == :id")
fun getDecoratedBlocking(id: Int): DecoratedArtistEntity
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(artist: ArtistEntity)
@Query("DELETE FROM artists")
fun deleteAll()
}
}
fun FunkwhaleArtist.toDao() = run {
ArtistEntity(id, name)
}
@DatabaseView("""
SELECT artists.id, artists.name, COUNT(*) AS album_count, albums.cover AS album_cover
FROM artists
INNER JOIN albums
ON albums.artist_id = artists.id
GROUP BY albums.artist_id
ORDER BY name
""")
data class DecoratedArtistEntity(
val id: Int,
val name: String,
val album_count: Int,
val album_cover: String?
)
open class RealmArtist(
@io.realm.annotations.PrimaryKey
var id: Int = 0,
@Required
var name: String = ""
) : RealmObject()
fun FunkwhaleArtist.toRealmDao() = run {
RealmArtist(id, name)
}

View File

@ -0,0 +1,26 @@
package com.github.apognu.otter.models.dao
import androidx.lifecycle.LiveData
import androidx.room.*
@Entity(tableName = "favorites")
data class FavoriteEntity(
@PrimaryKey
val track_id: Int
) {
@androidx.room.Dao
interface Dao {
@Query("SELECT * FROM favorites")
fun all(): LiveData<List<FavoriteEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(trackId: FavoriteEntity)
@Query("INSERT OR REPLACE INTO favorites VALUES ( :trackId )")
fun add(trackId: Int)
@Query("DELETE FROM favorites WHERE track_id = :trackId")
fun remove(trackId: Int)
}
}

View File

@ -0,0 +1,38 @@
package com.github.apognu.otter.models.dao
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@Database(
version = 1,
entities = [
ArtistEntity::class,
AlbumEntity::class,
TrackEntity::class,
UploadEntity::class,
QueueItemEntity::class,
PlaylistEntity::class,
PlaylistTrack::class,
RadioEntity::class,
FavoriteEntity::class
],
views = [
DecoratedArtistEntity::class,
DecoratedAlbumEntity::class,
DecoratedTrackEntity::class
]
)
@TypeConverters(StringListConverter::class)
abstract class OtterDatabase : RoomDatabase() {
abstract fun artists(): ArtistEntity.Dao
abstract fun albums(): AlbumEntity.Dao
abstract fun tracks(): TrackEntity.Dao
abstract fun uploads(): UploadEntity.Dao
abstract fun queue(): QueueItemEntity.Dao
abstract fun playlists(): PlaylistEntity.Dao
abstract fun radios(): RadioEntity.Dao
abstract fun favorites(): FavoriteEntity.Dao
}

View File

@ -0,0 +1,65 @@
package com.github.apognu.otter.models.dao
import androidx.lifecycle.LiveData
import androidx.room.*
import com.github.apognu.otter.models.api.FunkwhalePlaylist
@Entity(tableName = "playlists")
data class PlaylistEntity(
@PrimaryKey
val id: Int,
val name: String,
val album_covers: List<String>,
val tracks_count: Int,
val duration: Int
) {
@androidx.room.Dao
interface Dao {
@Query("SELECT * FROM playlists ORDER BY name")
fun all(): LiveData<List<PlaylistEntity>>
@Transaction
@Query("SELECT * FROM DecoratedTrackEntity WHERE id IN ( SELECT track_id FROM playlist_tracks WHERE playlist_id = :id )")
fun tracksFor(id: Int): LiveData<List<DecoratedTrackEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(playlist: PlaylistEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun addTracks(tracks: List<PlaylistTrack>)
@Query("DELETE FROM playlist_tracks WHERE playlist_id = :id")
fun deleteTracksFor(id: Int)
@Transaction
fun replaceTracks(id: Int, tracks: List<PlaylistTrack>) {
deleteTracksFor(id)
addTracks(tracks)
}
}
}
fun FunkwhalePlaylist.toDao(): PlaylistEntity = run {
PlaylistEntity(id, name, album_covers, tracks_count, duration ?: 0)
}
@Entity(tableName = "playlist_tracks", primaryKeys = ["playlist_id", "track_id"])
data class PlaylistTrack(
val playlist_id: Int,
val track_id: Int
)
object StringListConverter {
@TypeConverter
@JvmStatic
fun fromString(value: String): List<String> {
return value.split(",").toList()
}
@TypeConverter
@JvmStatic
fun toString(value: List<String>): String {
return value.joinToString(",")
}
}

View File

@ -0,0 +1,55 @@
package com.github.apognu.otter.models.dao
import androidx.lifecycle.LiveData
import androidx.room.*
import com.github.apognu.otter.models.domain.Track
@Entity(tableName = "queue")
data class QueueItemEntity(
@PrimaryKey
val position: Int,
@ForeignKey(entity = TrackEntity::class, parentColumns = ["id"], childColumns = ["track_id"], onDelete = ForeignKey.CASCADE)
val trackId: Int
) {
@androidx.room.Dao
interface Dao {
@Transaction
@Query("""
SELECT tracks.*
FROM DecoratedTrackEntity tracks
INNER JOIN queue
ON queue.trackId = tracks.id
ORDER BY queue.position
""")
fun allDecorated(): LiveData<List<DecoratedTrackEntity>>
@Transaction
@Query("""
SELECT tracks.*
FROM DecoratedTrackEntity tracks
INNER JOIN queue
ON queue.trackId = tracks.id
ORDER BY queue.position
""")
fun allDecoratedBlocking(): List<DecoratedTrackEntity>
@Query("DELETE FROM queue")
fun empty()
@Insert
fun insertAll(tracks: List<QueueItemEntity>)
@Transaction
fun replace(tracks: List<Track>) {
empty()
insertAll(tracks.mapIndexed { position, track ->
track.toQueueItemDao(position)
})
}
}
}
fun Track.toQueueItemDao(position: Int = 0): QueueItemEntity = run {
QueueItemEntity(position, id)
}

View File

@ -0,0 +1,29 @@
package com.github.apognu.otter.models.dao
import androidx.lifecycle.LiveData
import androidx.room.*
import com.github.apognu.otter.models.api.FunkwhaleRadio
@Entity(tableName = "radios")
data class RadioEntity(
@PrimaryKey
val id: Int,
var radio_type: String?,
val name: String,
val description: String,
var related_object_id: String? = null
) {
@androidx.room.Dao
interface Dao {
@Query("SELECT * FROM radios ORDER BY name")
fun all(): LiveData<List<RadioEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(radio: RadioEntity)
}
}
fun FunkwhaleRadio.toDao(): RadioEntity = run {
RadioEntity(id, radio_type, name, description, related_object_id)
}

View File

@ -0,0 +1,124 @@
package com.github.apognu.otter.models.dao
import androidx.lifecycle.LiveData
import androidx.room.*
import androidx.room.ForeignKey.CASCADE
import com.github.apognu.otter.Otter
import com.github.apognu.otter.models.api.FunkwhaleTrack
@Entity(tableName = "tracks")
data class TrackEntity(
@PrimaryKey
val id: Int,
val title: String,
@ForeignKey(entity = ArtistEntity::class, parentColumns = ["id"], childColumns = ["artist_id"], onDelete = CASCADE)
val artist_id: Int,
@ForeignKey(entity = AlbumEntity::class, parentColumns = ["id"], childColumns = ["album_id"], onDelete = CASCADE)
val album_id: Int?,
val position: Int?,
val copyright: String?,
val license: String?
) {
@androidx.room.Dao
interface Dao {
@Transaction
@Query("SELECT * FROM DecoratedTrackEntity WHERE id = :id")
fun find(id: Int): DecoratedTrackEntity
@Transaction
@Query("SELECT * FROM DecoratedTrackEntity WHERE id IN ( :ids )")
fun findAllDecorated(ids: List<Int>): LiveData<List<DecoratedTrackEntity>>
@Transaction
@Query("SELECT * FROM DecoratedTrackEntity WHERE id = :id")
fun getDecorated(id: Int): LiveData<DecoratedTrackEntity>
@Transaction
@Query("SELECT * FROM DecoratedTrackEntity WHERE id = :id")
fun getDecoratedBlocking(id: Int): DecoratedTrackEntity
@Transaction
@Query("SELECT * FROM DecoratedTrackEntity WHERE album_id IN ( :albumIds )")
fun ofAlbumsDecorated(albumIds: List<Int>): LiveData<List<DecoratedTrackEntity>>
@Transaction
@Query("SELECT * FROM DecoratedTrackEntity WHERE artist_id = :artistId")
suspend fun ofArtistBlocking(artistId: Int): List<DecoratedTrackEntity>
@Transaction
@Query("""
SELECT tracks.*
FROM DecoratedTrackEntity tracks
INNER JOIN favorites
WHERE favorites.track_id = tracks.id
""")
fun favorites(): LiveData<List<DecoratedTrackEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(track: TrackEntity)
@Transaction
fun insertWithAssocs(track: FunkwhaleTrack) {
Otter.get().database.artists().insert(track.artist.toDao())
track.album?.let {
Otter.get().database.albums().insert(it.toDao())
}
insert(track.toDao())
track.uploads.forEach {
Otter.get().database.uploads().insert(it.toDao(track.id))
}
}
}
}
fun FunkwhaleTrack.toDao() = run {
TrackEntity(
id,
title,
artist.id,
album?.id,
position,
copyright,
license
)
}
@DatabaseView("""
SELECT
tracks.id, tracks.title, tracks.position, tracks.copyright, tracks.license,
ar.id AS artist_id, ar.name AS artist_name, ar.album_count AS artist_album_count, ar.album_cover AS artist_album_cover,
al.id AS album_id, al.title AS album_title, al.artist_id AS album_artist_id, al.cover AS album_cover, al.release_date AS album_release_date, al.artist_name AS album_artist_name,
CASE
WHEN favorites.track_id IS NULL THEN 0
ELSE 1
END AS favorite
FROM tracks
LEFT JOIN DecoratedAlbumEntity al
ON al.id = tracks.album_id
LEFT JOIN DecoratedArtistEntity ar
ON ar.id = al.artist_id
LEFT JOIN favorites
ON favorites.track_id = tracks.id
""")
data class DecoratedTrackEntity(
val id: Int,
val title: String,
val position: Int?,
val copyright: String?,
val license: String?,
// Virtual attributes
val favorite: Boolean,
// Associations
@Embedded(prefix = "artist_")
val artist: DecoratedArtistEntity?,
@Embedded(prefix = "album_")
val album: DecoratedAlbumEntity?,
@Relation(entityColumn = "track_id", parentColumn = "id")
val uploads: List<UploadEntity>
)

View File

@ -0,0 +1,37 @@
package com.github.apognu.otter.models.dao
import androidx.lifecycle.LiveData
import androidx.room.*
import com.github.apognu.otter.models.api.FunkwhaleTrack
@Entity(tableName = "uploads")
data class UploadEntity(
@PrimaryKey
val listen_url: String,
@ForeignKey(entity = TrackEntity::class, parentColumns = ["id"], childColumns = ["track_id"], onDelete = ForeignKey.CASCADE)
val track_id: Int,
val duration: Int,
val bitrate: Int
) {
@androidx.room.Dao
interface Dao {
@Query("SELECT * FROM uploads WHERE track_id IN ( :ids )")
fun findAll(ids: List<Int>): LiveData<List<UploadEntity>>
@Query("SELECT * FROM uploads WHERE track_id IN ( :ids )")
suspend fun findAllBlocking(ids: List<Int>): List<UploadEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(upload: UploadEntity)
}
}
fun FunkwhaleTrack.FunkwhaleUpload.toDao(trackId: Int): UploadEntity = run {
UploadEntity(
listen_url,
trackId,
duration,
bitrate
)
}

View File

@ -0,0 +1,30 @@
package com.github.apognu.otter.models.domain
import com.github.apognu.otter.models.dao.DecoratedAlbumEntity
data class Album(
val id: Int,
val title: String,
val artist_id: Int,
val cover: String? = null,
val release_date: String? = null,
var artist_name: String = ""
): SearchResult {
companion object {
fun fromDecoratedEntity(entity: DecoratedAlbumEntity): Album = entity.run {
Album(
id,
title,
artist_id,
cover,
release_date,
artist_name
)
}
}
override fun cover() = cover
override fun title() = title
override fun subtitle() = artist_name
}

View File

@ -0,0 +1,27 @@
package com.github.apognu.otter.models.domain
import com.github.apognu.otter.models.dao.DecoratedArtistEntity
data class Artist(
val id: Int,
val name: String,
val album_count: Int = 0,
val album_cover: String? = "",
var albums: List<Album> = listOf()
) : SearchResult {
companion object {
fun fromDecoratedEntity(entity: DecoratedArtistEntity): Artist = entity.run {
Artist(
id,
name,
album_count = album_count,
album_cover = album_cover
)
}
}
override fun cover() = album_cover
override fun title() = name
override fun subtitle() = "Artist"
}

View File

@ -0,0 +1,7 @@
package com.github.apognu.otter.models.domain
interface SearchResult {
fun cover(): String?
fun title(): String
fun subtitle(): String
}

View File

@ -0,0 +1,54 @@
package com.github.apognu.otter.models.domain
import com.github.apognu.otter.models.dao.DecoratedTrackEntity
import com.preference.PowerPreference
data class Track(
val id: Int,
val title: String,
val position: Int?,
val copyright: String?,
val license: String?,
// Virtual attributes
val favorite: Boolean,
var current: Boolean = false,
var cached: Boolean = false,
var downloaded: Boolean = false,
// Associations
val artist: Artist? = null,
val album: Album? = null,
var uploads: List<Upload> = listOf()
) : SearchResult {
companion object {
fun fromDecoratedEntity(entity: DecoratedTrackEntity) = entity.run {
Track(
id,
title,
position,
copyright,
license,
favorite,
artist = entity.artist?.let { Artist.fromDecoratedEntity(it) },
album = entity.album?.let { Album.fromDecoratedEntity(it) },
uploads = uploads.map { Upload.fromEntity(it) }
)
}
}
override fun cover() = album?.cover
override fun title() = title
override fun subtitle() = album?.title ?: "N/A"
fun bestUpload(): Upload? {
if (uploads.isEmpty()) return null
return when (PowerPreference.getDefaultFile().getString("media_cache_quality")) {
"quality" -> uploads.maxBy { it.bitrate } ?: uploads[0]
"size" -> uploads.minBy { it.bitrate } ?: uploads[0]
else -> uploads.maxBy { it.bitrate } ?: uploads[0]
}
}
}

View File

@ -0,0 +1,22 @@
package com.github.apognu.otter.models.domain
import com.github.apognu.otter.models.dao.UploadEntity
data class Upload(
val listen_url: String,
val track_id: Int,
val duration: Int,
val bitrate: Int
) {
companion object {
fun fromEntity(upload: UploadEntity): Upload = upload.run {
Upload(
listen_url,
track_id,
duration,
bitrate
)
}
}
}

View File

@ -14,8 +14,8 @@ import com.github.apognu.otter.Otter
import com.github.apognu.otter.R
import com.github.apognu.otter.activities.MainActivity
import com.github.apognu.otter.utils.AppContext
import com.github.apognu.otter.utils.Track
import com.github.apognu.otter.utils.maybeNormalizeUrl
import com.github.apognu.otter.models.domain.Track
import com.squareup.picasso.Picasso
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.Default
@ -68,7 +68,7 @@ class MediaControlsManager(val context: Service, private val scope: CoroutineSco
this
}
.setContentTitle(track.title)
.setContentText(track.artist.name)
.setContentText(track.artist?.name)
.setContentIntent(openPendingIntent)
.setChannelId(AppContext.NOTIFICATION_CHANNEL_MEDIA_CONTROL)
.addAction(

View File

@ -6,7 +6,10 @@ import android.content.Intent
import android.net.Uri
import com.github.apognu.otter.Otter
import com.github.apognu.otter.R
import com.github.apognu.otter.models.api.DownloadInfo
import com.github.apognu.otter.utils.*
import com.github.apognu.otter.viewmodels.DownloadsViewModel
import com.github.apognu.otter.models.domain.Track
import com.google.android.exoplayer2.offline.Download
import com.google.android.exoplayer2.offline.DownloadManager
import com.google.android.exoplayer2.offline.DownloadRequest
@ -17,8 +20,6 @@ import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import java.util.*
class PinService : DownloadService(AppContext.NOTIFICATION_DOWNLOADS) {
@ -33,7 +34,7 @@ class PinService : DownloadService(AppContext.NOTIFICATION_DOWNLOADS) {
track.id,
url,
track.title,
track.artist.name,
track.artist?.name ?: "",
null
)
).toByteArray()
@ -48,17 +49,15 @@ class PinService : DownloadService(AppContext.NOTIFICATION_DOWNLOADS) {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
buildResumeDownloadsIntent(this, PinService::class.java, true)
scope.launch(Main) {
RequestBus.get().collect { request ->
when (request) {
is Request.GetDownloads -> request.channel?.offer(Response.Downloads(getDownloads()))
}
}
}
return super.onStartCommand(intent, flags, startId)
}
override fun onCreate() {
super.onCreate()
DownloadsViewModel.get().cursor.postValue(getDownloads())
}
override fun getDownloadManager() = Otter.get().exoDownloadManager.apply {
addListener(DownloadListener())
}
@ -77,7 +76,15 @@ class PinService : DownloadService(AppContext.NOTIFICATION_DOWNLOADS) {
override fun onDownloadChanged(downloadManager: DownloadManager, download: Download) {
super.onDownloadChanged(downloadManager, download)
EventBus.send(Event.DownloadChanged(download))
if (download.state != Download.STATE_REMOVING) {
EventBus.send(Event.DownloadChanged(download))
}
}
override fun onDownloadRemoved(downloadManager: DownloadManager, download: Download) {
super.onDownloadRemoved(downloadManager, download)
DownloadsViewModel.get().cursor.postValue(getDownloads())
}
}
}

View File

@ -17,7 +17,10 @@ import androidx.core.app.NotificationManagerCompat
import androidx.media.session.MediaButtonReceiver
import com.github.apognu.otter.Otter
import com.github.apognu.otter.R
import com.github.apognu.otter.models.dao.RadioEntity
import com.github.apognu.otter.models.domain.Track
import com.github.apognu.otter.utils.*
import com.github.apognu.otter.viewmodels.PlayerStateViewModel
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlaybackException
import com.google.android.exoplayer2.Player
@ -133,7 +136,7 @@ class PlayerService : Service() {
val (current, duration, percent) = getProgress(true)
ProgressBus.send(current, duration, percent)
PlayerStateViewModel.get().position.postValue(Triple(current, duration, percent))
}
}
@ -145,11 +148,9 @@ class PlayerService : Service() {
CommandBus.get().collect { command ->
when (command) {
is Command.RefreshService -> {
EventBus.send(Event.QueueChanged)
if (queue.metadata.isNotEmpty()) {
CommandBus.send(Command.RefreshTrack(queue.current()))
EventBus.send(Event.StateChanged(player.playWhenReady))
PlayerStateViewModel.get()._track.postValue(queue.current())
PlayerStateViewModel.get().isPlaying.postValue(player.playWhenReady)
}
}
@ -193,6 +194,7 @@ class PlayerService : Service() {
is Command.PlayRadio -> {
queue.clear()
// TOBEREDONE
radioPlayer.play(command.radio)
}
@ -204,24 +206,12 @@ class PlayerService : Service() {
}
}
scope.launch(Main) {
RequestBus.get().collect { request ->
when (request) {
is Request.GetCurrentTrack -> request.channel?.offer(Response.CurrentTrack(queue.current()))
is Request.GetState -> request.channel?.offer(Response.State(player.playWhenReady))
is Request.GetQueue -> request.channel?.offer(Response.Queue(queue.get()))
}
}
}
scope.launch(Main) {
while (true) {
delay(1000)
val (current, duration, percent) = getProgress()
if (player.playWhenReady) {
ProgressBus.send(current, duration, percent)
PlayerStateViewModel.get().position.postValue(getProgress())
}
}
}
@ -281,7 +271,7 @@ class PlayerService : Service() {
if (hasAudioFocus(state)) {
player.playWhenReady = state
EventBus.send(Event.StateChanged(state))
PlayerStateViewModel.get().isPlaying.postValue(state)
}
}
@ -301,7 +291,7 @@ class PlayerService : Service() {
player.next()
Cache.set(this@PlayerService, "progress", "0".toByteArray())
ProgressBus.send(0, 0, 0)
PlayerStateViewModel.get().position.postValue(Triple(0, 0, 0))
}
private fun getProgress(force: Boolean = false): Triple<Int, Int, Int> {
@ -331,7 +321,7 @@ class PlayerService : Service() {
return mediaMetadataBuilder.apply {
putString(MediaMetadataCompat.METADATA_KEY_TITLE, track.title)
putString(MediaMetadataCompat.METADATA_KEY_ARTIST, track.artist.name)
putString(MediaMetadataCompat.METADATA_KEY_ARTIST, track.artist?.name)
putLong(MediaMetadata.METADATA_KEY_DURATION, (track.bestUpload()?.duration?.toLong() ?: 0L) * 1000)
try {
@ -381,24 +371,22 @@ class PlayerService : Service() {
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
super.onPlayerStateChanged(playWhenReady, playbackState)
EventBus.send(Event.StateChanged(playWhenReady))
PlayerStateViewModel.get().isPlaying.postValue(playWhenReady)
if (queue.current == -1) {
CommandBus.send(Command.RefreshTrack(queue.current()))
PlayerStateViewModel.get()._track.postValue(queue.current())
}
when (playWhenReady) {
true -> {
when (playbackState) {
Player.STATE_READY -> mediaControlsManager.updateNotification(queue.current(), true)
Player.STATE_BUFFERING -> EventBus.send(Event.Buffering(true))
Player.STATE_BUFFERING -> PlayerStateViewModel.get().isBuffering.postValue(true)
Player.STATE_ENDED -> {
setPlaybackState(false)
queue.current = 0
player.seekTo(0, C.TIME_UNSET)
ProgressBus.send(0, 0, 0)
}
Player.STATE_IDLE -> {
@ -408,11 +396,11 @@ class PlayerService : Service() {
}
}
if (playbackState != Player.STATE_BUFFERING) EventBus.send(Event.Buffering(false))
if (playbackState != Player.STATE_BUFFERING) PlayerStateViewModel.get().isBuffering.postValue(false)
}
false -> {
EventBus.send(Event.Buffering(false))
PlayerStateViewModel.get().isBuffering.postValue(false)
Build.VERSION_CODES.N.onApi(
{ stopForeground(STOP_FOREGROUND_DETACH) },
@ -446,7 +434,7 @@ class PlayerService : Service() {
Cache.set(this@PlayerService, "current", queue.current.toString().toByteArray())
CommandBus.send(Command.RefreshTrack(queue.current()))
PlayerStateViewModel.get()._track.postValue(queue.current())
}
override fun onPositionDiscontinuity(reason: Int) {

View File

@ -4,8 +4,10 @@ import android.content.Context
import android.net.Uri
import com.github.apognu.otter.Otter
import com.github.apognu.otter.R
import com.github.apognu.otter.models.domain.Track
import com.github.apognu.otter.repositories.QueueRepository
import com.github.apognu.otter.utils.*
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.github.apognu.otter.viewmodels.PlayerStateViewModel
import com.google.android.exoplayer2.source.ConcatenatingMediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory
@ -13,9 +15,13 @@ import com.google.android.exoplayer2.upstream.FileDataSource
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
import com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory
import com.google.android.exoplayer2.util.Util
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class QueueManager(val context: Context) {
private val queueRepository = QueueRepository(GlobalScope)
var metadata: MutableList<Track> = mutableListOf()
val datasources = ConcatenatingMediaSource()
var current = -1
@ -44,34 +50,24 @@ class QueueManager(val context: Context) {
}
init {
Cache.get(context, "queue")?.let { json ->
gsonDeserializerOf(QueueCache::class.java).deserialize(json)?.let { cache ->
metadata = cache.data.toMutableList()
val factory = factory(context)
datasources.addMediaSources(metadata.map { track ->
val url = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
ProgressiveMediaSource.Factory(factory).setTag(track.title).createMediaSource(Uri.parse(url))
})
GlobalScope.launch(IO) {
queueRepository.allBlocking().also {
replace(it.map { Track.fromDecoratedEntity(it) })
}
}
Cache.get(context, "current")?.let { string ->
current = string.readLine().toInt()
PlayerStateViewModel.get()._track.postValue(current())
}
}
private fun persist() {
Cache.set(
context,
"queue",
Gson().toJson(QueueCache(metadata)).toByteArray()
)
}
private fun persist() = queueRepository.replace(metadata)
fun replace(tracks: List<Track>) {
metadata = tracks.toMutableList()
val factory = factory(context)
val sources = tracks.map { track ->
@ -80,13 +76,10 @@ class QueueManager(val context: Context) {
ProgressiveMediaSource.Factory(factory).setTag(track.title).createMediaSource(Uri.parse(url))
}
metadata = tracks.toMutableList()
datasources.clear()
datasources.addMediaSources(sources)
persist()
EventBus.send(Event.QueueChanged)
}
fun append(tracks: List<Track>) {
@ -103,8 +96,6 @@ class QueueManager(val context: Context) {
datasources.addMediaSources(sources)
persist()
EventBus.send(Event.QueueChanged)
}
fun insertNext(track: Track) {
@ -121,8 +112,6 @@ class QueueManager(val context: Context) {
}
persist()
EventBus.send(Event.QueueChanged)
}
fun remove(track: Track) {
@ -148,8 +137,6 @@ class QueueManager(val context: Context) {
}
persist()
EventBus.send(Event.QueueChanged)
}
fun move(oldPosition: Int, newPosition: Int) {
@ -159,10 +146,7 @@ class QueueManager(val context: Context) {
persist()
}
fun get() = metadata.mapIndexed { index, track ->
track.current = index == current
track
}
fun get() = metadata
fun get(index: Int): Track = metadata[index]
@ -205,7 +189,5 @@ class QueueManager(val context: Context) {
}
persist()
EventBus.send(Event.QueueChanged)
}
}

View File

@ -1,34 +1,44 @@
package com.github.apognu.otter.playback
import android.content.Context
import com.github.apognu.otter.Otter
import com.github.apognu.otter.R
import com.github.apognu.otter.models.api.FunkwhaleTrack
import com.github.apognu.otter.models.dao.RadioEntity
import com.github.apognu.otter.models.domain.Track
import com.github.apognu.otter.repositories.FavoritedRepository
import com.github.apognu.otter.repositories.Repository
import com.github.apognu.otter.utils.*
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
import com.github.kittinunf.fuel.coroutines.awaitObjectResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
data class RadioSessionBody(val radio_type: String, var custom_radio: Int? = null, var related_object_id: String? = null)
@Serializable
data class RadioSessionBody(val radio_type: String?, var custom_radio: Int? = null, var related_object_id: String? = null)
@Serializable
data class RadioSession(val id: Int)
@Serializable
data class RadioTrackBody(val session: Int)
@Serializable
data class RadioTrack(val position: Int, val track: RadioTrackID)
@Serializable
data class RadioTrackID(val id: Int)
class RadioPlayer(val context: Context, val scope: CoroutineScope) {
val lock = Semaphore(1)
private var currentRadio: Radio? = null
private var currentRadio: RadioEntity? = null
private var session: Int? = null
private var cookie: String? = null
@ -40,7 +50,7 @@ class RadioPlayer(val context: Context, val scope: CoroutineScope) {
Cache.get(context, "radio_session")?.readLine()?.toInt()?.let { radio_session ->
val cachedCookie = Cache.get(context, "radio_cookie")?.readLine()
currentRadio = Radio(radio_id, radio_type, "", "")
currentRadio = RadioEntity(radio_id, radio_type, "", "")
session = radio_session
cookie = cachedCookie
}
@ -48,7 +58,7 @@ class RadioPlayer(val context: Context, val scope: CoroutineScope) {
}
}
fun play(radio: Radio) {
fun play(radio: RadioEntity) {
currentRadio = radio
session = null
@ -70,6 +80,8 @@ class RadioPlayer(val context: Context, val scope: CoroutineScope) {
fun isActive() = currentRadio != null && session != null
private suspend fun createSession() {
"createSession".log()
currentRadio?.let { radio ->
try {
val request = RadioSessionBody(radio.radio_type, related_object_id = radio.related_object_id).apply {
@ -83,18 +95,20 @@ class RadioPlayer(val context: Context, val scope: CoroutineScope) {
.authorize()
.header("Content-Type", "application/json")
.body(body)
.awaitObjectResponseResult(gsonDeserializerOf(RadioSession::class.java))
.awaitObjectResponseResult<RadioSession>(AppContext.deserializer())
session = result.get().id
cookie = response.header("set-cookie").joinToString(";")
Cache.set(context, "radio_type", radio.radio_type.toByteArray())
radio.radio_type?.let { type -> Cache.set(context, "radio_type", type.toByteArray()) }
Cache.set(context, "radio_id", radio.id.toString().toByteArray())
Cache.set(context, "radio_session", session.toString().toByteArray())
Cache.set(context, "radio_cookie", cookie.toString().toByteArray())
prepareNextTrack(true)
} catch (e: Exception) {
e.log()
withContext(Main) {
context.toast(context.getString(R.string.radio_playback_error))
}
@ -103,6 +117,8 @@ class RadioPlayer(val context: Context, val scope: CoroutineScope) {
}
suspend fun prepareNextTrack(first: Boolean = false) {
"prepareTrack".log()
session?.let { session ->
try {
val body = Gson().toJson(RadioTrackBody(session))
@ -115,25 +131,23 @@ class RadioPlayer(val context: Context, val scope: CoroutineScope) {
}
}
.body(body)
.awaitObjectResult(gsonDeserializerOf(RadioTrack::class.java))
.awaitObjectResult<RadioTrack>(AppContext.deserializer())
val trackResponse = Fuel.get(mustNormalizeUrl("/api/v1/tracks/${result.get().track.id}/"))
val track = Fuel.get(mustNormalizeUrl("/api/v1/tracks/${result.get().track.id}/"))
.authorize()
.awaitObjectResult(gsonDeserializerOf(Track::class.java))
.awaitObjectResult<FunkwhaleTrack>(AppContext.deserializer())
.get()
val favorites = favoritedRepository.fetch(Repository.Origin.Cache.origin)
.map { it.data }
.toList()
.flatten()
Otter.get().database.tracks().run {
insertWithAssocs(track)
val track = trackResponse.get().apply {
favorite = favorites.contains(id)
}
if (first) {
CommandBus.send(Command.ReplaceQueue(listOf(track), true))
} else {
CommandBus.send(Command.AddToQueue(listOf(track)))
Track.fromDecoratedEntity(find(track.id)).let { track ->
if (first) {
CommandBus.send(Command.ReplaceQueue(listOf(track), true))
} else {
CommandBus.send(Command.AddToQueue(listOf(track)))
}
}
}
} catch (e: Exception) {
withContext(Main) {

View File

@ -1,32 +1,28 @@
package com.github.apognu.otter.repositories
import android.content.Context
import com.github.apognu.otter.utils.Album
import com.github.apognu.otter.utils.AlbumsCache
import com.github.apognu.otter.utils.AlbumsResponse
import com.github.apognu.otter.utils.OtterResponse
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import java.io.BufferedReader
import com.github.apognu.otter.Otter
import com.github.apognu.otter.models.api.FunkwhaleAlbum
import com.github.apognu.otter.models.dao.toDao
class AlbumsRepository(override val context: Context?, artistId: Int? = null) : Repository<Album, AlbumsCache>() {
override val cacheId: String by lazy {
if (artistId == null) "albums"
else "albums-artist-$artistId"
}
override val upstream: Upstream<Album> by lazy {
class AlbumsRepository(override val context: Context?, artistId: Int? = null) : Repository<FunkwhaleAlbum>() {
override val upstream: Upstream<FunkwhaleAlbum> by lazy {
val url =
if (artistId == null) "/api/v1/albums/?playable=true&ordering=title"
else "/api/v1/albums/?playable=true&artist=$artistId&ordering=release_date"
HttpUpstream<Album, OtterResponse<Album>>(
HttpUpstream(
HttpUpstream.Behavior.Progressive,
url,
object : TypeToken<AlbumsResponse>() {}.type
FunkwhaleAlbum.serializer()
)
}
override fun cache(data: List<Album>) = AlbumsCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(AlbumsCache::class.java).deserialize(reader)
override fun onDataFetched(data: List<FunkwhaleAlbum>): List<FunkwhaleAlbum> {
data.forEach {
Otter.get().database.albums().insert(it.toDao())
}
return super.onDataFetched(data)
}
}

View File

@ -1,18 +1,19 @@
package com.github.apognu.otter.repositories
import android.content.Context
import com.github.apognu.otter.utils.OtterResponse
import com.github.apognu.otter.utils.Track
import com.github.apognu.otter.utils.TracksCache
import com.github.apognu.otter.utils.TracksResponse
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import java.io.BufferedReader
import com.github.apognu.otter.Otter
import com.github.apognu.otter.models.api.FunkwhaleTrack
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.runBlocking
class ArtistTracksRepository(override val context: Context?, private val artistId: Int) : Repository<Track, TracksCache>() {
override val cacheId = "tracks-artist-$artistId"
override val upstream = HttpUpstream<Track, OtterResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&artist=$artistId", object : TypeToken<TracksResponse>() {}.type)
class ArtistTracksRepository(override val context: Context?, private val artistId: Int) : Repository<FunkwhaleTrack>() {
override val upstream = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&artist=$artistId", FunkwhaleTrack.serializer())
override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
override fun onDataFetched(data: List<FunkwhaleTrack>) = runBlocking(IO) {
data.forEach {
Otter.get().database.tracks().insertWithAssocs(it)
}
super.onDataFetched(data)
}
}

View File

@ -1,18 +1,33 @@
package com.github.apognu.otter.repositories
import android.content.Context
import com.github.apognu.otter.utils.Artist
import com.github.apognu.otter.utils.ArtistsCache
import com.github.apognu.otter.utils.ArtistsResponse
import com.github.apognu.otter.utils.OtterResponse
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import java.io.BufferedReader
import com.github.apognu.otter.Otter
import com.github.apognu.otter.models.api.FunkwhaleArtist
import com.github.apognu.otter.models.dao.toDao
import com.github.apognu.otter.models.dao.toRealmDao
import io.realm.Realm
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
class ArtistsRepository(override val context: Context?) : Repository<Artist, ArtistsCache>() {
override val cacheId = "artists"
override val upstream = HttpUpstream<Artist, OtterResponse<Artist>>(HttpUpstream.Behavior.Progressive, "/api/v1/artists/?playable=true&ordering=name", object : TypeToken<ArtistsResponse>() {}.type)
class ArtistsRepository(override val context: Context?) : Repository<FunkwhaleArtist>() {
override val upstream =
HttpUpstream(HttpUpstream.Behavior.Progressive, "/api/v1/artists/?playable=true&ordering=name", FunkwhaleArtist.serializer())
override fun cache(data: List<Artist>) = ArtistsCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader)
override fun onDataFetched(data: List<FunkwhaleArtist>): List<FunkwhaleArtist> {
scope.launch(IO) {
data.forEach { artist ->
Otter.get().database.artists().insert(artist.toDao())
Realm.getDefaultInstance().executeTransaction { realm ->
realm.insertOrUpdate(artist.toRealmDao())
}
artist.albums?.forEach { album ->
Otter.get().database.albums().insert(album.toDao(artist.id))
}
}
}
return super.onDataFetched(data)
}
}

View File

@ -1,32 +1,33 @@
package com.github.apognu.otter.repositories
import android.content.Context
import androidx.lifecycle.lifecycleScope
import com.github.apognu.otter.Otter
import com.github.apognu.otter.utils.*
import com.github.apognu.otter.models.api.Favorited
import com.github.apognu.otter.models.api.FunkwhaleTrack
import com.github.apognu.otter.models.dao.FavoriteEntity
import com.github.apognu.otter.utils.Settings
import com.github.apognu.otter.utils.mustNormalizeUrl
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitByteArrayResponseResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.io.BufferedReader
class FavoritesRepository(override val context: Context?) : Repository<Track, TracksCache>() {
override val cacheId = "favorites.v2"
override val upstream = HttpUpstream<Track, OtterResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?favorites=true&playable=true&ordering=title", object : TypeToken<TracksResponse>() {}.type)
class FavoritesRepository(override val context: Context?) : Repository<FunkwhaleTrack>() {
override val upstream =
HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?favorites=true&playable=true&ordering=title", FunkwhaleTrack.serializer())
override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
val favoritedRepository = FavoritedRepository(context)
private val favoritedRepository = FavoritedRepository(context)
override fun onDataFetched(data: List<FunkwhaleTrack>): List<FunkwhaleTrack> = runBlocking {
data.forEach {
Otter.get().database.tracks().insertWithAssocs(it)
Otter.get().database.favorites().insert(FavoriteEntity(it.id))
}
override fun onDataFetched(data: List<Track>): List<Track> = runBlocking {
val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
/* val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
data.map { track ->
track.favorite = true
@ -39,10 +40,14 @@ class FavoritesRepository(override val context: Context?) : Repository<Track, Tr
}
track
}
} */
data
}
fun addFavorite(id: Int) {
fun addFavorite(id: Int) = scope.launch(IO) {
Otter.get().database.favorites().add(id)
val body = mapOf("track" to id)
val request = Fuel.post(mustNormalizeUrl("/api/v1/favorites/tracks/")).apply {
@ -57,11 +62,13 @@ class FavoritesRepository(override val context: Context?) : Repository<Track, Tr
.body(Gson().toJson(body))
.awaitByteArrayResponseResult()
favoritedRepository.update(context, scope)
favoritedRepository.update()
}
}
fun deleteFavorite(id: Int) {
fun deleteFavorite(id: Int) = scope.launch(IO) {
Otter.get().database.favorites().remove(id)
val body = mapOf("track" to id)
val request = Fuel.post(mustNormalizeUrl("/api/v1/favorites/tracks/remove/")).apply {
@ -76,21 +83,26 @@ class FavoritesRepository(override val context: Context?) : Repository<Track, Tr
.body(Gson().toJson(body))
.awaitByteArrayResponseResult()
favoritedRepository.update(context, scope)
favoritedRepository.update()
}
}
}
class FavoritedRepository(override val context: Context?) : Repository<Int, FavoritedCache>() {
override val cacheId = "favorited"
override val upstream = HttpUpstream<Int, OtterResponse<Int>>(HttpUpstream.Behavior.Single, "/api/v1/favorites/tracks/all/?playable=true", object : TypeToken<FavoritedResponse>() {}.type)
class FavoritedRepository(override val context: Context?) : Repository<Favorited>() {
override val upstream =
HttpUpstream(HttpUpstream.Behavior.Single, "/api/v1/favorites/tracks/all/?playable=true", Favorited.serializer())
override fun cache(data: List<Int>) = FavoritedCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(FavoritedCache::class.java).deserialize(reader)
fun update(context: Context?, scope: CoroutineScope) {
fetch(Origin.Network.origin).untilNetwork(scope, IO) { favorites, _, _, _ ->
Cache.set(context, cacheId, Gson().toJson(cache(favorites)).toByteArray())
override fun onDataFetched(data: List<Favorited>): List<Favorited> {
scope.launch(IO) {
data.forEach {
Otter.get().database.favorites().insert(FavoriteEntity(it.track))
}
}
return super.onDataFetched(data)
}
fun update() = scope.launch(IO) {
fetch().collect()
}
}

View File

@ -1,30 +1,29 @@
package com.github.apognu.otter.repositories
import android.net.Uri
import com.github.apognu.otter.models.api.OtterResponse
import com.github.apognu.otter.models.api.OtterResponseSerializer
import com.github.apognu.otter.utils.*
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.core.FuelError
import com.github.kittinunf.fuel.core.ResponseDeserializable
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
import com.github.kittinunf.fuel.coroutines.awaitObjectResult
import com.github.kittinunf.result.Result
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import java.io.Reader
import java.lang.reflect.Type
import kotlinx.serialization.KSerializer
import kotlin.math.ceil
class HttpUpstream<D : Any, R : OtterResponse<D>>(val behavior: Behavior, private val url: String, private val type: Type) : Upstream<D> {
class HttpUpstream<D : Any>(val behavior: Behavior, private val url: String, private val serializer: KSerializer<D>) : Upstream<D> {
enum class Behavior {
Single, AtOnce, Progressive
}
override fun fetch(size: Int): Flow<Repository.Response<D>> = flow {
if (behavior == Behavior.Single && size != 0) return@flow
override fun fetch(size: Int): Flow<Repository.Response<D>> = channelFlow {
if (behavior == Behavior.Single && size != 0) return@channelFlow
val page = ceil(size / AppContext.PAGE_SIZE.toDouble()).toInt() + 1
@ -39,35 +38,32 @@ class HttpUpstream<D : Any, R : OtterResponse<D>>(val behavior: Behavior, privat
get(url).fold(
{ response ->
val data = response.getData()
val data = response.results
when (behavior) {
Behavior.Single -> emit(Repository.Response(Repository.Origin.Network, data, page, false))
Behavior.Progressive -> emit(Repository.Response(Repository.Origin.Network, data, page, response.next != null))
Behavior.Single -> send(Repository.Response(data, page, false))
Behavior.Progressive -> send(Repository.Response(data, page, response.next != null))
else -> {
emit(Repository.Response(Repository.Origin.Network, data, page, response.next != null))
send(Repository.Response(data, page, response.next != null))
if (response.next != null) fetch(size + data.size).collect { emit(it) }
if (response.next != null) fetch(size + data.size).collect { send(it) }
}
}
},
{ error ->
"GET $url".log()
error.log()
when (error.exception) {
is RefreshError -> EventBus.send(Event.LogOut)
else -> emit(Repository.Response(Repository.Origin.Network, listOf(), page, false))
else -> send(Repository.Response(listOf(), page, false))
}
}
)
}.flowOn(IO)
class GenericDeserializer<T : OtterResponse<*>>(val type: Type) : ResponseDeserializable<T> {
override fun deserialize(reader: Reader): T? {
return Gson().fromJson(reader, type)
}
}
suspend fun get(url: String): Result<R, FuelError> {
suspend fun get(url: String): Result<OtterResponse<D>, FuelError> {
return try {
val request = Fuel.get(mustNormalizeUrl(url)).apply {
if (!Settings.isAnonymous()) {
@ -75,7 +71,7 @@ class HttpUpstream<D : Any, R : OtterResponse<D>>(val behavior: Behavior, privat
}
}
val (_, response, result) = request.awaitObjectResponseResult(GenericDeserializer<R>(type))
val (_, response, result) = request.awaitObjectResponseResult(AppContext.deserializer(OtterResponseSerializer(serializer)))
if (response.statusCode == 401) {
return retryGet(url)
@ -87,7 +83,7 @@ class HttpUpstream<D : Any, R : OtterResponse<D>>(val behavior: Behavior, privat
}
}
private suspend fun retryGet(url: String): Result<R, FuelError> {
private suspend fun retryGet(url: String): Result<OtterResponse<D>, FuelError> {
return try {
return if (HTTP.refresh()) {
val request = Fuel.get(mustNormalizeUrl(url)).apply {
@ -96,7 +92,7 @@ class HttpUpstream<D : Any, R : OtterResponse<D>>(val behavior: Behavior, privat
}
}
request.awaitObjectResult(GenericDeserializer(type))
request.awaitObjectResult(AppContext.deserializer(OtterResponseSerializer(serializer)))
} else {
Result.Failure(FuelError.wrap(RefreshError))
}

View File

@ -1,33 +1,24 @@
package com.github.apognu.otter.repositories
import android.content.Context
import com.github.apognu.otter.utils.OtterResponse
import com.github.apognu.otter.utils.PlaylistTrack
import com.github.apognu.otter.utils.PlaylistTracksCache
import com.github.apognu.otter.utils.PlaylistTracksResponse
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import com.github.apognu.otter.Otter
import com.github.apognu.otter.models.api.FunkwhalePlaylistTrack
import com.github.apognu.otter.models.dao.PlaylistTrack
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
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, OtterResponse<PlaylistTrack>>(HttpUpstream.Behavior.Single, "/api/v1/playlists/$playlistId/tracks/?playable=true", object : TypeToken<PlaylistTracksResponse>() {}.type)
class PlaylistTracksRepository(override val context: Context?, private val playlistId: Int) : Repository<FunkwhalePlaylistTrack>() {
override val upstream =
HttpUpstream(HttpUpstream.Behavior.Single, "/api/v1/playlists/$playlistId/tracks/?playable=true", FunkwhalePlaylistTrack.serializer())
override fun cache(data: List<PlaylistTrack>) = PlaylistTracksCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(PlaylistTracksCache::class.java).deserialize(reader)
override fun onDataFetched(data: List<FunkwhalePlaylistTrack>): List<FunkwhalePlaylistTrack> = runBlocking {
Otter.get().database.playlists().replaceTracks(playlistId, data.map {
Otter.get().database.tracks().insertWithAssocs(it.track)
override fun onDataFetched(data: List<PlaylistTrack>): List<PlaylistTrack> = runBlocking {
val favorites = FavoritedRepository(context).fetch(Origin.Network.origin)
.map { it.data }
.toList()
.flatten()
PlaylistTrack(playlistId, it.track.id)
})
data.map { track ->
track.track.favorite = favorites.contains(track.track.id)
track
}
data
}
}

View File

@ -1,18 +1,19 @@
package com.github.apognu.otter.repositories
import android.content.Context
import com.github.apognu.otter.utils.OtterResponse
import com.github.apognu.otter.utils.Playlist
import com.github.apognu.otter.utils.PlaylistsCache
import com.github.apognu.otter.utils.PlaylistsResponse
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import java.io.BufferedReader
import com.github.apognu.otter.Otter
import com.github.apognu.otter.models.api.FunkwhalePlaylist
import com.github.apognu.otter.models.dao.toDao
class PlaylistsRepository(override val context: Context?) : Repository<Playlist, PlaylistsCache>() {
override val cacheId = "tracks-playlists"
override val upstream = HttpUpstream<Playlist, OtterResponse<Playlist>>(HttpUpstream.Behavior.Progressive, "/api/v1/playlists/?playable=true&ordering=name", object : TypeToken<PlaylistsResponse>() {}.type)
class PlaylistsRepository(override val context: Context?) : Repository<FunkwhalePlaylist>() {
override val upstream =
HttpUpstream(HttpUpstream.Behavior.Progressive, "/api/v1/playlists/?playable=true&ordering=name", FunkwhalePlaylist.serializer())
override fun cache(data: List<Playlist>) = PlaylistsCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(PlaylistsCache::class.java).deserialize(reader)
override fun onDataFetched(data: List<FunkwhalePlaylist>): List<FunkwhalePlaylist> {
data.forEach {
Otter.get().database.playlists().insert(it.toDao())
}
return super.onDataFetched(data)
}
}

View File

@ -0,0 +1,16 @@
package com.github.apognu.otter.repositories
import com.github.apognu.otter.Otter
import com.github.apognu.otter.models.domain.Track
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class QueueRepository(val scope: CoroutineScope) {
fun all() = Otter.get().database.queue().allDecorated()
fun allBlocking() = Otter.get().database.queue().allDecoratedBlocking()
fun replace(tracks: List<Track>) = scope.launch {
Otter.get().database.queue().replace(tracks)
}
}

View File

@ -1,22 +1,19 @@
package com.github.apognu.otter.repositories
import android.content.Context
import com.github.apognu.otter.utils.OtterResponse
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
import com.github.apognu.otter.Otter
import com.github.apognu.otter.models.api.FunkwhaleRadio
import com.github.apognu.otter.models.dao.toDao
class RadiosRepository(override val context: Context?) : Repository<Radio, RadiosCache>() {
override val cacheId = "radios"
override val upstream = HttpUpstream<Radio, OtterResponse<Radio>>(HttpUpstream.Behavior.Progressive, "/api/v1/radios/radios/?ordering=name", object : TypeToken<RadiosResponse>() {}.type)
class RadiosRepository(override val context: Context?) : Repository<FunkwhaleRadio>() {
override val upstream =
HttpUpstream(HttpUpstream.Behavior.Progressive, "/api/v1/radios/radios/?playable=true&ordering=name", FunkwhaleRadio.serializer())
override fun cache(data: List<Radio>) = RadiosCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(RadiosCache::class.java).deserialize(reader)
override fun onDataFetched(data: List<FunkwhaleRadio>): List<FunkwhaleRadio> {
data.forEach {
Otter.get().database.radios().insert(it.toDao())
}
override fun onDataFetched(data: List<Radio>): List<Radio> {
return data
.map { radio -> radio.apply { radio_type = "custom" } }
.toMutableList()

View File

@ -1,59 +1,31 @@
package com.github.apognu.otter.repositories
import android.content.Context
import com.github.apognu.otter.utils.AppContext
import com.github.apognu.otter.utils.Cache
import com.github.apognu.otter.utils.CacheItem
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import java.io.BufferedReader
import kotlin.math.ceil
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
interface Upstream<D> {
fun fetch(size: Int = 0): Flow<Repository.Response<D>>
}
abstract class Repository<D : Any, C : CacheItem<D>> {
abstract class Repository<D : Any> {
protected val scope: CoroutineScope = CoroutineScope(Job() + IO)
enum class Origin(val origin: Int) {
Cache(0b01),
Network(0b10)
}
data class Response<D>(val origin: Origin, val data: List<D>, val page: Int, val hasMore: Boolean)
data class Response<D>(val data: List<D>, val page: Int, val hasMore: Boolean)
abstract val context: Context?
abstract val cacheId: String?
abstract val upstream: Upstream<D>
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, size: Int = 0): Flow<Response<D>> = flow {
if (Origin.Cache.origin and upstreams == upstreams) fromCache().collect { emit(it) }
if (Origin.Network.origin and upstreams == upstreams) fromNetwork(size).collect { emit(it) }
}
private fun fromCache() = flow {
cacheId?.let { cacheId ->
Cache.get(context, cacheId)?.let { reader ->
uncache(reader)?.let { cache ->
return@flow emit(Response(Origin.Cache, cache.data, ceil(cache.data.size / AppContext.PAGE_SIZE.toDouble()).toInt(), false))
}
}
return@flow emit(Response(Origin.Cache, listOf(), 1, false))
}
}.flowOn(IO)
private fun fromNetwork(size: Int) = flow {
fun fetch(size: Int = 0) = channelFlow {
upstream
.fetch(size)
.map { response -> Response(Origin.Network, onDataFetched(response.data), response.page, response.hasMore) }
.collect { response -> emit(Response(Origin.Network, response.data, response.page, response.hasMore)) }
.map { response -> Response(onDataFetched(response.data), response.page, response.hasMore) }
.collect { response -> send(Response(response.data, response.page, response.hasMore)) }
}
protected open fun onDataFetched(data: List<D>) = data

View File

@ -1,30 +1,24 @@
package com.github.apognu.otter.repositories
import android.content.Context
import com.github.apognu.otter.Otter
import com.github.apognu.otter.utils.*
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.google.gson.reflect.TypeToken
import com.github.apognu.otter.models.api.FunkwhaleAlbum
import com.github.apognu.otter.models.api.FunkwhaleArtist
import com.github.apognu.otter.models.api.FunkwhaleTrack
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import java.io.BufferedReader
class TracksSearchRepository(override val context: Context?, var query: String) : Repository<Track, TracksCache>() {
override val cacheId: String? = null
override val upstream: Upstream<Track>
get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&q=$query", object : TypeToken<TracksResponse>() {}.type)
class TracksSearchRepository(override val context: Context?, var query: String) : Repository<FunkwhaleTrack>() {
override val upstream: Upstream<FunkwhaleTrack>
get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&q=$query", FunkwhaleTrack.serializer())
override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
override fun onDataFetched(data: List<Track>): List<Track> = runBlocking {
val favorites = FavoritedRepository(context).fetch(Origin.Cache.origin)
override fun onDataFetched(data: List<FunkwhaleTrack>): List<FunkwhaleTrack> = runBlocking {
val favorites = FavoritedRepository(context).fetch()
.map { it.data }
.toList()
.flatten()
val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
/* val downloaded = TracksRepository.getDownloadedIds() ?: listOf()
data.map { track ->
track.favorite = favorites.contains(track.id)
@ -37,24 +31,18 @@ class TracksSearchRepository(override val context: Context?, var query: String)
}
track
}
} */
data
}
}
class ArtistsSearchRepository(override val context: Context?, var query: String) : Repository<Artist, ArtistsCache>() {
override val cacheId: String? = null
override val upstream: Upstream<Artist>
get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/artists/?playable=true&q=$query", object : TypeToken<ArtistsResponse>() {}.type)
override fun cache(data: List<Artist>) = ArtistsCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader)
class ArtistsSearchRepository(override val context: Context?, var query: String) : Repository<FunkwhaleArtist>() {
override val upstream: Upstream<FunkwhaleArtist>
get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/artists/?playable=true&q=$query", FunkwhaleArtist.serializer())
}
class AlbumsSearchRepository(override val context: Context?, var query: String) : Repository<Album, AlbumsCache>() {
override val cacheId: String? = null
override val upstream: Upstream<Album>
get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/albums/?playable=true&q=$query", object : TypeToken<AlbumsResponse>() {}.type)
override fun cache(data: List<Album>) = AlbumsCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(AlbumsCache::class.java).deserialize(reader)
class AlbumsSearchRepository(override val context: Context?, var query: String) : Repository<FunkwhaleAlbum>() {
override val upstream: Upstream<FunkwhaleAlbum>
get() = HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/albums/?playable=true&q=$query", FunkwhaleAlbum.serializer())
}

View File

@ -2,21 +2,14 @@ package com.github.apognu.otter.repositories
import android.content.Context
import com.github.apognu.otter.Otter
import com.github.apognu.otter.utils.*
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.github.apognu.otter.models.api.FunkwhaleTrack
import com.github.apognu.otter.utils.getMetadata
import com.google.android.exoplayer2.offline.Download
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
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, OtterResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&album=$albumId&ordering=disc_number,position", object : TypeToken<TracksResponse>() {}.type)
override fun cache(data: List<Track>) = TracksCache(data)
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
class TracksRepository(override val context: Context?, albumId: Int) : Repository<FunkwhaleTrack>() {
override val upstream =
HttpUpstream(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks/?playable=true&album=$albumId&ordering=disc_number,position", FunkwhaleTrack.serializer())
companion object {
fun getDownloadedIds(): List<Int>? {
@ -37,25 +30,11 @@ class TracksRepository(override val context: Context?, albumId: Int) : Repositor
}
}
override fun onDataFetched(data: List<Track>): List<Track> = runBlocking {
val favorites = FavoritedRepository(context).fetch(Origin.Cache.origin)
.map { it.data }
.toList()
.flatten()
override fun onDataFetched(data: List<FunkwhaleTrack>): List<FunkwhaleTrack> = runBlocking {
data.forEach { track ->
Otter.get().database.tracks().insertWithAssocs(track)
}
val downloaded = getDownloadedIds() ?: listOf()
data.map { track ->
track.favorite = favorites.contains(track.id)
track.downloaded = downloaded.contains(track.id)
track.bestUpload()?.let { upload ->
val url = mustNormalizeUrl(upload.listen_url)
track.cached = Otter.get().exoCache.isCached(url, 0, upload.duration * 1000L)
}
track
}.sortedWith(compareBy({ it.disc_number }, { it.position }))
data.sortedWith(compareBy({ it.disc_number }, { it.position }))
}
}

View File

@ -11,6 +11,12 @@ import android.os.Build
import com.github.apognu.otter.R
import com.github.kittinunf.fuel.core.FuelManager
import com.github.kittinunf.fuel.core.Method
import com.github.kittinunf.fuel.core.ResponseDeserializable
import com.github.kittinunf.fuel.serialization.kotlinxDeserializerOf
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonConfiguration
import kotlinx.serialization.serializer
object AppContext {
const val PREFS_CREDENTIALS = "credentials"
@ -23,6 +29,12 @@ object AppContext {
const val PAGE_SIZE = 50
const val TRANSITION_DURATION = 300L
inline fun <reified T : Any> deserializer(serializer: DeserializationStrategy<T>): ResponseDeserializable<T> =
kotlinxDeserializerOf(loader = serializer, json = Json(JsonConfiguration(ignoreUnknownKeys = true)))
inline fun <reified T : Any> deserializer() =
kotlinxDeserializerOf(T::class.serializer(), Json(JsonConfiguration(ignoreUnknownKeys = true)))
fun init(context: Activity) {
setupNotificationChannels(context)

View File

@ -1,13 +1,12 @@
package com.github.apognu.otter.utils
import com.github.apognu.otter.Otter
import com.github.apognu.otter.models.dao.RadioEntity
import com.github.apognu.otter.models.domain.Track
import com.google.android.exoplayer2.offline.Download
import com.google.android.exoplayer2.offline.DownloadCursor
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.launch
sealed class Command {
@ -28,7 +27,7 @@ sealed class Command {
class MoveFromQueue(val oldPosition: Int, val newPosition: Int) : Command()
object ClearQueue : Command()
object ShuffleQueue : Command()
class PlayRadio(val radio: Radio) : Command()
class PlayRadio(val radio: RadioEntity) : Command()
class SetRepeatMode(val mode: Int) : Command()
@ -44,29 +43,12 @@ sealed class Event {
class PlaybackError(val message: String) : Event()
object PlaybackStopped : Event()
class Buffering(val value: Boolean) : Event()
class TrackFinished(val track: Track?) : Event()
class StateChanged(val playing: Boolean) : Event()
object QueueChanged : Event()
object RadioStarted : Event()
object ListingsChanged : Event()
class DownloadChanged(val download: Download) : Event()
}
sealed class Request(var channel: Channel<Response>? = null) {
object GetState : Request()
object GetQueue : Request()
object GetCurrentTrack : Request()
object GetDownloads : Request()
}
sealed class Response {
class State(val playing: Boolean) : Response()
class Queue(val queue: List<Track>) : Response()
class CurrentTrack(val track: Track?) : Response()
class Downloads(val cursor: DownloadCursor) : Response()
}
object EventBus {
fun send(event: Event) {
GlobalScope.launch(IO) {
@ -87,33 +69,3 @@ object CommandBus {
fun get() = Otter.get().commandBus.asFlow()
}
object RequestBus {
fun send(request: Request): Channel<Response> {
return Channel<Response>().also {
GlobalScope.launch(IO) {
request.channel = it
Otter.get().requestBus.offer(request)
}
}
}
fun get() = Otter.get().requestBus.asFlow()
}
object ProgressBus {
fun send(current: Int, duration: Int, percent: Int) {
GlobalScope.launch(IO) {
Otter.get().progressBus.send(Triple(current, duration, percent))
}
}
fun get() = Otter.get().progressBus.asFlow().conflate()
}
suspend inline fun <reified T> Channel<Response>.wait(): T? {
return when (val response = this.receive()) {
is T -> response
else -> null
}
}

View File

@ -1,12 +1,11 @@
package com.github.apognu.otter.utils
import android.content.Context
import com.github.apognu.otter.activities.FwCredentials
import com.github.apognu.otter.models.api.Credentials
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.core.FuelError
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
import com.github.kittinunf.fuel.coroutines.awaitObjectResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.github.kittinunf.result.Result
import com.preference.PowerPreference
import java.io.BufferedReader
@ -23,7 +22,9 @@ object HTTP {
"password" to PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("password")
).toList()
val result = Fuel.post(mustNormalizeUrl("/api/v1/token"), body).awaitObjectResult(gsonDeserializerOf(FwCredentials::class.java))
val result = Fuel
.post(mustNormalizeUrl("/api/v1/token"), body)
.awaitObjectResult<Credentials>(AppContext.deserializer())
return result.fold(
{ data ->
@ -42,7 +43,7 @@ object HTTP {
}
}
val (_, response, result) = request.awaitObjectResponseResult(gsonDeserializerOf(T::class.java))
val (_, response, result) = request.awaitObjectResponseResult<T>(AppContext.deserializer())
if (response.statusCode == 401) {
return retryGet(url)
@ -59,7 +60,7 @@ object HTTP {
}
}
request.awaitObjectResult(gsonDeserializerOf(T::class.java))
request.awaitObjectResult(AppContext.deserializer())
} else {
Result.Failure(FuelError.wrap(RefreshError))
}

View File

@ -4,6 +4,7 @@ import android.os.Build
import androidx.fragment.app.Fragment
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.BrowseFragment
import com.github.apognu.otter.models.api.DownloadInfo
import com.github.apognu.otter.repositories.Repository
import com.github.kittinunf.fuel.core.Request
import com.google.android.exoplayer2.offline.Download
@ -17,10 +18,10 @@ import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
inline fun <D> Flow<Repository.Response<D>>.untilNetwork(scope: CoroutineScope, context: CoroutineContext = Main, crossinline callback: (data: List<D>, isCache: Boolean, page: Int, hasMore: Boolean) -> Unit) {
inline fun <D> Flow<Repository.Response<D>>.untilNetwork(scope: CoroutineScope, context: CoroutineContext = Main, crossinline callback: (data: List<D>, page: Int, hasMore: Boolean) -> Unit) {
scope.launch(context) {
collect { data ->
callback(data.data, data.origin == Repository.Origin.Cache, data.page, data.hasMore)
callback(data.data, data.page, data.hasMore)
}
}
}

View File

@ -1,8 +1,8 @@
package com.github.apognu.otter.utils
import com.github.apognu.otter.models.api.User
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.github.kittinunf.result.Result
import com.preference.PowerPreference
@ -12,7 +12,7 @@ object Userinfo {
val hostname = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("hostname")
val (_, _, result) = Fuel.get("$hostname/api/v1/users/users/me/")
.authorize()
.awaitObjectResponseResult(gsonDeserializerOf(User::class.java))
.awaitObjectResponseResult<User>(AppContext.deserializer())
return when (result) {
is Result.Success -> {

View File

@ -0,0 +1,46 @@
package com.github.apognu.otter.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import com.github.apognu.otter.Otter
import com.github.apognu.otter.models.domain.Album
import com.github.apognu.otter.models.domain.Track
import com.github.apognu.otter.models.domain.Upload
class AlbumsViewModel(private val artistId: Int? = null) : ViewModel() {
val albums: LiveData<List<Album>> by lazy {
if (artistId == null) {
Transformations.map(Otter.get().database.albums().allDecorated()) {
it.map { album -> Album.fromDecoratedEntity(album) }
}
} else {
Transformations.map(Otter.get().database.albums().forArtistDecorated(artistId)) {
it.map { album -> Album.fromDecoratedEntity(album) }
}
}
}
suspend fun tracks(): List<Track> {
artistId?.let {
val tracks = Otter.get().database.tracks().ofArtistBlocking(artistId)
val uploads = Otter.get().database.uploads().findAllBlocking(tracks.map { it.id })
return tracks.map {
Track.fromDecoratedEntity(it).apply {
this.uploads = uploads.filter { it.track_id == id }.map { Upload.fromEntity(it) }
}
}
}
return listOf()
}
}
class AlbumViewModel(private val id: Int) : ViewModel() {
val album: LiveData<Album> by lazy {
Transformations.map(Otter.get().database.albums().getDecorated(id)) { album ->
Album.fromDecoratedEntity(album)
}
}
}

View File

@ -0,0 +1,31 @@
package com.github.apognu.otter.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations.map
import androidx.lifecycle.ViewModel
import androidx.lifecycle.map
import com.github.apognu.otter.Otter
import com.github.apognu.otter.models.domain.Artist
class ArtistsViewModel : ViewModel() {
companion object {
private lateinit var instance: ArtistsViewModel
fun get(): ArtistsViewModel {
instance = if (::instance.isInitialized) instance else ArtistsViewModel()
return instance
}
}
val artists: LiveData<List<Artist>> = Otter.get().database.artists().allDecorated().map {
it.map { Artist.fromDecoratedEntity(it) }
}
}
class ArtistViewModel(private val id: Int) : ViewModel() {
val artist: LiveData<Artist> by lazy {
map(Otter.get().database.artists().getDecorated(id)) { artist ->
Artist.fromDecoratedEntity(artist)
}
}
}

View File

@ -0,0 +1,38 @@
package com.github.apognu.otter.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import com.github.apognu.otter.models.api.DownloadInfo
import com.github.apognu.otter.utils.getMetadata
import com.google.android.exoplayer2.offline.DownloadCursor
class DownloadsViewModel : ViewModel() {
companion object {
private lateinit var instance: DownloadsViewModel
fun get(): DownloadsViewModel {
instance = if (::instance.isInitialized) instance else DownloadsViewModel()
return instance
}
}
val cursor: MutableLiveData<DownloadCursor> by lazy { MutableLiveData<DownloadCursor>() }
val downloads: LiveData<List<DownloadInfo>> = Transformations.map(cursor) { cursor ->
val downloads = mutableListOf<DownloadInfo>()
while (cursor.moveToNext()) {
val download = cursor.download
download.getMetadata()?.let { info ->
downloads.add(info.apply {
this.download = download
})
}
}
downloads.sortedBy { it.title }
}
}

View File

@ -0,0 +1,73 @@
package com.github.apognu.otter.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import com.github.apognu.otter.Otter
import com.github.apognu.otter.models.domain.Album
import com.github.apognu.otter.models.domain.Track
import com.github.apognu.otter.models.domain.Upload
class FavoritesViewModel : ViewModel() {
companion object {
private lateinit var instance: FavoritesViewModel
fun get(): FavoritesViewModel {
instance = if (::instance.isInitialized) instance else FavoritesViewModel()
return instance
}
}
private val _albums: LiveData<List<Album>> by lazy {
Transformations.switchMap(_favorites) { tracks ->
val ids = tracks.mapNotNull { it.album?.id }
Transformations.map(Otter.get().database.albums().findAllDecorated(ids)) { albums ->
albums.map { album -> Album.fromDecoratedEntity(album) }
}
}
}
private val _favorites: LiveData<List<Track>> by lazy {
Transformations.switchMap(Otter.get().database.favorites().all()) {
val ids = it.map { favorite -> favorite.track_id }
Transformations.map(Otter.get().database.tracks().findAllDecorated(ids)) { tracks ->
tracks.map { track -> Track.fromDecoratedEntity(track) }.sortedBy { it.title }
}
}
}
private val _uploads: LiveData<List<Upload>> by lazy {
Transformations.switchMap(_favorites) { tracks ->
val ids = tracks.mapNotNull { it.album?.id }
Transformations.map(Otter.get().database.uploads().findAll(ids)) { uploads ->
uploads.map { upload -> Upload.fromEntity(upload) }
}
}
}
val favorites = MediatorLiveData<List<Track>>().apply {
addSource(_favorites) { merge(_favorites, _albums, _uploads) }
addSource(_albums) { merge(_favorites, _albums, _uploads) }
addSource(_uploads) { merge(_favorites, _albums, _uploads) }
}
private fun merge(_tracks: LiveData<List<Track>>, _albums: LiveData<List<Album>>, _uploads: LiveData<List<Upload>>) {
val _tracks = _tracks.value
val _albums = _albums.value
val _uploads = _uploads.value
if (_tracks == null || _albums == null || _uploads == null) {
return
}
favorites.value = _tracks.map { track ->
track.uploads = _uploads.filter { upload -> upload.track_id == track.id }
track
}
}
}

View File

@ -0,0 +1,33 @@
package com.github.apognu.otter.viewmodels
import androidx.lifecycle.*
import com.github.apognu.otter.Otter
import com.github.apognu.otter.models.domain.Track
class PlayerStateViewModel private constructor() : ViewModel() {
companion object {
private lateinit var instance: PlayerStateViewModel
fun get(): PlayerStateViewModel {
instance = if (::instance.isInitialized) instance else PlayerStateViewModel()
return instance
}
}
val isPlaying: MutableLiveData<Boolean> by lazy { MutableLiveData<Boolean>() }
val isBuffering: MutableLiveData<Boolean> by lazy { MutableLiveData<Boolean>() }
val position: MutableLiveData<Triple<Int, Int, Int>> by lazy { MutableLiveData<Triple<Int, Int, Int>>() }
val _track: MutableLiveData<Track> by lazy { MutableLiveData<Track>() }
val track: LiveData<Track> by lazy {
Transformations.switchMap(_track) {
if (it == null) {
return@switchMap null
}
Otter.get().database.tracks().getDecorated(it.id).map { Track.fromDecoratedEntity(it) }
}
}
}

View File

@ -0,0 +1,20 @@
package com.github.apognu.otter.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import com.github.apognu.otter.Otter
import com.github.apognu.otter.models.dao.PlaylistEntity
import com.github.apognu.otter.models.domain.Track
class PlaylistsViewModel : ViewModel() {
val playlists: LiveData<List<PlaylistEntity>> by lazy { Otter.get().database.playlists().all() }
}
class PlaylistViewModel(playlistId: Int) : ViewModel() {
val tracks: LiveData<List<Track>> by lazy {
Transformations.map(Otter.get().database.playlists().tracksFor(playlistId)) {
it.map { track -> Track.fromDecoratedEntity(track) }
}
}
}

View File

@ -0,0 +1,59 @@
package com.github.apognu.otter.viewmodels
import androidx.lifecycle.*
import com.github.apognu.otter.Otter
import com.github.apognu.otter.models.domain.Track
import com.github.apognu.otter.repositories.QueueRepository
import com.github.apognu.otter.utils.maybeNormalizeUrl
import kotlinx.coroutines.delay
class QueueViewModel private constructor() : ViewModel() {
companion object {
private lateinit var instance: QueueViewModel
fun get(): QueueViewModel {
instance = if (::instance.isInitialized) instance else QueueViewModel()
return instance
}
}
private val queueRepository = QueueRepository(viewModelScope)
private val _cached = liveData {
while (true) {
emit(Otter.get().exoCache.keys)
delay(5000)
}
}
private val _current = PlayerStateViewModel.get().track
private val _queue: LiveData<List<Track>> by lazy {
Transformations.map(queueRepository.all()) { tracks ->
tracks.map { Track.fromDecoratedEntity(it) }
}
}
val queue = MediatorLiveData<List<Track>>().apply {
addSource(_queue) { merge(_queue, _current, _cached) }
addSource(_current) { merge(_queue, _current, _cached) }
addSource(_cached) { merge(_queue, _current, _cached) }
}
private fun merge(_tracks: LiveData<List<Track>>, _current: LiveData<Track>, _cached: LiveData<Set<String>>) {
val _tracks = _tracks.value
val _current = _current.value
val _cached = _cached.value
if (_tracks == null || _cached == null) {
return
}
queue.value = _tracks.map { track ->
track.current = _current?.id == track.id
track.cached = _cached.contains(maybeNormalizeUrl(track.bestUpload()?.listen_url))
track
}
}
}

View File

@ -0,0 +1,20 @@
package com.github.apognu.otter.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import com.github.apognu.otter.Otter
import com.github.apognu.otter.models.dao.RadioEntity
class RadiosViewModel : ViewModel() {
companion object {
private lateinit var instance: RadiosViewModel
fun get(): RadiosViewModel {
instance = if (::instance.isInitialized) instance else RadiosViewModel()
return instance
}
}
val radios: LiveData<List<RadioEntity>> by lazy { Otter.get().database.radios().all() }
}

View File

@ -0,0 +1,66 @@
package com.github.apognu.otter.viewmodels
import androidx.lifecycle.*
import com.github.apognu.otter.Otter
import com.github.apognu.otter.models.domain.Track
import com.github.apognu.otter.utils.maybeNormalizeUrl
import kotlinx.coroutines.delay
class TracksViewModel(private val albumId: Int) : ViewModel() {
private val _cached = liveData {
while (true) {
emit(Otter.get().exoCache.keys)
delay(5000)
}
}
private val _current = PlayerStateViewModel.get().track
private val _tracks: LiveData<List<Track>> by lazy {
Transformations.map(Otter.get().database.tracks().ofAlbumsDecorated(listOf(albumId))) {
it.map { track -> Track.fromDecoratedEntity(track) }
}
}
private val _favorites: LiveData<List<Track>> by lazy {
Transformations.map(Otter.get().database.tracks().favorites()) {
it.map { track -> Track.fromDecoratedEntity(track) }
}
}
val tracks = MediatorLiveData<List<Track>>().apply {
addSource(_tracks) { mergeTracks(_tracks, _current, _cached) }
addSource(_current) { mergeTracks(_tracks, _current, _cached) }
addSource(_cached) { mergeTracks(_tracks, _current, _cached) }
}
val favorites = MediatorLiveData<List<Track>>().apply {
addSource(_favorites) { mergeFavorites(_favorites, _current, _cached) }
addSource(_current) { mergeFavorites(_favorites, _current, _cached) }
addSource(_cached) { mergeFavorites(_favorites, _current, _cached) }
}
private fun mergeTracks(_tracks: LiveData<List<Track>>, _current: LiveData<Track>, _cached: LiveData<Set<String>>) {
tracks.value = merge(_tracks, _current, _cached) ?: return
}
private fun mergeFavorites(_tracks: LiveData<List<Track>>, _current: LiveData<Track>, _cached: LiveData<Set<String>>) {
favorites.value = merge(_tracks, _current, _cached) ?: return
}
private fun merge(_tracks: LiveData<List<Track>>, _current: LiveData<Track>, _cached: LiveData<Set<String>>): List<Track>? {
val _tracks = _tracks.value
val _current = _current.value
val _cached = _cached.value
if (_tracks == null || _cached == null) {
return null
}
return _tracks.map { track ->
track.current = _current?.id == track.id
track.cached = _cached.contains(maybeNormalizeUrl(track.bestUpload()?.listen_url))
track
}
}
}

View File

@ -11,7 +11,7 @@
android:baselineAligned="false"
android:orientation="horizontal">
<FrameLayout
<com.github.apognu.otter.views.DisableableFrameLayout
android:id="@+id/container"
android:layout_width="0dp"
android:layout_height="match_parent"
@ -19,7 +19,7 @@
android:layout_weight="1"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<FrameLayout
<com.github.apognu.otter.views.DisableableFrameLayout
android:id="@+id/landscape_queue"
android:layout_width="0dp"
android:layout_height="match_parent"

View File

@ -41,6 +41,7 @@
android:orientation="vertical">
<TextView
android:id="@+id/title"
style="@style/AppTheme.Title"
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@ -7,6 +7,7 @@ buildscript {
dependencies {
classpath("com.android.tools.build:gradle:4.0.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.72")
classpath("io.realm:realm-gradle-plugin:10.0.0-BETA.6")
}
}

View File

@ -1,4 +1,4 @@
org.gradle.jvmargs=-Xmx1536m
org.gradle.jvmargs=-Xmx4608m
kotlin.code.style=official