Initial commit.

This commit is contained in:
Antoine POPINEAU 2019-08-19 16:50:33 +02:00 committed by Antoine POPINEAU
commit 5f495f54e5
No known key found for this signature in database
GPG Key ID: A78AC64694F84063
129 changed files with 6734 additions and 0 deletions

8
.gitignore vendored Normal file
View File

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

21
LICENSE Normal file
View File

@ -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 vendored Normal file
View File

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

61
app/build.gradle Normal file
View File

@ -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 vendored Normal file
View File

@ -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

View File

@ -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>

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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
}
)
}
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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
})
}
}

View File

@ -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)
}
}
}
}

View File

@ -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])
}
}
}

View File

@ -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])
}
}
}

View File

@ -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])
}
}
}

View File

@ -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 -> ""
}
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}