Browse Source

Initial commit.

pull/38/head
Antoine POPINEAU 3 years ago
committed by Antoine POPINEAU
commit
5f495f54e5
No known key found for this signature in database GPG Key ID: A78AC64694F84063
  1. 8
      .gitignore
  2. 21
      LICENSE
  3. 2
      app/.gitignore
  4. 61
      app/build.gradle
  5. 21
      app/proguard-rules.pro
  6. 45
      app/src/main/AndroidManifest.xml
  7. 17
      app/src/main/java/com/github/apognu/otter/Otter.kt
  8. 102
      app/src/main/java/com/github/apognu/otter/activities/LicencesActivity.kt
  9. 97
      app/src/main/java/com/github/apognu/otter/activities/LoginActivity.kt
  10. 303
      app/src/main/java/com/github/apognu/otter/activities/MainActivity.kt
  11. 66
      app/src/main/java/com/github/apognu/otter/activities/SearchActivity.kt
  12. 121
      app/src/main/java/com/github/apognu/otter/activities/SettingsActivity.kt
  13. 55
      app/src/main/java/com/github/apognu/otter/adapters/AlbumsAdapter.kt
  14. 52
      app/src/main/java/com/github/apognu/otter/adapters/AlbumsGridAdapter.kt
  15. 65
      app/src/main/java/com/github/apognu/otter/adapters/ArtistsAdapter.kt
  16. 44
      app/src/main/java/com/github/apognu/otter/adapters/BrowseTabsAdapter.kt
  17. 183
      app/src/main/java/com/github/apognu/otter/adapters/FavoritesAdapter.kt
  18. 179
      app/src/main/java/com/github/apognu/otter/adapters/PlaylistTracksAdapter.kt
  19. 65
      app/src/main/java/com/github/apognu/otter/adapters/PlaylistsAdapter.kt
  20. 32
      app/src/main/java/com/github/apognu/otter/adapters/SearchResultsAdapter.kt
  21. 206
      app/src/main/java/com/github/apognu/otter/adapters/TracksAdapter.kt
  22. 93
      app/src/main/java/com/github/apognu/otter/fragments/AlbumsFragment.kt
  23. 60
      app/src/main/java/com/github/apognu/otter/fragments/AlbumsGridFragment.kt
  24. 58
      app/src/main/java/com/github/apognu/otter/fragments/ArtistsFragment.kt
  25. 34
      app/src/main/java/com/github/apognu/otter/fragments/BrowseFragment.kt
  26. 71
      app/src/main/java/com/github/apognu/otter/fragments/FavoritesFragment.kt
  27. 80
      app/src/main/java/com/github/apognu/otter/fragments/FunkwhaleFragment.kt
  28. 23
      app/src/main/java/com/github/apognu/otter/fragments/LoginDialog.kt
  29. 120
      app/src/main/java/com/github/apognu/otter/fragments/PlaylistTracksFragment.kt
  30. 55
      app/src/main/java/com/github/apognu/otter/fragments/PlaylistsFragment.kt
  31. 89
      app/src/main/java/com/github/apognu/otter/fragments/QueueFragment.kt
  32. 122
      app/src/main/java/com/github/apognu/otter/fragments/TracksFragment.kt
  33. 125
      app/src/main/java/com/github/apognu/otter/playback/MediaControlsManager.kt
  34. 442
      app/src/main/java/com/github/apognu/otter/playback/PlayerService.kt
  35. 158
      app/src/main/java/com/github/apognu/otter/playback/QueueManager.kt
  36. 32
      app/src/main/java/com/github/apognu/otter/repositories/AlbumsRepository.kt
  37. 18
      app/src/main/java/com/github/apognu/otter/repositories/ArtistsRepository.kt
  38. 55
      app/src/main/java/com/github/apognu/otter/repositories/FavoritesRepository.kt
  39. 102
      app/src/main/java/com/github/apognu/otter/repositories/HttpUpstream.kt
  40. 18
      app/src/main/java/com/github/apognu/otter/repositories/PlaylistTracksRepository.kt
  41. 18
      app/src/main/java/com/github/apognu/otter/repositories/PlaylistsRepository.kt
  42. 75
      app/src/main/java/com/github/apognu/otter/repositories/Repository.kt
  43. 35
      app/src/main/java/com/github/apognu/otter/repositories/SearchRepository.kt
  44. 33
      app/src/main/java/com/github/apognu/otter/repositories/TracksRepository.kt
  45. 75
      app/src/main/java/com/github/apognu/otter/utils/AppContext.kt
  46. 90
      app/src/main/java/com/github/apognu/otter/utils/Data.kt
  47. 117
      app/src/main/java/com/github/apognu/otter/utils/EventBus.kt
  48. 88
      app/src/main/java/com/github/apognu/otter/utils/Extensions.kt
  49. 113
      app/src/main/java/com/github/apognu/otter/utils/Models.kt
  50. 26
      app/src/main/java/com/github/apognu/otter/utils/Util.kt
  51. 79
      app/src/main/java/com/github/apognu/otter/views/ExplodeReveal.kt
  52. 240
      app/src/main/java/com/github/apognu/otter/views/NowPlayingView.kt
  53. 17
      app/src/main/java/com/github/apognu/otter/views/SquareImageView.kt
  54. BIN
      app/src/main/res/drawable-hdpi/ottericon.png
  55. BIN
      app/src/main/res/drawable-mdpi/ottericon.png
  56. BIN
      app/src/main/res/drawable-xhdpi/ottericon.png
  57. BIN
      app/src/main/res/drawable-xxhdpi/ottericon.png
  58. BIN
      app/src/main/res/drawable-xxxhdpi/ottericon.png
  59. 9
      app/src/main/res/drawable/add.xml
  60. BIN
      app/src/main/res/drawable/cover.png
  61. 9
      app/src/main/res/drawable/favorite.xml
  62. 8
      app/src/main/res/drawable/login_input.xml
  63. 9
      app/src/main/res/drawable/more.xml
  64. 9
      app/src/main/res/drawable/next.xml
  65. BIN
      app/src/main/res/drawable/ottershape.png
  66. 9
      app/src/main/res/drawable/pause.xml
  67. 9
      app/src/main/res/drawable/play.xml
  68. 9
      app/src/main/res/drawable/previous.xml
  69. 9
      app/src/main/res/drawable/queue.xml
  70. 9
      app/src/main/res/drawable/reorder.xml
  71. 9
      app/src/main/res/drawable/search.xml
  72. 9
      app/src/main/res/drawable/settings.xml
  73. 31
      app/src/main/res/layout/activity_licences.xml
  74. 97
      app/src/main/res/layout/activity_login.xml
  75. 44
      app/src/main/res/layout/activity_main.xml
  76. 68
      app/src/main/res/layout/activity_search.xml
  77. 21
      app/src/main/res/layout/activity_settings.xml
  78. 15
      app/src/main/res/layout/dialog_login.xml
  79. 109
      app/src/main/res/layout/fragment_albums.xml
  80. 45
      app/src/main/res/layout/fragment_albums_grid.xml
  81. 48
      app/src/main/res/layout/fragment_artists.xml
  82. 26
      app/src/main/res/layout/fragment_browse.xml
  83. 59
      app/src/main/res/layout/fragment_favorites.xml
  84. 47
      app/src/main/res/layout/fragment_playlists.xml
  85. 45
      app/src/main/res/layout/fragment_queue.xml
  86. 206
      app/src/main/res/layout/fragment_tracks.xml
  87. 218
      app/src/main/res/layout/partial_now_playing.xml
  88. 7
      app/src/main/res/layout/preference_category.xml
  89. 46
      app/src/main/res/layout/row_album.xml
  90. 28
      app/src/main/res/layout/row_album_grid.xml
  91. 47
      app/src/main/res/layout/row_artist.xml
  92. 24
      app/src/main/res/layout/row_licence.xml
  93. 114
      app/src/main/res/layout/row_playlist.xml
  94. 72
      app/src/main/res/layout/row_track.xml
  95. 8
      app/src/main/res/menu/row_queue.xml
  96. 16
      app/src/main/res/menu/row_track.xml
  97. 31
      app/src/main/res/menu/toolbar.xml
  98. 5
      app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
  99. 5
      app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
  100. BIN
      app/src/main/res/mipmap-hdpi/ic_launcher.png

8
.gitignore

@ -0,0 +1,8 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild

21
LICENSE

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Antoine POPINEAU
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

2
app/.gitignore

@ -0,0 +1,2 @@
/build
/release

61
app/build.gradle

@ -0,0 +1,61 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8
}
compileSdkVersion 29
defaultConfig {
applicationId "com.github.apognu.otter"
minSdkVersion 23
targetSdkVersion 29
versionCode 4
versionName "1.0.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.2'
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.core:core-ktx:1.2.0-beta01'
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.0.0'
implementation 'androidx.preference:preference:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.0.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.0.0'
implementation 'com.google.android.material:material:1.1.0-beta01'
implementation 'com.android.support.constraint:constraint-layout:1.1.3'
implementation 'com.google.android.exoplayer:exoplayer:2.10.3'
implementation 'com.google.android.exoplayer:extension-mediasession:2.10.6'
implementation 'com.google.android.exoplayer:extension-cast:2.10.6'
implementation 'com.aliassadi:power-preference-lib:1.4.1'
implementation 'com.github.kittinunf.fuel:fuel:2.1.0'
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.google.code.gson:gson:2.8.5'
implementation 'com.squareup.picasso:picasso:2.71828'
implementation 'jp.wasabeef:picasso-transformations:2.2.1'
}

21
app/proguard-rules.pro

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

45
app/src/main/AndroidManifest.xml

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.github.apognu.otter">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<permission android:name="android.permission.MEDIA_CONTENT_CONTROL"/>
<application
android:name="com.github.apognu.otter.Otter"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:screenOrientation="portrait"
android:theme="@style/AppTheme">
<!-- <meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.google.android.exoplayer2.ext.cast.DefaultCastOptionsProvider"/> -->
<activity android:name="com.github.apognu.otter.activities.LoginActivity" android:noHistory="true" android:launchMode="singleInstance">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity android:name="com.github.apognu.otter.activities.MainActivity"/>
<activity android:name="com.github.apognu.otter.activities.SearchActivity" android:launchMode="singleTop"/>
<activity android:name="com.github.apognu.otter.activities.SettingsActivity"/>
<activity android:name="com.github.apognu.otter.activities.LicencesActivity"/>
<service android:name="com.github.apognu.otter.playback.PlayerService"/>
<receiver android:name="com.github.apognu.otter.playback.MediaControlActionReceiver"/>
</application>
</manifest>

17
app/src/main/java/com/github/apognu/otter/Otter.kt

@ -0,0 +1,17 @@
package com.github.apognu.otter
import android.app.Application
import androidx.appcompat.app.AppCompatDelegate
import com.preference.PowerPreference
class Otter : Application() {
override fun onCreate() {
super.onCreate()
when (PowerPreference.getDefaultFile().getString("night_mode")) {
"on" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
"off" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
else -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
}
}
}

102
app/src/main/java/com/github/apognu/otter/activities/LicencesActivity.kt

@ -0,0 +1,102 @@
package com.github.apognu.otter.activities
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import kotlinx.android.synthetic.main.activity_licences.*
import kotlinx.android.synthetic.main.row_licence.view.*
class LicencesActivity : AppCompatActivity() {
data class Licence(val name: String, val licence: String, val url: String)
interface OnLicenceClickListener {
fun onClick(url: String)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_licences)
LicencesAdapter(OnLicenceClick()).also {
licences.layoutManager = LinearLayoutManager(this)
licences.adapter = it
}
}
private inner class LicencesAdapter(val listener: OnLicenceClickListener) : RecyclerView.Adapter<LicencesAdapter.ViewHolder>() {
val licences = listOf(
Licence(
"ExoPlayer",
"Apache License 2.0",
"https://github.com/google/ExoPlayer/blob/release-v2/LICENSE"
),
Licence(
"Fuel",
"MIT License",
"https://github.com/kittinunf/fuel/blob/master/LICENSE.md"
),
Licence(
"Gson",
"Apache License 2.0",
"https://github.com/google/gson/blob/master/LICENSE"
),
Licence(
"Picasso",
"Apache License 2.0",
"https://github.com/square/picasso/blob/master/LICENSE.txt"
),
Licence(
"Picasso Transformations",
"Apache License 2.0",
"https://github.com/wasabeef/picasso-transformations/blob/master/LICENSE"
),
Licence(
"PowerPreference",
"Apache License 2.0",
"https://github.com/AliAsadi/PowerPreference/blob/master/LICENSE"
)
)
override fun getItemCount() = licences.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(this@LicencesActivity).inflate(R.layout.row_licence, parent, false)
return ViewHolder(view).also {
view.setOnClickListener(it)
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = licences[position]
holder.name.text = item.name
holder.licence.text = item.licence
}
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view), View.OnClickListener {
val name = view.name
val licence = view.licence
override fun onClick(view: View?) {
listener.onClick(licences[layoutPosition].url)
}
}
}
inner class OnLicenceClick : OnLicenceClickListener {
override fun onClick(url: String) {
Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
startActivity(this)
}
}
}
}

97
app/src/main/java/com/github/apognu/otter/activities/LoginActivity.kt

@ -0,0 +1,97 @@
package com.github.apognu.otter.activities
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.appcompat.app.AppCompatActivity
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.LoginDialog
import com.github.apognu.otter.utils.AppContext
import com.github.apognu.otter.utils.log
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.coroutines.awaitObjectResult
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
import com.preference.PowerPreference
import kotlinx.android.synthetic.main.activity_login.*
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
data class FwCredentials(val token: String)
class LoginActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
getSharedPreferences(AppContext.PREFS_CREDENTIALS, Context.MODE_PRIVATE).apply {
when (contains("access_token")) {
true -> Intent(this@LoginActivity, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NO_ANIMATION
startActivity(this)
}
false -> setContentView(R.layout.activity_login)
}
}
login?.setOnClickListener {
val hostname = hostname.text.toString().trim()
val username = username.text.toString()
val password = password.text.toString()
try {
if (hostname.isEmpty()) throw Exception(getString(R.string.login_error_hostname))
val url = Uri.parse(hostname)
if (url.scheme != "https") {
throw Exception(getString(R.string.login_error_hostname_https))
}
} catch (e: Exception) {
val message =
if (e.message?.isEmpty() == true) getString(R.string.login_error_hostname)
else e.message
hostname_field.error = message
return@setOnClickListener
}
hostname_field.error = ""
val body = mapOf(
"username" to username,
"password" to password
).toList()
val dialog = LoginDialog().apply {
show(supportFragmentManager, "LoginDialog")
}
GlobalScope.launch(Main) {
val result = Fuel.post("$hostname/api/v1/token", body)
.awaitObjectResult(gsonDeserializerOf(FwCredentials::class.java))
result.fold(
{ data ->
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply {
setString("hostname", hostname)
setString("username", username)
setString("password", password)
setString("access_token", data.token)
}
dialog.dismiss()
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
},
{ error ->
dialog.dismiss()
hostname_field.error = error.localizedMessage
}
)
}
}
}
}

303
app/src/main/java/com/github/apognu/otter/activities/MainActivity.kt

@ -0,0 +1,303 @@
package com.github.apognu.otter.activities
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.SeekBar
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.BrowseFragment
import com.github.apognu.otter.fragments.QueueFragment
import com.github.apognu.otter.playback.MediaControlsManager
import com.github.apognu.otter.playback.PlayerService
import com.github.apognu.otter.repositories.FavoritesRepository
import com.github.apognu.otter.repositories.Repository
import com.github.apognu.otter.utils.*
import com.preference.PowerPreference
import com.squareup.picasso.Picasso
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.main.partial_now_playing.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class MainActivity : AppCompatActivity() {
private val favoriteRepository = FavoritesRepository(this)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
AppContext.init(this)
setContentView(R.layout.activity_main)
setSupportActionBar(appbar)
when (intent.action) {
MediaControlsManager.NOTIFICATION_ACTION_OPEN_QUEUE.toString() -> launchDialog(QueueFragment())
}
supportFragmentManager
.beginTransaction()
.replace(R.id.container, BrowseFragment())
.commit()
startService(Intent(this, PlayerService::class.java))
watchEventBus()
CommandBus.send(Command.RefreshService)
}
override fun onResume() {
super.onResume()
now_playing_toggle.setOnClickListener {
CommandBus.send(Command.ToggleState)
}
now_playing_next.setOnClickListener {
CommandBus.send(Command.NextTrack)
}
now_playing_details_previous.setOnClickListener {
CommandBus.send(Command.PreviousTrack)
}
now_playing_details_next.setOnClickListener {
CommandBus.send(Command.NextTrack)
}
now_playing_details_toggle.setOnClickListener {
CommandBus.send(Command.ToggleState)
}
now_playing_details_progress.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onStopTrackingTouch(view: SeekBar?) {}
override fun onStartTrackingTouch(view: SeekBar?) {}
override fun onProgressChanged(view: SeekBar?, progress: Int, fromUser: Boolean) {
if (fromUser) {
CommandBus.send(Command.Seek(progress))
}
}
})
}
override fun onBackPressed() {
if (now_playing.isOpened()) {
now_playing.close()
return
}
super.onBackPressed()
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.toolbar, menu)
// CastButtonFactory.setUpMediaRouteButton(this, menu, R.id.cast)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
now_playing.close()
(supportFragmentManager.fragments.last() as? BrowseFragment)?.let {
it.selectTabAt(0)
return true
}
launchFragment(BrowseFragment())
}
R.id.nav_queue -> launchDialog(QueueFragment())
R.id.nav_search -> startActivity(Intent(this, SearchActivity::class.java))
R.id.settings -> startActivity(Intent(this, SettingsActivity::class.java))
}
return true
}
private fun launchFragment(fragment: Fragment) {
supportFragmentManager.fragments.lastOrNull()?.also { oldFragment ->
oldFragment.enterTransition = null
oldFragment.exitTransition = null
supportFragmentManager.popBackStackImmediate(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
}
supportFragmentManager
.beginTransaction()
.setCustomAnimations(0, 0, 0, 0)
.replace(R.id.container, fragment)
.commit()
}
private fun launchDialog(fragment: DialogFragment) {
supportFragmentManager.beginTransaction().let {
fragment.show(it, "")
}
}
@SuppressLint("NewApi")
private fun watchEventBus() {
GlobalScope.launch(Main) {
for (message in EventBus.asChannel<Event>()) {
when (message) {
is Event.LogOut -> {
PowerPreference.clearAllData()
startActivity(Intent(this@MainActivity, LoginActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NO_HISTORY
})
finish()
}
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 {
it.bottomMargin = it.bottomMargin / 2
}
now_playing.animate()
.alpha(0.0f)
.setDuration(400)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animator: Animator?) {
now_playing.visibility = View.GONE
}
})
.start()
}
}
is Event.TrackPlayed -> {
message.track?.let { track ->
if (now_playing.visibility == View.GONE) {
now_playing.visibility = View.VISIBLE
now_playing.alpha = 0f
now_playing.animate()
.alpha(1.0f)
.setDuration(400)
.setListener(null)
.start()
(container.layoutParams as? ViewGroup.MarginLayoutParams)?.let {
it.bottomMargin = it.bottomMargin * 2
}
}
now_playing_title.text = track.title
now_playing_album.text = track.artist.name
now_playing_toggle.icon = getDrawable(R.drawable.pause)
now_playing_progress.progress = 0
now_playing_details_title.text = track.title
now_playing_details_artist.text = track.artist.name
now_playing_details_toggle.icon = getDrawable(R.drawable.pause)
now_playing_details_progress.progress = 0
Picasso.get()
.load(normalizeUrl(track.album.cover.original))
.fit()
.centerCrop()
.into(now_playing_cover)
Picasso.get()
.load(normalizeUrl(track.album.cover.original))
.fit()
.centerCrop()
.into(now_playing_details_cover)
favoriteRepository.fetch().untilNetwork(IO) { favorites ->
GlobalScope.launch(Main) {
val favorites = favorites.map { it.track.id }
track.favorite = favorites.contains(track.id)
when (track.favorite) {
true -> now_playing_details_favorite.setColorFilter(resources.getColor(R.color.colorFavorite))
false -> now_playing_details_favorite.setColorFilter(resources.getColor(R.color.controlForeground))
}
}
}
now_playing_details_favorite.setOnClickListener {
when (track.favorite) {
true -> {
favoriteRepository.deleteFavorite(track.id)
now_playing_details_favorite.setColorFilter(resources.getColor(R.color.controlForeground))
}
false -> {
favoriteRepository.addFavorite(track.id)
now_playing_details_favorite.setColorFilter(resources.getColor(R.color.colorFavorite))
}
}
track.favorite = !track.favorite
favoriteRepository.fetch(Repository.Origin.Network.origin)
}
}
}
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)
}
}
}
}
}
}
GlobalScope.launch(Main) {
for ((current, duration, percent) in ProgressBus.asChannel()) {
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)
}
}
}
}

66
app/src/main/java/com/github/apognu/otter/activities/SearchActivity.kt

@ -0,0 +1,66 @@
package com.github.apognu.otter.activities
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.apognu.otter.R
import com.github.apognu.otter.adapters.TracksAdapter
import com.github.apognu.otter.repositories.Repository
import com.github.apognu.otter.repositories.SearchRepository
import com.github.apognu.otter.utils.untilNetwork
import kotlinx.android.synthetic.main.activity_search.*
class SearchActivity : AppCompatActivity() {
private lateinit var adapter: TracksAdapter
lateinit var repository: SearchRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_search)
adapter = TracksAdapter(this).also {
results.layoutManager = LinearLayoutManager(this)
results.adapter = it
}
}
override fun onResume() {
super.onResume()
search.requestFocus()
search.setOnQueryTextListener(object : androidx.appcompat.widget.SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
query?.let {
repository = SearchRepository(this@SearchActivity, it.toLowerCase())
search_spinner.visibility = View.VISIBLE
search_no_results.visibility = View.GONE
adapter.data.clear()
adapter.notifyDataSetChanged()
repository.fetch(Repository.Origin.Network.origin).untilNetwork { tracks ->
search_spinner.visibility = View.GONE
search_empty.visibility = View.GONE
when (tracks.isEmpty()) {
true -> search_no_results.visibility = View.VISIBLE
false -> adapter.data = tracks.toMutableList()
}
adapter.notifyDataSetChanged()
}
}
return true
}
override fun onQueryTextChange(newText: String?) = true
})
}
}

121
app/src/main/java/com/github/apognu/otter/activities/SettingsActivity.kt

@ -0,0 +1,121 @@
package com.github.apognu.otter.activities
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SeekBarPreference
import com.github.apognu.otter.R
import com.github.apognu.otter.utils.AppContext
import com.preference.PowerPreference
class SettingsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
supportFragmentManager
.beginTransaction()
.replace(
R.id.container,
SettingsFragment()
)
.commit()
}
fun getThemeResId(): Int = R.style.AppTheme
}
class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedPreferenceChangeListener {
override fun onResume() {
super.onResume()
preferenceScreen.sharedPreferences.registerOnSharedPreferenceChangeListener(this)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings, rootKey)
updateValues()
}
override fun onPreferenceTreeClick(preference: Preference?): Boolean {
when (preference?.key) {
"oss_licences" -> startActivity(Intent(activity, LicencesActivity::class.java))
"logout" -> {
context?.let { context ->
AlertDialog.Builder(context)
.setTitle(context.getString(R.string.logout_title))
.setMessage(context.getString(R.string.logout_content))
.setPositiveButton(android.R.string.yes) { _, _ ->
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).clear()
Intent(context, LoginActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(this)
activity?.finish()
}
}
.setNegativeButton(android.R.string.no, null)
.show()
}
}
}
updateValues()
return super.onPreferenceTreeClick(preference)
}
override fun onSharedPreferenceChanged(preferences: SharedPreferences?, key: String?) {
updateValues()
}
private fun updateValues() {
(activity as? AppCompatActivity)?.let { activity ->
preferenceManager.findPreference<ListPreference>("media_quality")?.let {
it.summary = when (it.value) {
"quality" -> activity.getString(R.string.settings_media_quality_summary_quality)
"size" -> activity.getString(R.string.settings_media_quality_summary_size)
else -> activity.getString(R.string.settings_media_quality_summary_size)
}
}
preferenceManager.findPreference<ListPreference>("night_mode")?.let {
when (it.value) {
"on" -> {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
activity.delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES
it.summary = getString(R.string.settings_night_mode_on_summary)
}
"off" -> {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
activity.delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_NO
it.summary = getString(R.string.settings_night_mode_off_summary)
}
else -> {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
activity.delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
it.summary = getString(R.string.settings_night_mode_system_summary)
}
}
}
preferenceManager.findPreference<SeekBarPreference>("media_cache_size")?.let {
it.summary = getString(R.string.settings_media_cache_size_summary, it.value)
}
}
}
}

55
app/src/main/java/com/github/apognu/otter/adapters/AlbumsAdapter.kt

@ -0,0 +1,55 @@
package com.github.apognu.otter.adapters
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.FunkwhaleAdapter
import com.github.apognu.otter.utils.Album
import com.github.apognu.otter.utils.normalizeUrl
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.row_album.view.*
import kotlinx.android.synthetic.main.row_artist.view.art
class AlbumsAdapter(val context: Context?, val listener: OnAlbumClickListener) : FunkwhaleAdapter<Album, AlbumsAdapter.ViewHolder>() {
interface OnAlbumClickListener {
fun onClick(view: View?, album: Album)
}
override fun getItemCount() = data.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(context).inflate(R.layout.row_album, parent, false)
return ViewHolder(view, listener).also {
view.setOnClickListener(it)
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val album = data[position]
Picasso.get()
.load(normalizeUrl(album.cover.original))
.fit()
.placeholder(R.drawable.cover)
.transform(RoundedCornersTransformation(16, 0))
.into(holder.art)
holder.title.text = album.title
holder.artist.text = album.artist.name
}
inner class ViewHolder(view: View, val listener: OnAlbumClickListener) : RecyclerView.ViewHolder(view), View.OnClickListener {
val art = view.art
val title = view.title
val artist = view.artist
override fun onClick(view: View?) {
listener.onClick(view, data[layoutPosition])
}
}
}

52
app/src/main/java/com/github/apognu/otter/adapters/AlbumsGridAdapter.kt

@ -0,0 +1,52 @@
package com.github.apognu.otter.adapters
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.FunkwhaleAdapter
import com.github.apognu.otter.utils.Album
import com.github.apognu.otter.utils.normalizeUrl
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.row_album_grid.view.*
class AlbumsGridAdapter(val context: Context?, val listener: OnAlbumClickListener) : FunkwhaleAdapter<Album, AlbumsGridAdapter.ViewHolder>() {
interface OnAlbumClickListener {
fun onClick(view: View?, album: Album)
}
override fun getItemCount() = data.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(context).inflate(R.layout.row_album_grid, parent, false)
return ViewHolder(view, listener).also {
view.setOnClickListener(it)
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val album = data[position]
Picasso.get()
.load(normalizeUrl(album.cover.original))
.fit()
.placeholder(R.drawable.cover)
.transform(RoundedCornersTransformation(24, 0))
.into(holder.cover)
holder.title.text = album.title
}
inner class ViewHolder(view: View, val listener: OnAlbumClickListener) : RecyclerView.ViewHolder(view), View.OnClickListener {
val cover = view.cover
val title = view.title
override fun onClick(view: View?) {
listener.onClick(view, data[layoutPosition])
}
}
}

65
app/src/main/java/com/github/apognu/otter/adapters/ArtistsAdapter.kt

@ -0,0 +1,65 @@
package com.github.apognu.otter.adapters
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.FunkwhaleAdapter
import com.github.apognu.otter.utils.Artist
import com.github.apognu.otter.utils.normalizeUrl
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.row_artist.view.*
class ArtistsAdapter(val context: Context?, val listener: OnArtistClickListener) : FunkwhaleAdapter<Artist, ArtistsAdapter.ViewHolder>() {
interface OnArtistClickListener {
fun onClick(holder: View?, artist: Artist)
}
override fun getItemCount() = data.size
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)
return ViewHolder(view, listener).also {
view.setOnClickListener(it)
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val artist = data[position]
artist.albums?.let { albums ->
if (albums.isNotEmpty()) {
Picasso.get()
.load(normalizeUrl(albums[0].cover.original))
.fit()
.placeholder(R.drawable.cover)
.transform(RoundedCornersTransformation(16, 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)
}
}
}
inner class ViewHolder(view: View, val listener: OnArtistClickListener) : RecyclerView.ViewHolder(view), View.OnClickListener {
val art = view.art
val name = view.name
val albums = view.albums
override fun onClick(view: View?) {
listener.onClick(view, data[layoutPosition])
}
}
}

44
app/src/main/java/com/github/apognu/otter/adapters/BrowseTabsAdapter.kt

@ -0,0 +1,44 @@
package com.github.apognu.otter.adapters
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentPagerAdapter
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.AlbumsGridFragment
import com.github.apognu.otter.fragments.ArtistsFragment
import com.github.apognu.otter.fragments.FavoritesFragment
import com.github.apognu.otter.fragments.PlaylistsFragment
class BrowseTabsAdapter(val context: Fragment, manager: FragmentManager) : FragmentPagerAdapter(manager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
var tabs = mutableListOf<Fragment>()
override fun getCount() = 4
override fun getItem(position: Int): Fragment {
tabs.getOrNull(position)?.let {
return it
}
val fragment = when (position) {
0 -> ArtistsFragment()
1 -> AlbumsGridFragment()
2 -> PlaylistsFragment()
3 -> FavoritesFragment()
else -> ArtistsFragment()
}
tabs.add(position, fragment)
return fragment
}
override fun getPageTitle(position: Int): String {
return when (position) {
0 -> context.getString(R.string.artists)
1 -> context.getString(R.string.albums)
2 -> context.getString(R.string.playlists)
3 -> context.getString(R.string.favorites)
else -> ""
}
}
}

183
app/src/main/java/com/github/apognu/otter/adapters/FavoritesAdapter.kt

@ -0,0 +1,183 @@
package com.github.apognu.otter.adapters
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable
import android.os.Build
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.FunkwhaleAdapter
import com.github.apognu.otter.utils.*
import com.squareup.picasso.Picasso
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation
import kotlinx.android.synthetic.main.row_track.view.*
import java.util.*
class FavoritesAdapter(private val context: Context?, val favoriteListener: OnFavoriteListener, val fromQueue: Boolean = false) : FunkwhaleAdapter<Favorite, FavoritesAdapter.ViewHolder>() {
interface OnFavoriteListener {
fun onToggleFavorite(id: Int, state: Boolean)
}
var currentTrack: Track? = null
override fun getItemCount() = data.size
override fun getItemId(position: Int): Long {
return data[position].track.id.toLong()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(context).inflate(R.layout.row_track, parent, false)
return ViewHolder(view, context).also {
view.setOnClickListener(it)
}
}
@SuppressLint("NewApi")
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val favorite = data[position]
Picasso.get()
.load(normalizeUrl(favorite.track.album.cover.original))
.fit()
.placeholder(R.drawable.cover)
.transform(RoundedCornersTransformation(16, 0))
.into(holder.cover)
holder.title.text = favorite.track.title
holder.artist.text = favorite.track.artist.name
Build.VERSION_CODES.P.onApi(
{
holder.title.setTypeface(holder.title.typeface, Typeface.DEFAULT.weight)
holder.artist.setTypeface(holder.artist.typeface, Typeface.DEFAULT.weight)
},
{
holder.title.setTypeface(holder.title.typeface, Typeface.NORMAL)
holder.artist.setTypeface(holder.artist.typeface, Typeface.NORMAL)
})
if (favorite.track == currentTrack || favorite.track.current) {
holder.title.setTypeface(holder.title.typeface, Typeface.BOLD)
holder.artist.setTypeface(holder.artist.typeface, Typeface.BOLD)
}
context?.let {
when (favorite.track.favorite) {
true -> holder.favorite.setColorFilter(context.resources.getColor(R.color.colorFavorite))
false -> holder.favorite.setColorFilter(context.resources.getColor(R.color.colorSelected))
}
holder.favorite.setOnClickListener {
favoriteListener.onToggleFavorite(favorite.track.id, !favorite.track.favorite)
data.remove(favorite)
notifyItemRemoved(holder.adapterPosition)
}
}
holder.actions.setOnClickListener {
context?.let { context ->
PopupMenu(context, holder.actions, Gravity.START, R.attr.actionOverflowMenuStyle, 0).apply {
inflate(if (fromQueue) R.menu.row_queue else R.menu.row_track)
setOnMenuItemClickListener {
when (it.itemId) {
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(favorite.track)))
R.id.track_play_next -> CommandBus.send(Command.PlayNext(favorite.track))
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(favorite.track))
}
true
}
show()
}
}
}
}
fun onItemMove(oldPosition: Int, newPosition: Int) {
if (oldPosition < newPosition) {
for (i in oldPosition.rangeTo(newPosition - 1)) {
Collections.swap(data, i, i + 1)
}
} else {
for (i in newPosition.downTo(oldPosition)) {
Collections.swap(data, i, i - 1)
}
}
notifyItemMoved(oldPosition, newPosition)
CommandBus.send(Command.MoveFromQueue(oldPosition, newPosition))
}
inner class ViewHolder(view: View, val context: Context?) : RecyclerView.ViewHolder(view), View.OnClickListener {
val handle = view.handle
val cover = view.cover
val title = view.title
val artist = view.artist
val favorite = view.favorite
val actions = view.actions
override fun onClick(view: View?) {
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 }))
context.toast("All tracks were added to your queue")
}
}
}
}
}
inner class TouchHelperCallback : ItemTouchHelper.Callback() {
override fun isLongPressDragEnabled() = false
override fun isItemViewSwipeEnabled() = false
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) =
makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0)
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
onItemMove(viewHolder.adapterPosition, target.adapterPosition)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {}
@SuppressLint("NewApi")
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
context?.let {
Build.VERSION_CODES.M.onApi(
{ viewHolder?.itemView?.background = ColorDrawable(context.resources.getColor(R.color.colorSelected, null)) },
{ viewHolder?.itemView?.background = ColorDrawable(context.resources.getColor(R.color.colorSelected)) })
}
}
super.onSelectedChanged(viewHolder, actionState)
}
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
viewHolder.itemView.background = ColorDrawable(Color.TRANSPARENT)
super.clearView(recyclerView, viewHolder)
}
}
}

179
app/src/main/java/com/github/apognu/otter/adapters/PlaylistTracksAdapter.kt

@ -0,0 +1,179 @@
package com.github.apognu.otter.adapters
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable
import android.os.Build
import android.view.*
import androidx.appcompat.widget.PopupMenu
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.FunkwhaleAdapter
import com.github.apognu.otter.utils.*
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?, val fromQueue: Boolean = false) : FunkwhaleAdapter<PlaylistTrack, PlaylistTracksAdapter.ViewHolder>() {
private lateinit var touchHelper: ItemTouchHelper
var currentTrack: Track? = null
override fun getItemCount() = data.size
override fun getItemId(position: Int): Long {
return data[position].track.id.toLong()
}
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
if (fromQueue) {
touchHelper = ItemTouchHelper(TouchHelperCallback()).also {
it.attachToRecyclerView(recyclerView)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(context).inflate(R.layout.row_track, parent, false)
return ViewHolder(view, context).also {
view.setOnClickListener(it)
}
}
@SuppressLint("NewApi")
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val track = data[position]
Picasso.get()
.load(normalizeUrl(track.track.album.cover.original))
.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
Build.VERSION_CODES.P.onApi(
{
holder.title.setTypeface(holder.title.typeface, Typeface.DEFAULT.weight)
holder.artist.setTypeface(holder.artist.typeface, Typeface.DEFAULT.weight)
},
{
holder.title.setTypeface(holder.title.typeface, Typeface.NORMAL)
holder.artist.setTypeface(holder.artist.typeface, Typeface.NORMAL)
})
if (track.track == currentTrack || track.track.current) {
holder.title.setTypeface(holder.title.typeface, Typeface.BOLD)
holder.artist.setTypeface(holder.artist.typeface, Typeface.BOLD)
}
holder.actions.setOnClickListener {
context?.let { context ->
PopupMenu(context, holder.actions, Gravity.START, R.attr.actionOverflowMenuStyle, 0).apply {
inflate(if (fromQueue) R.menu.row_queue else R.menu.row_track)
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))
}
true
}
show()
}
}
}
if (fromQueue) {
holder.handle.visibility = View.VISIBLE
holder.handle.setOnTouchListener { _, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
touchHelper.startDrag(holder)
}
true
}
}
}
fun onItemMove(oldPosition: Int, newPosition: Int) {
if (oldPosition < newPosition) {
for (i in oldPosition.rangeTo(newPosition - 1)) {
Collections.swap(data, i, i + 1)
}
} else {
for (i in newPosition.downTo(oldPosition)) {
Collections.swap(data, i, i - 1)
}
}
notifyItemMoved(oldPosition, newPosition)
CommandBus.send(Command.MoveFromQueue(oldPosition, newPosition))
}
inner class ViewHolder(view: View, val context: Context?) : RecyclerView.ViewHolder(view), View.OnClickListener {
val handle = view.handle
val cover = view.cover
val title = view.title
val artist = view.artist
val actions = view.actions
override fun onClick(view: View?) {
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 }))
context.toast("All tracks were added to your queue")
}
}
}
}
}
inner class TouchHelperCallback : ItemTouchHelper.Callback() {
override fun isLongPressDragEnabled() = false
override fun isItemViewSwipeEnabled() = false
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) =
makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0)
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
onItemMove(viewHolder.adapterPosition, target.adapterPosition)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {}
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
viewHolder?.itemView?.background = ColorDrawable(Color.argb(255, 100, 100, 100))
}
super.onSelectedChanged(viewHolder, actionState)
}
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
viewHolder.itemView.background = ColorDrawable(Color.TRANSPARENT)
super.clearView(recyclerView, viewHolder)
}
}
}

65
app/src/main/java/com/github/apognu/otter/adapters/PlaylistsAdapter.kt

@ -0,0 +1,65 @@
package com.github.apognu.otter.adapters
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.FunkwhaleAdapter
import com.github.apognu.otter.utils.Playlist
import com.squareup.picasso.Picasso
import kotlinx.android.synthetic.main.row_playlist.view.*
class PlaylistsAdapter(val context: Context?, val listener: OnPlaylistClickListener) : FunkwhaleAdapter<Playlist, PlaylistsAdapter.ViewHolder>() {
interface OnPlaylistClickListener {
fun onClick(holder: View?, playlist: Playlist)
}
override fun getItemCount() = data.size
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_playlist, parent, false)
return ViewHolder(view, listener).also {
view.setOnClickListener(it)
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val playlist = data[position]
holder.name.text = playlist.name
holder.summary.text = "${playlist.tracks_count} tracks • ${playlist.duration} seconds"
playlist.album_covers.shuffled().take(4).forEachIndexed { index, url ->
val imageView = when (index) {
0 -> holder.cover_top_left
1 -> holder.cover_top_right
2 -> holder.cover_bottom_left
3 -> holder.cover_bottom_right
else -> holder.cover_top_left
}
Picasso.get()
.load(url)
.into(imageView)
}
}
inner class ViewHolder(view: View, val listener: OnPlaylistClickListener) : RecyclerView.ViewHolder(view), View.OnClickListener {
val name = view.name
val summary = view.summary
val cover_top_left = view.cover_top_left
val cover_top_right = view.cover_top_right
val cover_bottom_left = view.cover_bottom_left
val cover_bottom_right = view.cover_bottom_right
override fun onClick(view: View?) {
listener.onClick(view, data[layoutPosition])
}
}