Initial commit.
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
21
LICENSE
Normal 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
@ -0,0 +1,2 @@
|
||||
/build
|
||||
/release
|
61
app/build.gradle
Normal 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
@ -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
Normal 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>
|
17
app/src/main/java/com/github/apognu/otter/Otter.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
||||
})
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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])
|
||||
}
|
||||
}
|
||||
}
|
@ -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])
|
||||
}
|
||||
}
|
||||
}
|
@ -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])
|
||||
}
|
||||
}
|
||||
}
|
@ -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 -> ""
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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])
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
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.utils.Track
|
||||
import kotlinx.android.synthetic.main.row_track.view.*
|
||||
|
||||
class SearchResultsAdapter(val context: Context?) : RecyclerView.Adapter<SearchResultsAdapter.ViewHolder>() {
|
||||
var tracks: List<Track> = listOf()
|
||||
|
||||
override fun getItemCount() = tracks.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = LayoutInflater.from(context).inflate(R.layout.row_track, parent, false)
|
||||
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val artist = tracks[position]
|
||||
|
||||
holder.title.text = artist.title
|
||||
}
|
||||
|
||||
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val title = view.title
|
||||
}
|
||||
}
|
@ -0,0 +1,206 @@
|
||||
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 TracksAdapter(private val context: Context?, val favoriteListener: OnFavoriteListener? = null, val fromQueue: Boolean = false) : FunkwhaleAdapter<Track, TracksAdapter.ViewHolder>() {
|
||||
interface OnFavoriteListener {
|
||||
fun onToggleFavorite(id: Int, state: Boolean)
|
||||
}
|
||||
|
||||
private lateinit var touchHelper: ItemTouchHelper
|
||||
|
||||
var currentTrack: Track? = null
|
||||
|
||||
override fun getItemCount() = data.size
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return data[position].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.album.cover.original))
|
||||
.fit()
|
||||
.placeholder(R.drawable.cover)
|
||||
.transform(RoundedCornersTransformation(16, 0))
|
||||
.into(holder.cover)
|
||||
|
||||
holder.title.text = track.title
|
||||
holder.artist.text = 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 == currentTrack || track.current) {
|
||||
holder.title.setTypeface(holder.title.typeface, Typeface.BOLD)
|
||||
holder.artist.setTypeface(holder.artist.typeface, Typeface.BOLD)
|
||||
}
|
||||
|
||||
context?.let {
|
||||
when (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?.let {
|
||||
favoriteListener.onToggleFavorite(track.id, !track.favorite)
|
||||
|
||||
track.favorite = !track.favorite
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)))
|
||||
R.id.track_play_next -> CommandBus.send(Command.PlayNext(track))
|
||||
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(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 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))
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,93 @@
|
||||
package com.github.apognu.otter.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.transition.Fade
|
||||
import androidx.transition.Slide
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.activities.MainActivity
|
||||
import com.github.apognu.otter.adapters.AlbumsAdapter
|
||||
import com.github.apognu.otter.repositories.AlbumsRepository
|
||||
import com.github.apognu.otter.utils.Album
|
||||
import com.github.apognu.otter.utils.AppContext
|
||||
import com.github.apognu.otter.utils.Artist
|
||||
import com.squareup.picasso.Picasso
|
||||
import kotlinx.android.synthetic.main.fragment_albums.*
|
||||
|
||||
class AlbumsFragment : FunkwhaleFragment<Album, AlbumsAdapter>() {
|
||||
override val viewRes = R.layout.fragment_albums
|
||||
override val recycler: RecyclerView get() = albums
|
||||
|
||||
var artistId = 0
|
||||
var artistName = ""
|
||||
var artistArt = ""
|
||||
|
||||
companion object {
|
||||
fun new(artist: Artist): AlbumsFragment {
|
||||
return AlbumsFragment().apply {
|
||||
arguments = bundleOf(
|
||||
"artistId" to artist.id,
|
||||
"artistName" to artist.name,
|
||||
"artistArt" to artist.albums!![0].cover.original
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
arguments?.apply {
|
||||
artistId = getInt("artistId")
|
||||
artistName = getString("artistName") ?: ""
|
||||
artistArt = getString("artistArt") ?: ""
|
||||
}
|
||||
|
||||
adapter = AlbumsAdapter(context, OnAlbumClickListener())
|
||||
repository = AlbumsRepository(context, artistId)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
Picasso.get()
|
||||
.load(artistArt)
|
||||
.noFade()
|
||||
.fit()
|
||||
.centerCrop()
|
||||
.into(cover)
|
||||
|
||||
artist.text = artistName
|
||||
}
|
||||
|
||||
inner class OnAlbumClickListener : AlbumsAdapter.OnAlbumClickListener {
|
||||
override fun onClick(holder: View?, album: Album) {
|
||||
(context as? MainActivity)?.let { activity ->
|
||||
exitTransition = Fade().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
|
||||
view?.let {
|
||||
addTarget(it)
|
||||
}
|
||||
}
|
||||
|
||||
val fragment = TracksFragment.new(album).apply {
|
||||
enterTransition = Slide().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
}
|
||||
}
|
||||
|
||||
activity.supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.container, fragment)
|
||||
.addToBackStack(null)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,60 @@
|
||||
package com.github.apognu.otter.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.transition.Fade
|
||||
import androidx.transition.Slide
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.activities.MainActivity
|
||||
import com.github.apognu.otter.adapters.AlbumsGridAdapter
|
||||
import com.github.apognu.otter.repositories.AlbumsRepository
|
||||
import com.github.apognu.otter.utils.Album
|
||||
import com.github.apognu.otter.utils.AppContext
|
||||
import com.github.apognu.otter.utils.onViewPager
|
||||
import kotlinx.android.synthetic.main.fragment_albums_grid.*
|
||||
|
||||
class AlbumsGridFragment : FunkwhaleFragment<Album, AlbumsGridAdapter>() {
|
||||
override val viewRes = R.layout.fragment_albums_grid
|
||||
override val recycler: RecyclerView get() = albums
|
||||
override val layoutManager get() = GridLayoutManager(context, 3)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
adapter = AlbumsGridAdapter(context, OnAlbumClickListener())
|
||||
repository = AlbumsRepository(context)
|
||||
}
|
||||
|
||||
inner class OnAlbumClickListener : AlbumsGridAdapter.OnAlbumClickListener {
|
||||
override fun onClick(holder: View?, album: Album) {
|
||||
(context as? MainActivity)?.let { activity ->
|
||||
onViewPager {
|
||||
exitTransition = Fade().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
|
||||
view?.let {
|
||||
addTarget(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val fragment = TracksFragment.new(album).apply {
|
||||
enterTransition = Slide().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
}
|
||||
}
|
||||
|
||||
activity.supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.container, fragment)
|
||||
.addToBackStack(null)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
package com.github.apognu.otter.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.transition.Fade
|
||||
import androidx.transition.Slide
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.activities.MainActivity
|
||||
import com.github.apognu.otter.adapters.ArtistsAdapter
|
||||
import com.github.apognu.otter.repositories.ArtistsRepository
|
||||
import com.github.apognu.otter.utils.AppContext
|
||||
import com.github.apognu.otter.utils.Artist
|
||||
import com.github.apognu.otter.utils.onViewPager
|
||||
import kotlinx.android.synthetic.main.fragment_artists.*
|
||||
|
||||
class ArtistsFragment : FunkwhaleFragment<Artist, ArtistsAdapter>() {
|
||||
override val viewRes = R.layout.fragment_artists
|
||||
override val recycler: RecyclerView get() = artists
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
adapter = ArtistsAdapter(context, OnArtistClickListener())
|
||||
repository = ArtistsRepository(context)
|
||||
}
|
||||
|
||||
inner class OnArtistClickListener : ArtistsAdapter.OnArtistClickListener {
|
||||
override fun onClick(holder: View?, artist: Artist) {
|
||||
(context as? MainActivity)?.let { activity ->
|
||||
onViewPager {
|
||||
exitTransition = Fade().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
|
||||
view?.let {
|
||||
addTarget(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val fragment = AlbumsFragment.new(artist).apply {
|
||||
enterTransition = Slide().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
}
|
||||
}
|
||||
|
||||
activity.supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.container, fragment)
|
||||
.addToBackStack(null)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
package com.github.apognu.otter.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.adapters.BrowseTabsAdapter
|
||||
import kotlinx.android.synthetic.main.fragment_browse.view.*
|
||||
|
||||
class BrowseFragment : Fragment() {
|
||||
var adapter: BrowseTabsAdapter? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
adapter = BrowseTabsAdapter(this, childFragmentManager)
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.fragment_browse, container, false).apply {
|
||||
tabs.setupWithViewPager(pager)
|
||||
tabs.getTabAt(0)?.select()
|
||||
|
||||
pager.adapter = adapter
|
||||
pager.offscreenPageLimit = 4
|
||||
}
|
||||
}
|
||||
|
||||
fun selectTabAt(position: Int) {
|
||||
view?.tabs?.getTabAt(position)?.select()
|
||||
}
|
||||
}
|
@ -0,0 +1,71 @@
|
||||
package com.github.apognu.otter.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.adapters.FavoritesAdapter
|
||||
import com.github.apognu.otter.repositories.FavoritesRepository
|
||||
import com.github.apognu.otter.utils.*
|
||||
import kotlinx.android.synthetic.main.fragment_favorites.*
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class FavoritesFragment : FunkwhaleFragment<Favorite, FavoritesAdapter>() {
|
||||
override val viewRes = R.layout.fragment_favorites
|
||||
override val recycler: RecyclerView get() = favorites
|
||||
|
||||
lateinit var favoritesRepository: FavoritesRepository
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
adapter = FavoritesAdapter(context, FavoriteListener())
|
||||
repository = FavoritesRepository(context)
|
||||
favoritesRepository = FavoritesRepository(context)
|
||||
|
||||
watchEventBus()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
GlobalScope.launch(Main) {
|
||||
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
|
||||
adapter.currentTrack = response.track
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
play.setOnClickListener {
|
||||
CommandBus.send(Command.ReplaceQueue(adapter.data.shuffled().map { it.track }))
|
||||
}
|
||||
}
|
||||
|
||||
private fun watchEventBus() {
|
||||
GlobalScope.launch(Main) {
|
||||
for (message in EventBus.asChannel<Event>()) {
|
||||
when (message) {
|
||||
is Event.TrackPlayed -> {
|
||||
GlobalScope.launch(Main) {
|
||||
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
|
||||
adapter.currentTrack = response.track
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class FavoriteListener : FavoritesAdapter.OnFavoriteListener {
|
||||
override fun onToggleFavorite(id: Int, state: Boolean) {
|
||||
when (state) {
|
||||
true -> favoritesRepository.addFavorite(id)
|
||||
false -> favoritesRepository.deleteFavorite(id)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@ -0,0 +1,80 @@
|
||||
package com.github.apognu.otter.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.widget.NestedScrollView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.apognu.otter.repositories.Repository
|
||||
import com.github.apognu.otter.utils.untilNetwork
|
||||
import kotlinx.android.synthetic.main.fragment_artists.*
|
||||
|
||||
abstract class FunkwhaleAdapter<D, VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() {
|
||||
var data: MutableList<D> = mutableListOf()
|
||||
}
|
||||
|
||||
abstract class FunkwhaleFragment<D : Any, A : FunkwhaleAdapter<D, *>> : Fragment() {
|
||||
abstract val viewRes: Int
|
||||
abstract val recycler: RecyclerView
|
||||
open val layoutManager: RecyclerView.LayoutManager get() = LinearLayoutManager(context)
|
||||
|
||||
lateinit var repository: Repository<D, *>
|
||||
lateinit var adapter: A
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(viewRes, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
recycler.layoutManager = layoutManager
|
||||
recycler.adapter = adapter
|
||||
|
||||
scroller?.setOnScrollChangeListener { _: NestedScrollView?, _: Int, _: Int, _: Int, _: Int ->
|
||||
if (!scroller.canScrollVertically(1)) {
|
||||
repository.fetch(Repository.Origin.Network.origin, adapter.data).untilNetwork {
|
||||
swiper?.isRefreshing = false
|
||||
|
||||
onDataFetched(it)
|
||||
|
||||
adapter.data = it.toMutableList()
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
swiper?.isRefreshing = true
|
||||
|
||||
repository.fetch().untilNetwork {
|
||||
swiper?.isRefreshing = false
|
||||
|
||||
onDataFetched(it)
|
||||
|
||||
adapter.data = it.toMutableList()
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
recycler.adapter = adapter
|
||||
|
||||
swiper?.setOnRefreshListener {
|
||||
repository.fetch(Repository.Origin.Network.origin, listOf()).untilNetwork {
|
||||
swiper?.isRefreshing = false
|
||||
|
||||
onDataFetched(it)
|
||||
|
||||
adapter.data = it.toMutableList()
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun onDataFetched(data: List<D>) {}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
package com.github.apognu.otter.fragments
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.github.apognu.otter.R
|
||||
|
||||
class LoginDialog : DialogFragment() {
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
return AlertDialog.Builder(context).apply {
|
||||
setTitle(getString(R.string.login_logging_in))
|
||||
setView(R.layout.dialog_login)
|
||||
}.create()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
dialog?.setCanceledOnTouchOutside(false)
|
||||
dialog?.setCancelable(false)
|
||||
}
|
||||
}
|
@ -0,0 +1,120 @@
|
||||
package com.github.apognu.otter.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.adapters.PlaylistTracksAdapter
|
||||
import com.github.apognu.otter.repositories.PlaylistTracksRepository
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.squareup.picasso.Picasso
|
||||
import kotlinx.android.synthetic.main.fragment_tracks.*
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class PlaylistTracksFragment : FunkwhaleFragment<PlaylistTrack, PlaylistTracksAdapter>() {
|
||||
override val viewRes = R.layout.fragment_tracks
|
||||
override val recycler: RecyclerView get() = tracks
|
||||
|
||||
var albumId = 0
|
||||
var albumArtist = ""
|
||||
var albumTitle = ""
|
||||
var albumCover = ""
|
||||
|
||||
companion object {
|
||||
fun new(playlist: Playlist): PlaylistTracksFragment {
|
||||
return PlaylistTracksFragment().apply {
|
||||
arguments = bundleOf(
|
||||
"albumId" to playlist.id,
|
||||
"albumArtist" to "N/A",
|
||||
"albumTitle" to playlist.name,
|
||||
"albumCover" to ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
arguments?.apply {
|
||||
albumId = getInt("albumId")
|
||||
albumArtist = getString("albumArtist") ?: ""
|
||||
albumTitle = getString("albumTitle") ?: ""
|
||||
albumCover = getString("albumCover") ?: ""
|
||||
}
|
||||
|
||||
adapter = PlaylistTracksAdapter(context)
|
||||
repository = PlaylistTracksRepository(context, albumId)
|
||||
|
||||
watchEventBus()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
cover.visibility = View.INVISIBLE
|
||||
covers.visibility = View.VISIBLE
|
||||
|
||||
artist.text = "Playlist"
|
||||
title.text = albumTitle
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
GlobalScope.launch(Main) {
|
||||
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
|
||||
adapter.currentTrack = response.track
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
play.setOnClickListener {
|
||||
CommandBus.send(Command.ReplaceQueue(adapter.data.map { it.track }.shuffled()))
|
||||
|
||||
context.toast("All tracks were added to your queue")
|
||||
}
|
||||
|
||||
queue.setOnClickListener {
|
||||
CommandBus.send(Command.AddToQueue(adapter.data.map { it.track }))
|
||||
|
||||
context.toast("All tracks were added to your queue")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDataFetched(data: List<PlaylistTrack>) {
|
||||
data.map { it.track.album }.toSet().map { it.cover.original }.take(4).forEachIndexed { index, url ->
|
||||
val imageView = when (index) {
|
||||
0 -> cover_top_left
|
||||
1 -> cover_top_right
|
||||
2 -> cover_bottom_left
|
||||
3 -> cover_bottom_right
|
||||
else -> cover_top_left
|
||||
}
|
||||
|
||||
Picasso.get()
|
||||
.load(normalizeUrl(url))
|
||||
.into(imageView)
|
||||
}
|
||||
}
|
||||
|
||||
private fun watchEventBus() {
|
||||
GlobalScope.launch(Main) {
|
||||
for (message in EventBus.asChannel<Event>()) {
|
||||
when (message) {
|
||||
is Event.TrackPlayed -> {
|
||||
GlobalScope.launch(Main) {
|
||||
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
|
||||
adapter.currentTrack = response.track
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
package com.github.apognu.otter.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.transition.Fade
|
||||
import androidx.transition.Slide
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.activities.MainActivity
|
||||
import com.github.apognu.otter.adapters.PlaylistsAdapter
|
||||
import com.github.apognu.otter.repositories.PlaylistsRepository
|
||||
import com.github.apognu.otter.utils.AppContext
|
||||
import com.github.apognu.otter.utils.Playlist
|
||||
import kotlinx.android.synthetic.main.fragment_playlists.*
|
||||
|
||||
class PlaylistsFragment : FunkwhaleFragment<Playlist, PlaylistsAdapter>() {
|
||||
override val viewRes = R.layout.fragment_playlists
|
||||
override val recycler: RecyclerView get() = playlists
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
adapter = PlaylistsAdapter(context, OnPlaylistClickListener())
|
||||
repository = PlaylistsRepository(context)
|
||||
}
|
||||
|
||||
inner class OnPlaylistClickListener : PlaylistsAdapter.OnPlaylistClickListener {
|
||||
override fun onClick(holder: View?, playlist: Playlist) {
|
||||
(context as? MainActivity)?.let { activity ->
|
||||
exitTransition = Fade().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
|
||||
view?.let {
|
||||
addTarget(it)
|
||||
}
|
||||
}
|
||||
|
||||
val fragment = PlaylistTracksFragment.new(playlist).apply {
|
||||
enterTransition = Slide().apply {
|
||||
duration = AppContext.TRANSITION_DURATION
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
}
|
||||
}
|
||||
|
||||
activity.supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(R.id.container, fragment)
|
||||
.addToBackStack(null)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,89 @@
|
||||
package com.github.apognu.otter.fragments
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.adapters.TracksAdapter
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import kotlinx.android.synthetic.main.fragment_queue.*
|
||||
import kotlinx.android.synthetic.main.fragment_queue.view.*
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class QueueFragment : BottomSheetDialogFragment() {
|
||||
private var adapter: TracksAdapter? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setStyle(DialogFragment.STYLE_NORMAL, R.style.AppTheme_FloatingBottomSheet)
|
||||
|
||||
watchEventBus()
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
return super.onCreateDialog(savedInstanceState).apply {
|
||||
setOnShowListener {
|
||||
findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)?.let {
|
||||
BottomSheetBehavior.from(it).skipCollapsed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.fragment_queue, container, false).apply {
|
||||
adapter = TracksAdapter(context, fromQueue = true).also {
|
||||
queue.layoutManager = LinearLayoutManager(context)
|
||||
queue.adapter = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
queue?.visibility = View.GONE
|
||||
placeholder?.visibility = View.VISIBLE
|
||||
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
GlobalScope.launch(Main) {
|
||||
RequestBus.send(Request.GetQueue).wait<Response.Queue>()?.let { response ->
|
||||
adapter?.let {
|
||||
it.data = response.queue.toMutableList()
|
||||
it.notifyDataSetChanged()
|
||||
|
||||
if (it.data.isEmpty()) {
|
||||
queue?.visibility = View.GONE
|
||||
placeholder?.visibility = View.VISIBLE
|
||||
} else {
|
||||
queue?.visibility = View.VISIBLE
|
||||
placeholder?.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun watchEventBus() {
|
||||
GlobalScope.launch(Main) {
|
||||
for (message in EventBus.asChannel<Event>()) {
|
||||
when (message) {
|
||||
is Event.TrackPlayed -> refresh()
|
||||
is Event.QueueChanged -> refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,122 @@
|
||||
package com.github.apognu.otter.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.adapters.TracksAdapter
|
||||
import com.github.apognu.otter.repositories.FavoritesRepository
|
||||
import com.github.apognu.otter.repositories.TracksRepository
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.squareup.picasso.Picasso
|
||||
import kotlinx.android.synthetic.main.fragment_tracks.*
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class TracksFragment : FunkwhaleFragment<Track, TracksAdapter>() {
|
||||
override val viewRes = R.layout.fragment_tracks
|
||||
override val recycler: RecyclerView get() = tracks
|
||||
|
||||
lateinit var favoritesRepository: FavoritesRepository
|
||||
|
||||
var albumId = 0
|
||||
var albumArtist = ""
|
||||
var albumTitle = ""
|
||||
var albumCover = ""
|
||||
|
||||
companion object {
|
||||
fun new(album: Album): TracksFragment {
|
||||
return TracksFragment().apply {
|
||||
arguments = bundleOf(
|
||||
"albumId" to album.id,
|
||||
"albumArtist" to album.artist.name,
|
||||
"albumTitle" to album.title,
|
||||
"albumCover" to album.cover.original
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
arguments?.apply {
|
||||
albumId = getInt("albumId")
|
||||
albumArtist = getString("albumArtist") ?: ""
|
||||
albumTitle = getString("albumTitle") ?: ""
|
||||
albumCover = getString("albumCover") ?: ""
|
||||
}
|
||||
|
||||
adapter = TracksAdapter(context, FavoriteListener())
|
||||
repository = TracksRepository(context, albumId)
|
||||
favoritesRepository = FavoritesRepository(context)
|
||||
|
||||
watchEventBus()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
Picasso.get()
|
||||
.load(albumCover)
|
||||
.noFade()
|
||||
.fit()
|
||||
.centerCrop()
|
||||
.into(cover)
|
||||
|
||||
artist.text = albumArtist
|
||||
title.text = albumTitle
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
GlobalScope.launch(Main) {
|
||||
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
|
||||
adapter.currentTrack = response.track
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
play.setOnClickListener {
|
||||
CommandBus.send(Command.ReplaceQueue(adapter.data.shuffled()))
|
||||
|
||||
context.toast("All tracks were added to your queue")
|
||||
}
|
||||
|
||||
queue.setOnClickListener {
|
||||
CommandBus.send(Command.AddToQueue(adapter.data))
|
||||
|
||||
context.toast("All tracks were added to your queue")
|
||||
}
|
||||
}
|
||||
|
||||
private fun watchEventBus() {
|
||||
GlobalScope.launch(Main) {
|
||||
for (message in EventBus.asChannel<Event>()) {
|
||||
when (message) {
|
||||
is Event.TrackPlayed -> {
|
||||
GlobalScope.launch(Main) {
|
||||
RequestBus.send(Request.GetCurrentTrack).wait<Response.CurrentTrack>()?.let { response ->
|
||||
adapter.currentTrack = response.track
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class FavoriteListener : TracksAdapter.OnFavoriteListener {
|
||||
override fun onToggleFavorite(id: Int, state: Boolean) {
|
||||
when (state) {
|
||||
true -> favoritesRepository.addFavorite(id)
|
||||
false -> favoritesRepository.deleteFavorite(id)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@ -0,0 +1,125 @@
|
||||
package com.github.apognu.otter.playback
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.MediaMetadata
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.media.app.NotificationCompat.MediaStyle
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.activities.MainActivity
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.squareup.picasso.Picasso
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MediaControlsManager(val context: Service, val mediaSession: MediaSessionCompat) {
|
||||
companion object {
|
||||
const val NOTIFICATION_ACTION_OPEN_QUEUE = 0
|
||||
const val NOTIFICATION_ACTION_PREVIOUS = 1
|
||||
const val NOTIFICATION_ACTION_TOGGLE = 2
|
||||
const val NOTIFICATION_ACTION_NEXT = 3
|
||||
const val NOTIFICATION_ACTION_FAVORITE = 4
|
||||
}
|
||||
|
||||
var notification: Notification? = null
|
||||
|
||||
fun updateNotification(track: Track?, playing: Boolean) {
|
||||
if (notification == null && !playing) return
|
||||
|
||||
track?.let {
|
||||
val stateIcon = when (playing) {
|
||||
true -> R.drawable.pause
|
||||
false -> R.drawable.play
|
||||
}
|
||||
|
||||
GlobalScope.launch(IO) {
|
||||
val openIntent = Intent(context, MainActivity::class.java).apply { action = NOTIFICATION_ACTION_OPEN_QUEUE.toString() }
|
||||
val openPendingIntent = PendingIntent.getActivity(context, 0, openIntent, 0)
|
||||
|
||||
mediaSession.setMetadata(MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadata.METADATA_KEY_ARTIST, track.artist.name)
|
||||
putString(MediaMetadata.METADATA_KEY_TITLE, track.title)
|
||||
}.build())
|
||||
|
||||
notification = NotificationCompat.Builder(
|
||||
context,
|
||||
AppContext.NOTIFICATION_CHANNEL_MEDIA_CONTROL
|
||||
)
|
||||
.setShowWhen(false)
|
||||
.setStyle(
|
||||
MediaStyle()
|
||||
.setMediaSession(mediaSession.sessionToken)
|
||||
.setShowActionsInCompactView(0, 1, 2)
|
||||
)
|
||||
.setSmallIcon(R.drawable.ottericon)
|
||||
.setLargeIcon(Picasso.get().load(normalizeUrl(track.album.cover.original)).get())
|
||||
.setContentTitle(track.title)
|
||||
.setContentText(track.artist.name)
|
||||
.setContentIntent(openPendingIntent)
|
||||
.setChannelId(AppContext.NOTIFICATION_CHANNEL_MEDIA_CONTROL)
|
||||
.addAction(
|
||||
action(
|
||||
R.drawable.previous, context.getString(R.string.control_previous),
|
||||
NOTIFICATION_ACTION_PREVIOUS
|
||||
)
|
||||
)
|
||||
.addAction(
|
||||
action(
|
||||
stateIcon, context.getString(R.string.control_toggle),
|
||||
NOTIFICATION_ACTION_TOGGLE
|
||||
)
|
||||
)
|
||||
.addAction(
|
||||
action(
|
||||
R.drawable.next, context.getString(R.string.control_next),
|
||||
NOTIFICATION_ACTION_NEXT
|
||||
)
|
||||
)
|
||||
.build()
|
||||
|
||||
notification?.let {
|
||||
NotificationManagerCompat.from(context).notify(AppContext.NOTIFICATION_MEDIA_CONTROL, it)
|
||||
}
|
||||
|
||||
if (playing) tick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun tick() {
|
||||
notification?.let {
|
||||
context.startForeground(AppContext.NOTIFICATION_MEDIA_CONTROL, it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun action(icon: Int, title: String, id: Int): NotificationCompat.Action {
|
||||
val intent = Intent(context, MediaControlActionReceiver::class.java).apply { action = id.toString() }
|
||||
val pendingIntent = PendingIntent.getBroadcast(context, id, intent, 0)
|
||||
|
||||
return NotificationCompat.Action.Builder(icon, title, pendingIntent).build()
|
||||
}
|
||||
}
|
||||
|
||||
class MediaControlActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
MediaControlsManager.NOTIFICATION_ACTION_PREVIOUS.toString() -> CommandBus.send(
|
||||
Command.PreviousTrack
|
||||
)
|
||||
MediaControlsManager.NOTIFICATION_ACTION_TOGGLE.toString() -> CommandBus.send(
|
||||
Command.ToggleState
|
||||
)
|
||||
MediaControlsManager.NOTIFICATION_ACTION_NEXT.toString() -> CommandBus.send(
|
||||
Command.NextTrack
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,442 @@
|
||||
package com.github.apognu.otter.playback
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.view.KeyEvent
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||
import com.google.android.exoplayer2.source.TrackGroupArray
|
||||
import com.google.android.exoplayer2.trackselection.TrackSelectionArray
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class PlayerService : Service() {
|
||||
private lateinit var queue: QueueManager
|
||||
private val jobs = mutableListOf<Job>()
|
||||
|
||||
private lateinit var audioManager: AudioManager
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
private val audioFocusChangeListener = AudioFocusChange()
|
||||
private var stateWhenLostFocus = false
|
||||
|
||||
private lateinit var mediaControlsManager: MediaControlsManager
|
||||
private lateinit var mediaSession: MediaSessionCompat
|
||||
private lateinit var player: SimpleExoPlayer
|
||||
|
||||
private lateinit var playerEventListener: PlayerEventListener
|
||||
private val headphonesUnpluggedReceiver = HeadphonesUnpluggedReceiver()
|
||||
|
||||
private var progressCache = Triple(0, 0, 0)
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
watchEventBus()
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
queue = QueueManager(this)
|
||||
|
||||
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN).run {
|
||||
setAudioAttributes(AudioAttributes.Builder().run {
|
||||
setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
|
||||
setAcceptsDelayedFocusGain(true)
|
||||
setOnAudioFocusChangeListener(audioFocusChangeListener)
|
||||
|
||||
build()
|
||||
})
|
||||
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
mediaSession = MediaSessionCompat(this, applicationContext.packageName).apply {
|
||||
isActive = true
|
||||
}
|
||||
|
||||
mediaControlsManager = MediaControlsManager(this, mediaSession)
|
||||
|
||||
player = ExoPlayerFactory.newSimpleInstance(this).apply {
|
||||
playWhenReady = false
|
||||
|
||||
playerEventListener = PlayerEventListener().also {
|
||||
addListener(it)
|
||||
}
|
||||
|
||||
MediaSessionConnector(mediaSession).also {
|
||||
it.setPlayer(this)
|
||||
it.setMediaButtonEventHandler { player, _, mediaButtonEvent ->
|
||||
mediaButtonEvent?.extras?.getParcelable<KeyEvent>(Intent.EXTRA_KEY_EVENT)?.let { key ->
|
||||
if (key.action == KeyEvent.ACTION_UP) {
|
||||
when (key.keyCode) {
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY -> state(true)
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> state(false)
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT -> player?.next()
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> previousTrack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (queue.current > -1) {
|
||||
player.prepare(queue.datasources, true, true)
|
||||
player.seekTo(queue.current, 0)
|
||||
}
|
||||
|
||||
registerReceiver(headphonesUnpluggedReceiver, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY))
|
||||
}
|
||||
|
||||
private fun watchEventBus() {
|
||||
jobs.add(GlobalScope.launch(Main) {
|
||||
for (message in CommandBus.asChannel()) {
|
||||
when (message) {
|
||||
is Command.RefreshService -> {
|
||||
EventBus.send(Event.QueueChanged)
|
||||
|
||||
if (queue.metadata.isNotEmpty()) {
|
||||
EventBus.send(
|
||||
Event.TrackPlayed(
|
||||
queue.current(),
|
||||
player.playWhenReady
|
||||
)
|
||||
)
|
||||
EventBus.send(
|
||||
Event.StateChanged(
|
||||
player.playWhenReady
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is Command.ReplaceQueue -> {
|
||||
queue.replace(message.queue)
|
||||
player.prepare(queue.datasources, true, true)
|
||||
|
||||
state(true)
|
||||
|
||||
EventBus.send(
|
||||
Event.TrackPlayed(
|
||||
queue.current(),
|
||||
true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is Command.AddToQueue -> queue.append(message.tracks)
|
||||
is Command.PlayNext -> queue.insertNext(message.track)
|
||||
is Command.RemoveFromQueue -> queue.remove(message.track)
|
||||
is Command.MoveFromQueue -> queue.move(message.oldPosition, message.newPosition)
|
||||
|
||||
is Command.PlayTrack -> {
|
||||
queue.current = message.index
|
||||
player.seekTo(message.index, C.TIME_UNSET)
|
||||
|
||||
state(true)
|
||||
|
||||
EventBus.send(
|
||||
Event.TrackPlayed(
|
||||
queue.current(),
|
||||
true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is Command.ToggleState -> toggle()
|
||||
is Command.SetState -> state(message.state)
|
||||
|
||||
is Command.NextTrack -> player.next()
|
||||
is Command.PreviousTrack -> previousTrack()
|
||||
is Command.Seek -> progress(message.progress)
|
||||
}
|
||||
|
||||
if (player.playWhenReady) {
|
||||
mediaControlsManager.tick()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
jobs.add(GlobalScope.launch(Main) {
|
||||
for (request in RequestBus.asChannel<Request>()) {
|
||||
when (request) {
|
||||
is Request.GetCurrentTrack -> request.channel?.offer(
|
||||
Response.CurrentTrack(
|
||||
queue.current()
|
||||
)
|
||||
)
|
||||
is Request.GetState -> request.channel?.offer(
|
||||
Response.State(
|
||||
player.playWhenReady
|
||||
)
|
||||
)
|
||||
is Request.GetQueue -> request.channel?.offer(
|
||||
Response.Queue(
|
||||
queue.get()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
jobs.add(GlobalScope.launch(Main) {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
|
||||
val (current, duration, percent) = progress()
|
||||
|
||||
if (player.playWhenReady) {
|
||||
ProgressBus.send(current, duration, percent)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) = null
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
override fun onDestroy() {
|
||||
jobs.forEach { it.cancel() }
|
||||
|
||||
try {
|
||||
unregisterReceiver(headphonesUnpluggedReceiver)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
Build.VERSION_CODES.O.onApi(
|
||||
{
|
||||
audioFocusRequest?.let {
|
||||
audioManager.abandonAudioFocusRequest(it)
|
||||
}
|
||||
},
|
||||
{
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.abandonAudioFocus(audioFocusChangeListener)
|
||||
})
|
||||
|
||||
mediaSession.isActive = false
|
||||
mediaSession.release()
|
||||
|
||||
player.removeListener(playerEventListener)
|
||||
state(false)
|
||||
player.release()
|
||||
|
||||
queue.cache.release()
|
||||
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private fun state(state: Boolean) {
|
||||
if (state && player.playbackState == Player.STATE_IDLE) {
|
||||
player.prepare(queue.datasources)
|
||||
}
|
||||
|
||||
var allowed = !state
|
||||
|
||||
if (!allowed) {
|
||||
Build.VERSION_CODES.O.onApi(
|
||||
{
|
||||
audioFocusRequest?.let {
|
||||
allowed = when (audioManager.requestAudioFocus(it)) {
|
||||
AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.requestAudioFocus(audioFocusChangeListener, AudioAttributes.CONTENT_TYPE_MUSIC, AudioManager.AUDIOFOCUS_GAIN).let {
|
||||
allowed = when (it) {
|
||||
AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (allowed) {
|
||||
player.playWhenReady = state
|
||||
|
||||
EventBus.send(Event.StateChanged(state))
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggle() {
|
||||
state(!player.playWhenReady)
|
||||
}
|
||||
|
||||
private fun previousTrack() {
|
||||
if (player.currentPosition > 5000) {
|
||||
return player.seekTo(0)
|
||||
}
|
||||
|
||||
player.previous()
|
||||
}
|
||||
|
||||
private fun progress(): Triple<Int, Int, Int> {
|
||||
if (!player.playWhenReady) return progressCache
|
||||
|
||||
return queue.current()?.bestUpload()?.let { upload ->
|
||||
val current = player.currentPosition
|
||||
val duration = upload.duration.toFloat()
|
||||
val percent = ((current / (duration * 1000)) * 100).toInt()
|
||||
|
||||
progressCache = Triple(current.toInt(), duration.toInt(), percent)
|
||||
progressCache
|
||||
} ?: Triple(0, 0, 0)
|
||||
}
|
||||
|
||||
private fun progress(value: Int) {
|
||||
val duration = ((queue.current()?.bestUpload()?.duration ?: 0) * (value.toFloat() / 100)) * 1000
|
||||
|
||||
progressCache = Triple(duration.toInt(), queue.current()?.bestUpload()?.duration ?: 0, value)
|
||||
|
||||
player.seekTo(duration.toLong())
|
||||
}
|
||||
|
||||
inner class PlayerEventListener : Player.EventListener {
|
||||
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
|
||||
super.onPlayerStateChanged(playWhenReady, playbackState)
|
||||
|
||||
EventBus.send(
|
||||
Event.StateChanged(
|
||||
playWhenReady
|
||||
)
|
||||
)
|
||||
|
||||
if (queue.current == -1) {
|
||||
EventBus.send(
|
||||
Event.TrackPlayed(
|
||||
queue.current(),
|
||||
playWhenReady
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
when (playWhenReady) {
|
||||
true -> {
|
||||
when (playbackState) {
|
||||
Player.STATE_READY -> mediaControlsManager.updateNotification(queue.current(), true)
|
||||
Player.STATE_BUFFERING -> EventBus.send(
|
||||
Event.Buffering(
|
||||
true
|
||||
)
|
||||
)
|
||||
Player.STATE_IDLE -> state(false)
|
||||
Player.STATE_ENDED -> EventBus.send(Event.PlaybackStopped)
|
||||
}
|
||||
|
||||
if (playbackState != Player.STATE_BUFFERING) EventBus.send(
|
||||
Event.Buffering(
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
false -> {
|
||||
EventBus.send(
|
||||
Event.StateChanged(
|
||||
false
|
||||
)
|
||||
)
|
||||
EventBus.send(
|
||||
Event.Buffering(
|
||||
false
|
||||
)
|
||||
)
|
||||
|
||||
if (playbackState == Player.STATE_READY) {
|
||||
mediaControlsManager.updateNotification(queue.current(), false)
|
||||
stopForeground(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTracksChanged(trackGroups: TrackGroupArray?, trackSelections: TrackSelectionArray?) {
|
||||
super.onTracksChanged(trackGroups, trackSelections)
|
||||
|
||||
queue.current = player.currentWindowIndex
|
||||
mediaControlsManager.updateNotification(queue.current(), player.playWhenReady)
|
||||
|
||||
Cache.set(
|
||||
this@PlayerService,
|
||||
"current",
|
||||
queue.current.toString().toByteArray()
|
||||
)
|
||||
|
||||
EventBus.send(
|
||||
Event.TrackPlayed(
|
||||
queue.current(),
|
||||
true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: ExoPlaybackException?) {
|
||||
EventBus.send(
|
||||
Event.PlaybackError(
|
||||
getString(R.string.error_playback)
|
||||
)
|
||||
)
|
||||
|
||||
player.next()
|
||||
}
|
||||
}
|
||||
|
||||
inner class AudioFocusChange : AudioManager.OnAudioFocusChangeListener {
|
||||
override fun onAudioFocusChange(focus: Int) {
|
||||
when (focus) {
|
||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||
player.volume = 1f
|
||||
|
||||
state(stateWhenLostFocus)
|
||||
stateWhenLostFocus = false
|
||||
}
|
||||
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
stateWhenLostFocus = false
|
||||
state(false)
|
||||
}
|
||||
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||
stateWhenLostFocus = player.playWhenReady
|
||||
state(false)
|
||||
}
|
||||
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||
stateWhenLostFocus = player.playWhenReady
|
||||
player.volume = 0.3f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,158 @@
|
||||
package com.github.apognu.otter.playback
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.apognu.otter.repositories.FavoritesRepository
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.android.exoplayer2.source.ConcatenatingMediaSource
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory
|
||||
import com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory
|
||||
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
|
||||
import com.google.android.exoplayer2.upstream.cache.SimpleCache
|
||||
import com.google.android.exoplayer2.util.Util
|
||||
import com.google.gson.Gson
|
||||
import com.preference.PowerPreference
|
||||
|
||||
class QueueManager(val context: Context) {
|
||||
var cache: SimpleCache
|
||||
var metadata: MutableList<Track> = mutableListOf()
|
||||
val datasources = ConcatenatingMediaSource()
|
||||
var current = -1
|
||||
|
||||
init {
|
||||
PowerPreference.getDefaultFile().getInt("media_cache_size", 1).toLong().also {
|
||||
cache = SimpleCache(
|
||||
context.cacheDir.resolve("media"),
|
||||
LeastRecentlyUsedCacheEvictor(it * 1024 * 1024 * 1024)
|
||||
)
|
||||
}
|
||||
|
||||
Cache.get(context, "queue")?.let { json ->
|
||||
gsonDeserializerOf(QueueCache::class.java).deserialize(json)?.let { cache ->
|
||||
metadata = cache.data.toMutableList()
|
||||
|
||||
val factory = factory()
|
||||
|
||||
datasources.addMediaSources(metadata.map { track ->
|
||||
val url = normalizeUrl(track.bestUpload()?.listen_url ?: "")
|
||||
|
||||
ProgressiveMediaSource.Factory(factory).setTag(track.title).createMediaSource(Uri.parse(url))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Cache.get(context, "current")?.let { string ->
|
||||
current = string.readLine().toInt()
|
||||
}
|
||||
}
|
||||
|
||||
private fun persist() {
|
||||
Cache.set(
|
||||
context,
|
||||
"queue",
|
||||
Gson().toJson(QueueCache(metadata)).toByteArray()
|
||||
)
|
||||
}
|
||||
|
||||
private fun factory(): CacheDataSourceFactory {
|
||||
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
|
||||
|
||||
val http = DefaultHttpDataSourceFactory(Util.getUserAgent(context, context.getString(R.string.app_name))).apply {
|
||||
defaultRequestProperties.apply {
|
||||
set("Authorization", "Bearer $token")
|
||||
}
|
||||
}
|
||||
|
||||
return CacheDataSourceFactory(cache, http)
|
||||
}
|
||||
|
||||
fun replace(tracks: List<Track>) {
|
||||
val factory = factory()
|
||||
|
||||
val sources = tracks.map { track ->
|
||||
val url = normalizeUrl(track.bestUpload()?.listen_url ?: "")
|
||||
|
||||
ProgressiveMediaSource.Factory(factory).setTag(track.title).createMediaSource(Uri.parse(url))
|
||||
}
|
||||
|
||||
metadata = tracks.toMutableList()
|
||||
datasources.clear()
|
||||
datasources.addMediaSources(sources)
|
||||
|
||||
persist()
|
||||
|
||||
EventBus.send(Event.QueueChanged)
|
||||
}
|
||||
|
||||
fun append(tracks: List<Track>) {
|
||||
val factory = factory()
|
||||
val tracks = tracks.filter { metadata.indexOf(it) == -1 }
|
||||
|
||||
val sources = tracks.map { track ->
|
||||
val url = normalizeUrl(track.bestUpload()?.listen_url ?: "")
|
||||
|
||||
ProgressiveMediaSource.Factory(factory).createMediaSource(Uri.parse(url))
|
||||
}
|
||||
|
||||
metadata.addAll(tracks)
|
||||
datasources.addMediaSources(sources)
|
||||
|
||||
persist()
|
||||
|
||||
EventBus.send(Event.QueueChanged)
|
||||
}
|
||||
|
||||
fun insertNext(track: Track) {
|
||||
val factory = factory()
|
||||
val url = normalizeUrl(track.bestUpload()?.listen_url ?: "")
|
||||
|
||||
if (metadata.indexOf(track) == -1) {
|
||||
ProgressiveMediaSource.Factory(factory).createMediaSource(Uri.parse(url)).let {
|
||||
datasources.addMediaSource(current + 1, it)
|
||||
metadata.add(current + 1, track)
|
||||
}
|
||||
} else {
|
||||
move(metadata.indexOf(track), current + 1)
|
||||
}
|
||||
|
||||
persist()
|
||||
|
||||
EventBus.send(Event.QueueChanged)
|
||||
}
|
||||
|
||||
fun remove(track: Track) {
|
||||
metadata.indexOf(track).let {
|
||||
datasources.removeMediaSource(it)
|
||||
metadata.removeAt(it)
|
||||
}
|
||||
|
||||
persist()
|
||||
|
||||
EventBus.send(Event.QueueChanged)
|
||||
}
|
||||
|
||||
fun move(oldPosition: Int, newPosition: Int) {
|
||||
datasources.moveMediaSource(oldPosition, newPosition)
|
||||
metadata.add(newPosition, metadata.removeAt(oldPosition))
|
||||
|
||||
persist()
|
||||
}
|
||||
|
||||
fun get() = metadata.mapIndexed { index, track ->
|
||||
track.current = index == current
|
||||
track
|
||||
}
|
||||
|
||||
fun get(index: Int): Track = metadata[index]
|
||||
|
||||
fun current(): Track? {
|
||||
if (current == -1) {
|
||||
return metadata.getOrNull(0)
|
||||
}
|
||||
|
||||
return metadata.getOrNull(current)
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
package com.github.apognu.otter.repositories
|
||||
|
||||
import android.content.Context
|
||||
import com.github.apognu.otter.utils.Album
|
||||
import com.github.apognu.otter.utils.AlbumsCache
|
||||
import com.github.apognu.otter.utils.AlbumsResponse
|
||||
import com.github.apognu.otter.utils.FunkwhaleResponse
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.io.BufferedReader
|
||||
|
||||
class AlbumsRepository(override val context: Context?, artistId: Int? = null) : Repository<Album, AlbumsCache>() {
|
||||
override val cacheId: String by lazy {
|
||||
if (artistId == null) "albums"
|
||||
else "albums-artist-$artistId"
|
||||
}
|
||||
|
||||
override val upstream: Upstream<Album> by lazy {
|
||||
val url =
|
||||
if (artistId == null) "/api/v1/albums?playable=true"
|
||||
else "/api/v1/albums?playable=true&artist=$artistId"
|
||||
|
||||
HttpUpstream<Album, FunkwhaleResponse<Album>>(
|
||||
HttpUpstream.Behavior.Progressive,
|
||||
url,
|
||||
object : TypeToken<AlbumsResponse>() {}.type
|
||||
)
|
||||
}
|
||||
|
||||
override fun cache(data: List<Album>) = AlbumsCache(data)
|
||||
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(AlbumsCache::class.java).deserialize(reader)
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
package com.github.apognu.otter.repositories
|
||||
|
||||
import android.content.Context
|
||||
import com.github.apognu.otter.utils.Artist
|
||||
import com.github.apognu.otter.utils.ArtistsCache
|
||||
import com.github.apognu.otter.utils.ArtistsResponse
|
||||
import com.github.apognu.otter.utils.FunkwhaleResponse
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.io.BufferedReader
|
||||
|
||||
class ArtistsRepository(override val context: Context?) : Repository<Artist, ArtistsCache>() {
|
||||
override val cacheId = "artists"
|
||||
override val upstream = HttpUpstream<Artist, FunkwhaleResponse<Artist>>(HttpUpstream.Behavior.Progressive, "/api/v1/artists?playable=true", object : TypeToken<ArtistsResponse>() {}.type)
|
||||
|
||||
override fun cache(data: List<Artist>) = ArtistsCache(data)
|
||||
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader)
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
package com.github.apognu.otter.repositories
|
||||
|
||||
import android.content.Context
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.github.kittinunf.fuel.Fuel
|
||||
import com.github.kittinunf.fuel.coroutines.awaitByteArrayResponseResult
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.preference.PowerPreference
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.BufferedReader
|
||||
|
||||
class FavoritesRepository(override val context: Context?) : Repository<Favorite, FavoritesCache>() {
|
||||
override val cacheId = "favorites"
|
||||
override val upstream = HttpUpstream<Favorite, FunkwhaleResponse<Favorite>>(HttpUpstream.Behavior.AtOnce, "/api/v1/favorites/tracks?playable=true", object : TypeToken<FavoritesResponse>() {}.type)
|
||||
|
||||
override fun cache(data: List<Favorite>) = FavoritesCache(data)
|
||||
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(FavoritesCache::class.java).deserialize(reader)
|
||||
|
||||
override fun onDataFetched(data: List<Favorite>) = data.map {
|
||||
it.apply {
|
||||
it.track.favorite = true
|
||||
}
|
||||
}
|
||||
|
||||
fun addFavorite(id: Int) {
|
||||
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
|
||||
val body = mapOf("track" to id)
|
||||
|
||||
runBlocking(IO) {
|
||||
Fuel
|
||||
.post(normalizeUrl("/api/v1/favorites/tracks"))
|
||||
.header("Authorization", "Bearer $token")
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Gson().toJson(body))
|
||||
.awaitByteArrayResponseResult()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteFavorite(id: Int) {
|
||||
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
|
||||
val body = mapOf("track" to id)
|
||||
|
||||
runBlocking(IO) {
|
||||
Fuel
|
||||
.post(normalizeUrl("/api/v1/favorites/tracks/remove/"))
|
||||
.header("Authorization", "Bearer $token")
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Gson().toJson(body))
|
||||
.awaitByteArrayResponseResult()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,102 @@
|
||||
package com.github.apognu.otter.repositories
|
||||
|
||||
import android.net.Uri
|
||||
import com.github.apognu.otter.utils.*
|
||||
import com.github.kittinunf.fuel.Fuel
|
||||
import com.github.kittinunf.fuel.core.FuelError
|
||||
import com.github.kittinunf.fuel.core.ResponseDeserializable
|
||||
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
|
||||
import com.github.kittinunf.fuel.coroutines.awaitObjectResult
|
||||
import com.github.kittinunf.result.Result
|
||||
import com.google.gson.Gson
|
||||
import com.preference.PowerPreference
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.Reader
|
||||
import java.lang.reflect.Type
|
||||
import kotlin.math.ceil
|
||||
|
||||
class HttpUpstream<D : Any, R : FunkwhaleResponse<D>>(private val behavior: Behavior, private val url: String, private val type: Type) : Upstream<D> {
|
||||
enum class Behavior {
|
||||
AtOnce, Progressive
|
||||
}
|
||||
|
||||
private var _channel: Channel<Repository.Response<D>>? = null
|
||||
private val channel: Channel<Repository.Response<D>>
|
||||
get() {
|
||||
if (_channel?.isClosedForSend ?: true) {
|
||||
_channel = Channel()
|
||||
}
|
||||
|
||||
return _channel!!
|
||||
}
|
||||
|
||||
override fun fetch(data: List<D>): Channel<Repository.Response<D>>? {
|
||||
val page = ceil(data.size / AppContext.PAGE_SIZE.toDouble()).toInt() + 1
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val offsetUrl =
|
||||
Uri.parse(url)
|
||||
.buildUpon()
|
||||
.appendQueryParameter("page_size", AppContext.PAGE_SIZE.toString())
|
||||
.appendQueryParameter("page", page.toString())
|
||||
.build()
|
||||
.toString()
|
||||
|
||||
get(offsetUrl).fold(
|
||||
{ response ->
|
||||
val data = data.plus(response.getData())
|
||||
|
||||
if (behavior == Behavior.Progressive || response.next == null) {
|
||||
channel.offer(Repository.Response(Repository.Origin.Network, data))
|
||||
} else {
|
||||
fetch(data)
|
||||
}
|
||||
},
|
||||
{ error ->
|
||||
when (error.exception) {
|
||||
is RefreshError -> EventBus.send(Event.LogOut)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return channel
|
||||
}
|
||||
|
||||
class GenericDeserializer<T : FunkwhaleResponse<*>>(val type: Type) : ResponseDeserializable<T> {
|
||||
override fun deserialize(reader: Reader): T? {
|
||||
return Gson().fromJson(reader, type)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun get(url: String): Result<R, FuelError> {
|
||||
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
|
||||
|
||||
val (_, response, result) = Fuel
|
||||
.get(normalizeUrl(url))
|
||||
.header("Authorization", "Bearer $token")
|
||||
.awaitObjectResponseResult(GenericDeserializer<R>(type))
|
||||
|
||||
if (response.statusCode == 401) {
|
||||
return retryGet(url)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend fun retryGet(url: String): Result<R, FuelError> {
|
||||
return if (HTTP.refresh()) {
|
||||
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
|
||||
|
||||
Fuel
|
||||
.get(normalizeUrl(url))
|
||||
.header("Authorization", "Bearer $token")
|
||||
.awaitObjectResult(GenericDeserializer(type))
|
||||
} else {
|
||||
Result.Failure(FuelError.wrap(RefreshError))
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
package com.github.apognu.otter.repositories
|
||||
|
||||
import android.content.Context
|
||||
import com.github.apognu.otter.utils.FunkwhaleResponse
|
||||
import com.github.apognu.otter.utils.PlaylistTrack
|
||||
import com.github.apognu.otter.utils.PlaylistTracksCache
|
||||
import com.github.apognu.otter.utils.PlaylistTracksResponse
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.io.BufferedReader
|
||||
|
||||
class PlaylistTracksRepository(override val context: Context?, playlistId: Int) : Repository<PlaylistTrack, PlaylistTracksCache>() {
|
||||
override val cacheId = "tracks-playlist-$playlistId"
|
||||
override val upstream = HttpUpstream<PlaylistTrack, FunkwhaleResponse<PlaylistTrack>>(HttpUpstream.Behavior.AtOnce, "/api/v1/playlists/$playlistId/tracks?playable=true", object : TypeToken<PlaylistTracksResponse>() {}.type)
|
||||
|
||||
override fun cache(data: List<PlaylistTrack>) = PlaylistTracksCache(data)
|
||||
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(PlaylistTracksCache::class.java).deserialize(reader)
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
package com.github.apognu.otter.repositories
|
||||
|
||||
import android.content.Context
|
||||
import com.github.apognu.otter.utils.FunkwhaleResponse
|
||||
import com.github.apognu.otter.utils.Playlist
|
||||
import com.github.apognu.otter.utils.PlaylistsCache
|
||||
import com.github.apognu.otter.utils.PlaylistsResponse
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.io.BufferedReader
|
||||
|
||||
class PlaylistsRepository(override val context: Context?) : Repository<Playlist, PlaylistsCache>() {
|
||||
override val cacheId = "tracks-playlists"
|
||||
override val upstream = HttpUpstream<Playlist, FunkwhaleResponse<Playlist>>(HttpUpstream.Behavior.Progressive, "/api/v1/playlists?playable=true", object : TypeToken<PlaylistsResponse>() {}.type)
|
||||
|
||||
override fun cache(data: List<Playlist>) = PlaylistsCache(data)
|
||||
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(PlaylistsCache::class.java).deserialize(reader)
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
package com.github.apognu.otter.repositories
|
||||
|
||||
import android.content.Context
|
||||
import com.github.apognu.otter.utils.Cache
|
||||
import com.github.apognu.otter.utils.CacheItem
|
||||
import com.github.apognu.otter.utils.untilNetwork
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import java.io.BufferedReader
|
||||
|
||||
interface Upstream<D> {
|
||||
fun fetch(data: List<D> = listOf()): Channel<Repository.Response<D>>?
|
||||
}
|
||||
|
||||
abstract class Repository<D : Any, C : CacheItem<D>> {
|
||||
enum class Origin(val origin: Int) {
|
||||
Cache(0b01),
|
||||
Network(0b10)
|
||||
}
|
||||
|
||||
data class Response<D>(val origin: Origin, val data: List<D>)
|
||||
|
||||
abstract val context: Context?
|
||||
abstract val cacheId: String?
|
||||
abstract val upstream: Upstream<D>
|
||||
|
||||
private var _channel: Channel<Response<D>>? = null
|
||||
private val channel: Channel<Response<D>>
|
||||
get() {
|
||||
if (_channel?.isClosedForSend ?: true) {
|
||||
_channel = Channel(10)
|
||||
}
|
||||
|
||||
return _channel!!
|
||||
}
|
||||
|
||||
protected open fun cache(data: List<D>): C? = null
|
||||
protected open fun uncache(reader: BufferedReader): C? = null
|
||||
|
||||
fun fetch(upstreams: Int = Origin.Cache.origin and Origin.Network.origin, from: List<D> = listOf()): Channel<Response<D>> {
|
||||
if (Origin.Cache.origin and upstreams == upstreams) fromCache()
|
||||
if (Origin.Network.origin and upstreams == upstreams) fromNetwork(from)
|
||||
|
||||
return channel
|
||||
}
|
||||
|
||||
private fun fromCache() {
|
||||
cacheId?.let { cacheId ->
|
||||
Cache.get(context, cacheId)?.let { reader ->
|
||||
uncache(reader)?.let { cache ->
|
||||
channel.offer(Response(Origin.Cache, cache.data))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fromNetwork(from: List<D>) {
|
||||
upstream.fetch(data = from)?.untilNetwork(IO) {
|
||||
val data = onDataFetched(it)
|
||||
|
||||
cacheId?.let { cacheId ->
|
||||
Cache.set(
|
||||
context,
|
||||
cacheId,
|
||||
Gson().toJson(cache(data)).toByteArray()
|
||||
)
|
||||
}
|
||||
|
||||
channel.offer(Response(Origin.Network, data))
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun onDataFetched(data: List<D>) = data
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
package com.github.apognu.otter.repositories
|
||||
|
||||
import android.content.Context
|
||||
import com.github.apognu.otter.utils.FunkwhaleResponse
|
||||
import com.github.apognu.otter.utils.Track
|
||||
import com.github.apognu.otter.utils.TracksCache
|
||||
import com.github.apognu.otter.utils.TracksResponse
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.BufferedReader
|
||||
|
||||
class SearchRepository(override val context: Context?, query: String) : Repository<Track, TracksCache>() {
|
||||
override val cacheId: String? = null
|
||||
override val upstream = HttpUpstream<Track, FunkwhaleResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks?playable=true&q=$query", object : TypeToken<TracksResponse>() {}.type)
|
||||
|
||||
override fun cache(data: List<Track>) = TracksCache(data)
|
||||
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
|
||||
|
||||
var query: String? = null
|
||||
|
||||
override fun onDataFetched(data: List<Track>): List<Track> = runBlocking {
|
||||
val favorites = FavoritesRepository(context).fetch(Origin.Network.origin).receive().data
|
||||
|
||||
data.map { track ->
|
||||
val favorite = favorites.find { it.track.id == track.id }
|
||||
|
||||
if (favorite != null) {
|
||||
track.favorite = true
|
||||
}
|
||||
|
||||
track
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
package com.github.apognu.otter.repositories
|
||||
|
||||
import android.content.Context
|
||||
import com.github.apognu.otter.utils.FunkwhaleResponse
|
||||
import com.github.apognu.otter.utils.Track
|
||||
import com.github.apognu.otter.utils.TracksCache
|
||||
import com.github.apognu.otter.utils.TracksResponse
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.BufferedReader
|
||||
|
||||
class TracksRepository(override val context: Context?, albumId: Int) : Repository<Track, TracksCache>() {
|
||||
override val cacheId = "tracks-album-$albumId"
|
||||
override val upstream = HttpUpstream<Track, FunkwhaleResponse<Track>>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks?playable=true&album=$albumId", object : TypeToken<TracksResponse>() {}.type)
|
||||
|
||||
override fun cache(data: List<Track>) = TracksCache(data)
|
||||
override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader)
|
||||
|
||||
override fun onDataFetched(data: List<Track>): List<Track> = runBlocking {
|
||||
val favorites = FavoritesRepository(context).fetch(Origin.Network.origin).receive().data
|
||||
|
||||
data.map { track ->
|
||||
val favorite = favorites.find { it.track.id == track.id }
|
||||
|
||||
if (favorite != null) {
|
||||
track.favorite = true
|
||||
}
|
||||
|
||||
track
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
package com.github.apognu.otter.utils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.os.Build
|
||||
import com.github.apognu.otter.R
|
||||
import com.github.kittinunf.fuel.core.FuelManager
|
||||
import com.github.kittinunf.fuel.core.Method
|
||||
|
||||
object AppContext {
|
||||
const val PREFS_CREDENTIALS = "credentials"
|
||||
|
||||
const val NOTIFICATION_MEDIA_CONTROL = 1
|
||||
const val NOTIFICATION_CHANNEL_MEDIA_CONTROL = "mediacontrols"
|
||||
|
||||
const val PAGE_SIZE = 7
|
||||
const val TRANSITION_DURATION = 300L
|
||||
|
||||
fun init(context: Activity) {
|
||||
setupNotificationChannels(context)
|
||||
|
||||
context.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
|
||||
// CastContext.getSharedInstance(context)
|
||||
|
||||
FuelManager.instance.addResponseInterceptor { next ->
|
||||
{ request, response ->
|
||||
if (request.method == Method.GET && response.statusCode == 200) {
|
||||
var cacheId = request.url.path.toString()
|
||||
|
||||
request.url.query?.let {
|
||||
cacheId = "$cacheId?$it"
|
||||
}
|
||||
|
||||
Cache.set(context, cacheId, response.body().toByteArray())
|
||||
}
|
||||
|
||||
next(request, response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private fun setupNotificationChannels(context: Context) {
|
||||
Build.VERSION_CODES.O.onApi {
|
||||
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).let { manager ->
|
||||
NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_MEDIA_CONTROL,
|
||||
context.getString(R.string.playback_media_controls),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).run {
|
||||
description = context.getString(R.string.playback_media_controls_description)
|
||||
|
||||
enableLights(false)
|
||||
enableVibration(false)
|
||||
setSound(null, null)
|
||||
|
||||
manager.createNotificationChannel(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class HeadphonesUnpluggedReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
CommandBus.send(Command.SetState(false))
|
||||
}
|
||||
}
|
90
app/src/main/java/com/github/apognu/otter/utils/Data.kt
Normal file
@ -0,0 +1,90 @@
|
||||
package com.github.apognu.otter.utils
|
||||
|
||||
import android.content.Context
|
||||
import com.github.apognu.otter.activities.FwCredentials
|
||||
import com.github.kittinunf.fuel.Fuel
|
||||
import com.github.kittinunf.fuel.core.FuelError
|
||||
import com.github.kittinunf.fuel.coroutines.awaitObjectResponseResult
|
||||
import com.github.kittinunf.fuel.coroutines.awaitObjectResult
|
||||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf
|
||||
import com.github.kittinunf.result.Result
|
||||
import com.preference.PowerPreference
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.nio.charset.Charset
|
||||
import java.security.MessageDigest
|
||||
|
||||
object RefreshError : Throwable()
|
||||
|
||||
object HTTP {
|
||||
suspend fun refresh(): Boolean {
|
||||
val body = mapOf(
|
||||
"username" to PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("username"),
|
||||
"password" to PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("password")
|
||||
).toList()
|
||||
|
||||
val result = Fuel.post(normalizeUrl("/api/v1/token"), body).awaitObjectResult(gsonDeserializerOf(FwCredentials::class.java))
|
||||
|
||||
return result.fold(
|
||||
{ data ->
|
||||
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).setString("access_token", data.token)
|
||||
|
||||
true
|
||||
},
|
||||
{ false }
|
||||
)
|
||||
}
|
||||
|
||||
suspend inline fun <reified T : Any> get(url: String): Result<T, FuelError> {
|
||||
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
|
||||
|
||||
val (_, response, result) = Fuel
|
||||
.get(normalizeUrl(url))
|
||||
.header("Authorization", "Bearer $token")
|
||||
.awaitObjectResponseResult(gsonDeserializerOf(T::class.java))
|
||||
|
||||
if (response.statusCode == 401) {
|
||||
return retryGet(url)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
suspend inline fun <reified T : Any> retryGet(url: String): Result<T, FuelError> {
|
||||
return if (refresh()) {
|
||||
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token")
|
||||
|
||||
Fuel
|
||||
.get(normalizeUrl(url))
|
||||
.header("Authorization", "Bearer $token")
|
||||
.awaitObjectResult(gsonDeserializerOf(T::class.java))
|
||||
} else {
|
||||
Result.Failure(FuelError.wrap(RefreshError))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object Cache {
|
||||
private fun key(key: String): String {
|
||||
val md = MessageDigest.getInstance("SHA-1")
|
||||
val digest = md.digest(key.toByteArray(Charset.defaultCharset()))
|
||||
|
||||
return digest.fold("", { acc, it -> acc + "%02x".format(it) })
|
||||
}
|
||||
|
||||
fun set(context: Context?, key: String, value: ByteArray) = context?.let {
|
||||
with(File(it.cacheDir, key(key))) {
|
||||
writeBytes(value)
|
||||
}
|
||||
}
|
||||
|
||||
fun get(context: Context?, key: String): BufferedReader? = context?.let {
|
||||
try {
|
||||
with(File(it.cacheDir, key(key))) {
|
||||
bufferedReader()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
117
app/src/main/java/com/github/apognu/otter/utils/EventBus.kt
Normal file
@ -0,0 +1,117 @@
|
||||
package com.github.apognu.otter.utils
|
||||
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.channels.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed class Command {
|
||||
object RefreshService : Command()
|
||||
|
||||
object ToggleState : Command()
|
||||
class SetState(val state: Boolean) : Command()
|
||||
|
||||
object NextTrack : Command()
|
||||
object PreviousTrack : Command()
|
||||
class Seek(val progress: Int) : Command()
|
||||
|
||||
class AddToQueue(val tracks: List<Track>) : Command()
|
||||
class PlayNext(val track: Track) : Command()
|
||||
class ReplaceQueue(val queue: List<Track>) : Command()
|
||||
class RemoveFromQueue(val track: Track) : Command()
|
||||
class MoveFromQueue(val oldPosition: Int, val newPosition: Int) : Command()
|
||||
|
||||
class PlayTrack(val index: Int) : Command()
|
||||
}
|
||||
|
||||
sealed class Event {
|
||||
object LogOut : Event()
|
||||
|
||||
class PlaybackError(val message: String) : Event()
|
||||
object PlaybackStopped : Event()
|
||||
class Buffering(val value: Boolean) : Event()
|
||||
class TrackPlayed(val track: Track?, val play: Boolean) : Event()
|
||||
class StateChanged(val playing: Boolean) : Event()
|
||||
object QueueChanged : Event()
|
||||
}
|
||||
|
||||
sealed class Request(var channel: Channel<Response>? = null) {
|
||||
object GetState : Request()
|
||||
object GetQueue : Request()
|
||||
object GetCurrentTrack : Request()
|
||||
}
|
||||
|
||||
sealed class Response {
|
||||
class State(val playing: Boolean) : Response()
|
||||
class Queue(val queue: List<Track>) : Response()
|
||||
class CurrentTrack(val track: Track?) : Response()
|
||||
}
|
||||
|
||||
object EventBus {
|
||||
private var bus: BroadcastChannel<Event> = BroadcastChannel(10)
|
||||
|
||||
fun send(event: Event) {
|
||||
GlobalScope.launch {
|
||||
bus.offer(event)
|
||||
}
|
||||
}
|
||||
|
||||
fun get() = bus
|
||||
|
||||
inline fun <reified T : Event> asChannel(): ReceiveChannel<T> {
|
||||
return get().openSubscription().filter { it is T }.map { it as T }
|
||||
}
|
||||
}
|
||||
|
||||
object CommandBus {
|
||||
private var bus: Channel<Command> = Channel(10)
|
||||
|
||||
fun send(command: Command) {
|
||||
GlobalScope.launch {
|
||||
bus.offer(command)
|
||||
}
|
||||
}
|
||||
|
||||
fun asChannel() = bus
|
||||
}
|
||||
|
||||
object RequestBus {
|
||||
private var bus: BroadcastChannel<Request> = BroadcastChannel(10)
|
||||
|
||||
fun send(request: Request): Channel<Response> {
|
||||
return Channel<Response>().also {
|
||||
GlobalScope.launch(Main) {
|
||||
request.channel = it
|
||||
|
||||
bus.offer(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun get() = bus
|
||||
|
||||
inline fun <reified T> asChannel(): ReceiveChannel<T> {
|
||||
return get().openSubscription().filter { it is T }.map { it as T }
|
||||
}
|
||||
}
|
||||
|
||||
object ProgressBus {
|
||||
private val bus: BroadcastChannel<Triple<Int, Int, Int>> = ConflatedBroadcastChannel()
|
||||
|
||||
fun send(current: Int, duration: Int, percent: Int) {
|
||||
GlobalScope.launch {
|
||||
bus.send(Triple(current, duration, percent))
|
||||
}
|
||||
}
|
||||
|
||||
fun asChannel(): ReceiveChannel<Triple<Int, Int, Int>> {
|
||||
return bus.openSubscription()
|
||||
}
|
||||
}
|
||||
|
||||
suspend inline fun <reified T> Channel<Response>.wait(): T? {
|
||||
return when (val response = this.receive()) {
|
||||
is T -> response
|
||||
else -> null
|
||||
}
|
||||
}
|
@ -0,0 +1,88 @@
|
||||
package com.github.apognu.otter.utils
|
||||
|
||||
import android.os.Build
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.Interpolator
|
||||
import androidx.core.view.doOnPreDraw
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.transition.TransitionSet
|
||||
import com.github.apognu.otter.fragments.BrowseFragment
|
||||
import com.github.apognu.otter.repositories.Repository
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
inline fun <D> Channel<Repository.Response<D>>.await(context: CoroutineContext = Main, crossinline callback: (data: List<D>) -> Unit) {
|
||||
GlobalScope.launch(context) {
|
||||
this@await.receive().also {
|
||||
callback(it.data)
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <D> Channel<Repository.Response<D>>.untilNetwork(context: CoroutineContext = Main, crossinline callback: (data: List<D>) -> Unit) {
|
||||
GlobalScope.launch(context) {
|
||||
for (data in this@untilNetwork) {
|
||||
callback(data.data)
|
||||
|
||||
if (data.origin == Repository.Origin.Network) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun TransitionSet.setCommonInterpolator(interpolator: Interpolator): TransitionSet {
|
||||
(0 until transitionCount)
|
||||
.map { index -> getTransitionAt(index) }
|
||||
.forEach { transition -> transition.interpolator = interpolator }
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
fun Fragment.onViewPager(block: Fragment.() -> Unit) {
|
||||
for (f in activity?.supportFragmentManager?.fragments ?: listOf()) {
|
||||
if (f is BrowseFragment) {
|
||||
f.block()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Fragment.startTransitions() {
|
||||
(view?.parent as? ViewGroup)?.doOnPreDraw {
|
||||
startPostponedEnterTransition()
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> Int.onApi(block: () -> T) {
|
||||
if (Build.VERSION.SDK_INT >= this) {
|
||||
block()
|
||||
}
|
||||
}
|
||||
|
||||
fun <T, U> Int.onApi(block: () -> T, elseBlock: (() -> U)) {
|
||||
if (Build.VERSION.SDK_INT >= this) {
|
||||
block()
|
||||
} else {
|
||||
elseBlock()
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> Int.onApiForResult(block: () -> T, elseBlock: (() -> T)): T {
|
||||
if (Build.VERSION.SDK_INT >= this) {
|
||||
return block()
|
||||
} else {
|
||||
return elseBlock()
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> T.applyOnApi(api: Int, block: T.() -> T): T {
|
||||
if (Build.VERSION.SDK_INT >= api) {
|
||||
return block()
|
||||
} else {
|
||||
return this
|
||||
}
|
||||
}
|
113
app/src/main/java/com/github/apognu/otter/utils/Models.kt
Normal file
@ -0,0 +1,113 @@
|
||||
package com.github.apognu.otter.utils
|
||||
|
||||
import com.preference.PowerPreference
|
||||
|
||||
sealed class CacheItem<D : Any>(val data: List<D>)
|
||||
class ArtistsCache(data: List<Artist>) : CacheItem<Artist>(data)
|
||||
class AlbumsCache(data: List<Album>) : CacheItem<Album>(data)
|
||||
class TracksCache(data: List<Track>) : CacheItem<Track>(data)
|
||||
class PlaylistsCache(data: List<Playlist>) : CacheItem<Playlist>(data)
|
||||
class PlaylistTracksCache(data: List<PlaylistTrack>) : CacheItem<PlaylistTrack>(data)
|
||||
class FavoritesCache(data: List<Favorite>) : CacheItem<Favorite>(data)
|
||||
class QueueCache(data: List<Track>) : CacheItem<Track>(data)
|
||||
|
||||
abstract class FunkwhaleResponse<D : Any> {
|
||||
abstract val count: Int
|
||||
abstract val next: String?
|
||||
|
||||
abstract fun getData(): List<D>
|
||||
}
|
||||
|
||||
data class ArtistsResponse(override val count: Int, override val next: String?, val results: List<Artist>) : FunkwhaleResponse<Artist>() {
|
||||
override fun getData() = results
|
||||
}
|
||||
|
||||
data class AlbumsResponse(override val count: Int, override val next: String?, val results: AlbumList) : FunkwhaleResponse<Album>() {
|
||||
override fun getData() = results
|
||||
}
|
||||
|
||||
data class TracksResponse(override val count: Int, override val next: String?, val results: List<Track>) : FunkwhaleResponse<Track>() {
|
||||
override fun getData() = results
|
||||
}
|
||||
|
||||
data class FavoritesResponse(override val count: Int, override val next: String?, val results: List<Favorite>) : FunkwhaleResponse<Favorite>() {
|
||||
override fun getData() = results
|
||||
}
|
||||
|
||||
data class PlaylistsResponse(override val count: Int, override val next: String?, val results: List<Playlist>) : FunkwhaleResponse<Playlist>() {
|
||||
override fun getData() = results
|
||||
}
|
||||
|
||||
data class PlaylistTracksResponse(override val count: Int, override val next: String?, val results: List<PlaylistTrack>) : FunkwhaleResponse<PlaylistTrack>() {
|
||||
override fun getData() = results
|
||||
}
|
||||
|
||||
data class Covers(val original: String)
|
||||
|
||||
typealias AlbumList = List<Album>
|
||||
|
||||
data class Album(
|
||||
val id: Int,
|
||||
val artist: Artist,
|
||||
val title: String,
|
||||
val cover: Covers
|
||||
) {
|
||||
data class Artist(val name: String)
|
||||
}
|
||||
|
||||
data class Artist(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val albums: List<Album>?
|
||||
) {
|
||||
data class Album(
|
||||
val title: String,
|
||||
val cover: Covers
|
||||
)
|
||||
}
|
||||
|
||||
data class Track(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val artist: Artist,
|
||||
val album: Album,
|
||||
val uploads: List<Upload>
|
||||
) {
|
||||
var current: Boolean = false
|
||||
var favorite: Boolean = false
|
||||
|
||||
data class Upload(
|
||||
val listen_url: String,
|
||||
val duration: Int,
|
||||
val bitrate: Int
|
||||
)
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return when (other) {
|
||||
is Track -> other.id == id
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun bestUpload(): Upload? {
|
||||
if (uploads.isEmpty()) return null
|
||||
|
||||
return when (PowerPreference.getDefaultFile().getString("media_cache_quality")) {
|
||||
"quality" -> uploads.maxBy { it.bitrate } ?: uploads[0]
|
||||
"size" -> uploads.minBy { it.bitrate } ?: uploads[0]
|
||||
else -> uploads.maxBy { it.bitrate } ?: uploads[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Favorite(val id: Int, val track: Track)
|
||||
|
||||
data class Playlist(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val album_covers: List<String>,
|
||||
val tracks_count: Int,
|
||||
val duration: Int
|
||||
)
|
||||
|
||||
data class PlaylistTrack(val track: Track)
|
26
app/src/main/java/com/github/apognu/otter/utils/Util.kt
Normal file
@ -0,0 +1,26 @@
|
||||
package com.github.apognu.otter.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import com.google.android.exoplayer2.util.Log
|
||||
import com.preference.PowerPreference
|
||||
import java.net.URI
|
||||
|
||||
fun Context?.toast(message: String, length: Int = Toast.LENGTH_SHORT) {
|
||||
if (this != null) {
|
||||
Toast.makeText(this, message, length).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun Any.log(message: String) {
|
||||
Log.d("FUNKWHALE", "${this.javaClass.simpleName}: $message")
|
||||
}
|
||||
|
||||
fun normalizeUrl(url: String): String {
|
||||
val fallbackHost = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("hostname")
|
||||
val uri = URI(url).takeIf { it.host != null } ?: URI("$fallbackHost$url")
|
||||
|
||||
return uri.run {
|
||||
URI("https", host, path, query, null)
|
||||
}.toString()
|
||||
}
|
@ -0,0 +1,79 @@
|
||||
package com.github.apognu.otter.views
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.ObjectAnimator
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.transition.TransitionValues
|
||||
import androidx.transition.Visibility
|
||||
|
||||
class ExplodeReveal : Visibility() {
|
||||
val SCREEN_BOUNDS = "screenBounds"
|
||||
|
||||
private val locations = IntArray(2)
|
||||
|
||||
override fun captureStartValues(transitionValues: TransitionValues) {
|
||||
super.captureStartValues(transitionValues)
|
||||
|
||||
capture(transitionValues)
|
||||
}
|
||||
|
||||
override fun captureEndValues(transitionValues: TransitionValues) {
|
||||
super.captureEndValues(transitionValues)
|
||||
|
||||
capture(transitionValues)
|
||||
}
|
||||
|
||||
override fun onAppear(sceneRoot: ViewGroup, view: View, startValues: TransitionValues?, endValues: TransitionValues?): Animator? {
|
||||
if (endValues == null) return null
|
||||
|
||||
val bounds = endValues.values[SCREEN_BOUNDS] as Rect
|
||||
|
||||
val endY = view.translationY
|
||||
val distance = calculateDistance(sceneRoot, bounds)
|
||||
val startY = endY + distance
|
||||
|
||||
return ObjectAnimator.ofFloat(view, View.TRANSLATION_Y, startY, endY)
|
||||
}
|
||||
|
||||
override fun onDisappear(sceneRoot: ViewGroup, view: View, startValues: TransitionValues?, endValues: TransitionValues?): Animator? {
|
||||
if (startValues == null) return null
|
||||
|
||||
val bounds = startValues.values[SCREEN_BOUNDS] as Rect
|
||||
|
||||
val startY = view.translationY
|
||||
val distance = calculateDistance(sceneRoot, bounds)
|
||||
val endY = startY + distance
|
||||
|
||||
return ObjectAnimator.ofFloat(view, View.TRANSLATION_Y, startY, endY)
|
||||
}
|
||||
|
||||
private fun capture(transitionValues: TransitionValues) {
|
||||
transitionValues.view.also {
|
||||
it.getLocationOnScreen(locations)
|
||||
|
||||
val left = locations[0]
|
||||
val top = locations[1]
|
||||
val right = left + it.width
|
||||
val bottom = top + it.height
|
||||
|
||||
transitionValues.values[SCREEN_BOUNDS] = Rect(left, top, right, bottom)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateDistance(sceneRoot: View, viewBounds: Rect): Int {
|
||||
sceneRoot.getLocationOnScreen(locations)
|
||||
|
||||
val sceneRootY = locations[1]
|
||||
|
||||
return when (epicenter) {
|
||||
is Rect -> return when {
|
||||
viewBounds.top <= (epicenter as Rect).top -> sceneRootY - (epicenter as Rect).top
|
||||
else -> sceneRootY + sceneRoot.height - (epicenter as Rect).bottom
|
||||
}
|
||||
|
||||
else -> -sceneRoot.height
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,240 @@
|
||||
package com.github.apognu.otter.views
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import com.github.apognu.otter.R
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import kotlinx.android.synthetic.main.partial_now_playing.view.*
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.min
|
||||
|
||||
class NowPlayingView : MaterialCardView {
|
||||
val activity: Context
|
||||
var gestureDetector: GestureDetector? = null
|
||||
var gestureDetectorCallback: OnGestureDetection? = null
|
||||
|
||||
constructor(context: Context) : super(context) {
|
||||
activity = context
|
||||
}
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
|
||||
activity = context
|
||||
}
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?, style: Int) : super(context, attrs, style) {
|
||||
activity = context
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
|
||||
now_playing_root.measure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(heightMeasureSpec), MeasureSpec.UNSPECIFIED))
|
||||
}
|
||||
|
||||
override fun onVisibilityChanged(changedView: View, visibility: Int) {
|
||||
super.onVisibilityChanged(changedView, visibility)
|
||||
|
||||
if (visibility == View.VISIBLE && gestureDetector == null) {
|
||||
viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
|
||||
override fun onGlobalLayout() {
|
||||
gestureDetectorCallback = OnGestureDetection()
|
||||
gestureDetector = GestureDetector(context, gestureDetectorCallback)
|
||||
|
||||
setOnTouchListener { _, motionEvent ->
|
||||
val ret = gestureDetector?.onTouchEvent(motionEvent) ?: false
|
||||
|
||||
if (motionEvent.actionMasked == MotionEvent.ACTION_UP) {
|
||||
if (gestureDetectorCallback?.isScrolling == true) {
|
||||
gestureDetectorCallback?.onUp(motionEvent)
|
||||
}
|
||||
}
|
||||
|
||||
ret
|
||||
}
|
||||
|
||||
viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun isOpened(): Boolean = gestureDetectorCallback?.isOpened() ?: false
|
||||
|
||||
fun close() {
|
||||
gestureDetectorCallback?.close()
|
||||
}
|
||||
|
||||
inner class OnGestureDetection : GestureDetector.SimpleOnGestureListener() {
|
||||
var maxHeight = 0
|
||||
private var minHeight = 0
|
||||
private var maxMargin = 0
|
||||
|
||||
private var initialTouchY = 0f
|
||||
private var lastTouchY = 0f
|
||||
|
||||
var isScrolling = false
|
||||
private var flingAnimator: ValueAnimator? = null
|
||||
|
||||
init {
|
||||
(layoutParams as? MarginLayoutParams)?.let {
|
||||
maxMargin = it.marginStart
|
||||
}
|
||||
|
||||
minHeight = TypedValue().let {
|
||||
activity.theme.resolveAttribute(R.attr.actionBarSize, it, true)
|
||||
|
||||
TypedValue.complexToDimensionPixelSize(it.data, resources.displayMetrics)
|
||||
}
|
||||
|
||||
maxHeight = now_playing_details.measuredHeight + (2 * maxMargin)
|
||||
}
|
||||
|
||||
override fun onDown(e: MotionEvent): Boolean {
|
||||
initialTouchY = e.rawY
|
||||
lastTouchY = e.rawY
|
||||
|
||||
flingAnimator?.cancel()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun onUp(event: MotionEvent): Boolean {
|
||||
isScrolling = false
|
||||
|
||||
layoutParams.let {
|
||||
val offsetToMax = maxHeight - height
|
||||
val offsetToMin = height - minHeight
|
||||
|
||||
flingAnimator =
|
||||
if (offsetToMin < offsetToMax) ValueAnimator.ofInt(it.height, minHeight)
|
||||
else ValueAnimator.ofInt(it.height, maxHeight)
|
||||
|
||||
animateFling(500)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFling(firstMotionEvent: MotionEvent?, secondMotionEvent: MotionEvent?, velocityX: Float, velocityY: Float): Boolean {
|
||||
isScrolling = false
|
||||
|
||||
layoutParams.let {
|
||||
val diff =
|
||||
if (velocityY < 0) maxHeight - it.height
|
||||
else it.height - minHeight
|
||||
|
||||
flingAnimator =
|
||||
if (velocityY < 0) ValueAnimator.ofInt(it.height, maxHeight)
|
||||
else ValueAnimator.ofInt(it.height, minHeight)
|
||||
|
||||
animateFling(min(abs((diff.toFloat() / velocityY * 1000).toLong()), 600))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onScroll(firstMotionEvent: MotionEvent, secondMotionEvent: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
|
||||
isScrolling = true
|
||||
|
||||
layoutParams.let {
|
||||
val newHeight = it.height + lastTouchY - secondMotionEvent.rawY
|
||||
val progress = (newHeight - minHeight) / (maxHeight - minHeight)
|
||||
val newMargin = maxMargin - (maxMargin * progress)
|
||||
|
||||
(layoutParams as? MarginLayoutParams)?.let {
|
||||
it.marginStart = newMargin.toInt()
|
||||
it.marginEnd = newMargin.toInt()
|
||||
it.bottomMargin = newMargin.toInt()
|
||||
}
|
||||
|
||||
layoutParams = layoutParams.apply {
|
||||
when {
|
||||
newHeight <= minHeight -> {
|
||||
height = minHeight
|
||||
return true
|
||||
}
|
||||
newHeight >= maxHeight -> {
|
||||
height = maxHeight
|
||||
return true
|
||||
}
|
||||
else -> height = newHeight.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
summary.alpha = 1f - progress
|
||||
|
||||
summary.layoutParams = summary.layoutParams.apply {
|
||||
height = (minHeight * (1f - progress)).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
lastTouchY = secondMotionEvent.rawY
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSingleTapUp(e: MotionEvent?): Boolean {
|
||||
layoutParams.let {
|
||||
if (height != minHeight) return true
|
||||
|
||||
flingAnimator = ValueAnimator.ofInt(it.height, maxHeight)
|
||||
|
||||
animateFling(300)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun isOpened(): Boolean = layoutParams.height == maxHeight
|
||||
|
||||
fun close(): Boolean {
|
||||
layoutParams.let {
|
||||
if (it.height == minHeight) return true
|
||||
|
||||
flingAnimator = ValueAnimator.ofInt(it.height, minHeight)
|
||||
|
||||
animateFling(300)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun animateFling(dur: Long) {
|
||||
flingAnimator?.apply {
|
||||
duration = dur
|
||||
interpolator = DecelerateInterpolator()
|
||||
|
||||
addUpdateListener { valueAnimator ->
|
||||
layoutParams = layoutParams.apply {
|
||||
val newHeight = valueAnimator.animatedValue as Int
|
||||
val progress = (newHeight.toFloat() - minHeight) / (maxHeight - minHeight)
|
||||
val newMargin = maxMargin - (maxMargin * progress)
|
||||
|
||||
(layoutParams as? MarginLayoutParams)?.let {
|
||||
it.marginStart = newMargin.toInt()
|
||||
it.marginEnd = newMargin.toInt()
|
||||
it.bottomMargin = newMargin.toInt()
|
||||
}
|
||||
|
||||
height = newHeight
|
||||
|
||||
summary.alpha = 1f - progress
|
||||
|
||||
summary.layoutParams = summary.layoutParams.apply {
|
||||
height = (minHeight * (1f - progress)).toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
package com.github.apognu.otter.views
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
|
||||
class SquareImageView : AppCompatImageView {
|
||||
constructor(context: Context) : super(context)
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
|
||||
constructor(context: Context, attrs: AttributeSet?, style: Int) : super(context, attrs, style)
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
|
||||
setMeasuredDimension(measuredWidth, measuredWidth)
|
||||
}
|
||||
}
|
BIN
app/src/main/res/drawable-hdpi/ottericon.png
Normal file
After Width: | Height: | Size: 696 B |
BIN
app/src/main/res/drawable-mdpi/ottericon.png
Normal file
After Width: | Height: | Size: 469 B |
BIN
app/src/main/res/drawable-xhdpi/ottericon.png
Normal file
After Width: | Height: | Size: 946 B |
BIN
app/src/main/res/drawable-xxhdpi/ottericon.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/drawable-xxxhdpi/ottericon.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
9
app/src/main/res/drawable/add.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
|
||||
</vector>
|
BIN
app/src/main/res/drawable/cover.png
Normal file
After Width: | Height: | Size: 12 KiB |
9
app/src/main/res/drawable/favorite.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z" />
|
||||
</vector>
|
8
app/src/main/res/drawable/login_input.xml
Normal file
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item android:color="@android:color/white" android:state_focused="true" />
|
||||
<item android:color="@android:color/white" android:state_hovered="true" />
|
||||
<item android:color="@android:color/white" />
|
||||
|
||||
</selector>
|
9
app/src/main/res/drawable/more.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z" />
|
||||
</vector>
|
9
app/src/main/res/drawable/next.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z" />
|
||||
</vector>
|
BIN
app/src/main/res/drawable/ottershape.png
Normal file
After Width: | Height: | Size: 54 KiB |
9
app/src/main/res/drawable/pause.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z" />
|
||||
</vector>
|
9
app/src/main/res/drawable/play.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M8,5v14l11,-7z" />
|
||||
</vector>
|
9
app/src/main/res/drawable/previous.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M6,6h2v12L6,18zM9.5,12l8.5,6L18,6z" />
|
||||
</vector>
|
9
app/src/main/res/drawable/queue.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M15,6L3,6v2h12L15,6zM15,10L3,10v2h12v-2zM3,16h8v-2L3,14v2zM17,6v8.18c-0.31,-0.11 -0.65,-0.18 -1,-0.18 -1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3L19,8h3L22,6h-5z" />
|
||||
</vector>
|
9
app/src/main/res/drawable/reorder.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M3,15h18v-2L3,13v2zM3,19h18v-2L3,17v2zM3,11h18L21,9L3,9v2zM3,5v2h18L21,5L3,5z" />
|
||||
</vector>
|
9
app/src/main/res/drawable/search.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" />
|
||||
</vector>
|
9
app/src/main/res/drawable/settings.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M19.1,12.9a2.8,2.8 0,0 0,0.1 -0.9,2.8 2.8,0 0,0 -0.1,-0.9l2.1,-1.6a0.7,0.7 0,0 0,0.1 -0.6L19.4,5.5a0.7,0.7 0,0 0,-0.6 -0.2l-2.4,1a6.5,6.5 0,0 0,-1.6 -0.9l-0.4,-2.6a0.5,0.5 0,0 0,-0.5 -0.4H10.1a0.5,0.5 0,0 0,-0.5 0.4L9.3,5.4a5.6,5.6 0,0 0,-1.7 0.9l-2.4,-1a0.4,0.4 0,0 0,-0.5 0.2l-2,3.4c-0.1,0.2 0,0.4 0.2,0.6l2,1.6a2.8,2.8 0,0 0,-0.1 0.9,2.8 2.8,0 0,0 0.1,0.9L2.8,14.5a0.7,0.7 0,0 0,-0.1 0.6l1.9,3.4a0.7,0.7 0,0 0,0.6 0.2l2.4,-1a6.5,6.5 0,0 0,1.6 0.9l0.4,2.6a0.5,0.5 0,0 0,0.5 0.4h3.8a0.5,0.5 0,0 0,0.5 -0.4l0.3,-2.6a5.6,5.6 0,0 0,1.7 -0.9l2.4,1a0.4,0.4 0,0 0,0.5 -0.2l2,-3.4c0.1,-0.2 0,-0.4 -0.2,-0.6ZM12,15.6A3.6,3.6 0,1 1,15.6 12,3.6 3.6,0 0,1 12,15.6Z" />
|
||||
</vector>
|
31
app/src/main/res/layout/activity_licences.xml
Normal file
@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/surface"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
style="@style/AppTheme.Title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginVertical="16dp"
|
||||
android:text="@string/title_oss_licences" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/licences"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:itemCount="10"
|
||||
tools:listitem="@layout/row_licence" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.core.widget.NestedScrollView>
|
97
app/src/main/res/layout/activity_login.xml
Normal file
@ -0,0 +1,97 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/colorPrimary"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="32dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="128dp"
|
||||
android:layout_height="128dp"
|
||||
android:layout_marginBottom="32dp"
|
||||
android:contentDescription="@string/alt_app_logo"
|
||||
android:src="@drawable/ottershape"
|
||||
android:tint="@android:color/white" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="32dp"
|
||||
android:text="@string/login_welcome"
|
||||
android:textAlignment="center"
|
||||
android:textColor="@android:color/white" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
|
||||
android:id="@+id/hostname_field"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:hint="@string/login_hostname"
|
||||
android:textColorHint="@drawable/login_input"
|
||||
app:boxStrokeColor="@drawable/login_input"
|
||||
app:hintTextColor="@drawable/login_input">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/hostname"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textUri"
|
||||
android:lines="1"
|
||||
android:textColor="@android:color/white" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
|
||||
android:id="@+id/username_field"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:hint="@string/login_username"
|
||||
android:textColorHint="@drawable/login_input"
|
||||
app:boxStrokeColor="@drawable/login_input"
|
||||
app:hintTextColor="@drawable/login_input">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/username"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textEmailAddress"
|
||||
android:lines="1"
|
||||
android:textColor="@android:color/white" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
|
||||
android:id="@+id/password_field"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:hint="@string/login_password"
|
||||
android:textColorHint="@drawable/login_input"
|
||||
app:boxStrokeColor="@drawable/login_input"
|
||||
app:hintTextColor="@drawable/login_input"
|
||||
app:passwordToggleEnabled="true">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/password"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword"
|
||||
android:lines="1"
|
||||
android:textColor="@android:color/white" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/login"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:backgroundTint="@color/colorAccent"
|
||||
android:text="@string/login_submit" />
|
||||
</LinearLayout>
|
44
app/src/main/res/layout/activity_main.xml
Normal file
@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginBottom="?attr/actionBarSize"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||
|
||||
<com.github.apognu.otter.views.NowPlayingView
|
||||
android:id="@+id/now_playing"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:layout_gravity="bottom"
|
||||
android:layout_margin="8dp"
|
||||
android:alpha="0"
|
||||
android:visibility="gone"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="12dp"
|
||||
app:layout_dodgeInsetEdges="bottom"
|
||||
tools:alpha="1"
|
||||
tools:visibility="visible">
|
||||
|
||||
<include layout="@layout/partial_now_playing" />
|
||||
|
||||
</com.github.apognu.otter.views.NowPlayingView>
|
||||
|
||||
<com.google.android.material.bottomappbar.BottomAppBar
|
||||
android:id="@+id/appbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:theme="@style/AppTheme.AppBar"
|
||||
app:backgroundTint="@color/colorPrimary"
|
||||
app:layout_insetEdge="bottom"
|
||||
app:navigationIcon="@drawable/ottericon"
|
||||
tools:menu="@menu/toolbar" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
68
app/src/main/res/layout/activity_search.xml
Normal file
@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
tools:context=".activities.SearchActivity">
|
||||
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
android:elevation="4dp">
|
||||
|
||||
<androidx.appcompat.widget.SearchView
|
||||
android:id="@+id/search"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:iconifiedByDefault="false"
|
||||
app:queryHint="@string/search_placeholder" />
|
||||
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/search_spinner"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginTop="16dp"
|
||||
android:indeterminate="true"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/search_empty"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:drawableTop="@drawable/ottericon"
|
||||
android:drawablePadding="16dp"
|
||||
android:drawableTint="#525252"
|
||||
android:text="@string/search_welcome"
|
||||
android:textAlignment="center"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/search_no_results"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:drawableTop="@drawable/ottericon"
|
||||
android:drawablePadding="16dp"
|
||||
android:drawableTint="#525252"
|
||||
android:text="@string/search_no_results"
|
||||
android:textAlignment="center"
|
||||
android:textSize="14sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/results"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:itemCount="10"
|
||||
tools:listitem="@layout/row_track" />
|
||||
|
||||
</LinearLayout>
|
21
app/src/main/res/layout/activity_settings.xml
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/surface"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
style="@style/AppTheme.Title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginVertical="16dp"
|
||||
android:text="@string/title_settings" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</LinearLayout>
|
15
app/src/main/res/layout/dialog_login.xml
Normal file
@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="32dp">
|
||||
|
||||
<ProgressBar
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:indeterminate="true"
|
||||
android:indeterminateTint="@color/colorAccent" />
|
||||
|
||||
</LinearLayout>
|
109
app/src/main/res/layout/fragment_albums.xml
Normal file
@ -0,0 +1,109 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/swiper"
|
||||
style="@style/AppTheme.Fragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/colorSurface"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false">
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/scroller"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/colorSurface"
|
||||
android:elevation="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cover"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="250dp"
|
||||
android:contentDescription="@string/alt_album_cover"
|
||||
android:scaleType="centerCrop"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_bias="0"
|
||||
tools:src="@tools:sample/avatars" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/albums"
|
||||
android:textAllCaps="true"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/artist"
|
||||
style="@style/AppTheme.Title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
tools:text="Muse" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/albums"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
tools:itemCount="10"
|
||||
tools:listitem="@layout/row_album" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
45
app/src/main/res/layout/fragment_albums_grid.xml
Normal file
@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/swiper"
|
||||
style="@style/AppTheme.Fragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false">
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/scroller"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
style="@style/AppTheme.Title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginVertical="16dp"
|
||||
android:text="@string/albums" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/albums"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:itemCount="10"
|
||||
tools:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
|
||||
tools:listitem="@layout/row_album_grid"
|
||||
tools:spanCount="3" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
48
app/src/main/res/layout/fragment_artists.xml
Normal file
@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/swiper"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
style="@style/AppTheme.Fragment">
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/scroller"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
style="@style/AppTheme.Title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginVertical="16dp"
|
||||
android:text="@string/artists" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/artists"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
tools:itemCount="10"
|
||||
tools:listitem="@layout/row_artist" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
26
app/src/main/res/layout/fragment_browse.xml
Normal file
@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.tabs.TabLayout
|
||||
android:id="@+id/tabs"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:elevation="4dp"
|
||||
app:tabMode="scrollable" />
|
||||
|
||||
<androidx.viewpager.widget.ViewPager
|
||||
android:id="@+id/pager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/colorSurface"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false" />
|
||||
|
||||
</LinearLayout>
|
59
app/src/main/res/layout/fragment_favorites.xml
Normal file
@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/swiper"
|
||||
style="@style/AppTheme.Fragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/scroller"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:clipChildren="false"
|
||||
android:orientation="vertical">
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:clipChildren="false">
|
||||
|
||||
<TextView
|
||||
style="@style/AppTheme.Title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginTop="64dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="@string/favorites" />
|
||||
|
||||
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
||||
android:id="@+id/play"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_centerHorizontal="true"
|
||||
android:layout_marginTop="16dp"
|
||||
android:backgroundTint="@color/colorPrimary"
|
||||
android:elevation="10dp"
|
||||
android:text="@string/playback_shuffle"
|
||||
android:textColor="@android:color/white"
|
||||
app:icon="@drawable/play"
|
||||
app:iconTint="@android:color/white" />
|
||||
|
||||
</RelativeLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/favorites"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:listitem="@layout/row_track" />
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
47
app/src/main/res/layout/fragment_playlists.xml
Normal file
@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/swiper"
|
||||
style="@style/AppTheme.Fragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false">
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/scroller"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
style="@style/AppTheme.Title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginVertical="16dp"
|
||||
android:text="@string/playlists" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/playlists"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
tools:itemCount="10"
|
||||
tools:listitem="@layout/row_playlist" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
45
app/src/main/res/layout/fragment_queue.xml
Normal file
@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingTop="16dp">
|
||||
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/queue"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:itemCount="10"
|
||||
tools:listitem="@layout/row_track" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/placeholder"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginVertical="64dp"
|
||||
android:drawableTop="@drawable/ottericon"
|
||||
android:drawablePadding="16dp"
|
||||
android:drawableTint="#525252"
|
||||
android:text="@string/playback_queue_empty"
|
||||
android:textAlignment="center"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
</FrameLayout>
|
206
app/src/main/res/layout/fragment_tracks.xml
Normal file
@ -0,0 +1,206 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/swiper"
|
||||
style="@style/AppTheme.Fragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:transitionGroup="true">
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/scroller"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/colorSurface"
|
||||
android:elevation="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cover"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="250dp"
|
||||
android:contentDescription="@string/alt_album_cover"
|
||||
android:scaleType="centerCrop"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_bias="0"
|
||||
tools:src="@tools:sample/avatars"
|
||||
tools:visibility="invisible" />
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/covers"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="250dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_bias="0"
|
||||
tools:visibility="visible">
|
||||
|
||||
<androidx.constraintlayout.widget.Guideline
|
||||
android:id="@+id/horizontal"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintGuide_percent=".50" />
|
||||
|
||||
<androidx.constraintlayout.widget.Guideline
|
||||
android:id="@+id/vertical"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
app:layout_constraintGuide_percent=".50" />
|
||||
|
||||
<com.github.apognu.otter.views.SquareImageView
|
||||
android:id="@+id/cover_top_left"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:scaleType="centerCrop"
|
||||
android:src="@drawable/cover"
|
||||
app:layout_constraintBottom_toBottomOf="@id/vertical"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="@id/horizontal"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:src="@tools:sample/avatars" />
|
||||
|
||||
<com.github.apognu.otter.views.SquareImageView
|
||||
android:id="@+id/cover_top_right"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:scaleType="centerCrop"
|
||||
android:src="@drawable/cover"
|
||||
app:layout_constraintBottom_toBottomOf="@id/vertical"
|
||||
app:layout_constraintLeft_toLeftOf="@id/horizontal"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:src="@tools:sample/avatars" />
|
||||
|
||||
<com.github.apognu.otter.views.SquareImageView
|
||||
android:id="@+id/cover_bottom_left"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:scaleType="centerCrop"
|
||||
android:src="@drawable/cover"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="@id/horizontal"
|
||||
app:layout_constraintTop_toTopOf="@id/vertical"
|
||||
tools:src="@tools:sample/avatars" />
|
||||
|
||||
<com.github.apognu.otter.views.SquareImageView
|
||||
android:id="@+id/cover_bottom_right"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:scaleType="centerCrop"
|
||||
android:src="@drawable/cover"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="@id/horizontal"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/vertical"
|
||||
tools:src="@tools:sample/avatars" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
||||
android:id="@+id/play"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:backgroundTint="@color/colorPrimary"
|
||||
android:elevation="10dp"
|
||||
android:text="@string/playback_shuffle"
|
||||
android:textColor="@android:color/white"
|
||||
app:icon="@drawable/play"
|
||||
app:iconTint="@android:color/white"
|
||||
app:layout_constraintBottom_toBottomOf="@id/cover"
|
||||
app:layout_constraintLeft_toLeftOf="@id/cover"
|
||||
app:layout_constraintRight_toRightOf="@id/cover"
|
||||
app:layout_constraintTop_toBottomOf="@id/cover" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/artist"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:textAllCaps="true"
|
||||
android:textSize="14sp"
|
||||
tools:text="Muse" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
style="@style/AppTheme.Title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
tools:text="Absolution" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/queue"
|
||||
style="@style/AppTheme.OutlinedButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:text="@string/playback_queue"
|
||||
app:icon="@drawable/add" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/tracks"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:itemCount="10"
|
||||
tools:listitem="@layout/row_track" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
218
app/src/main/res/layout/partial_now_playing.xml
Normal file
@ -0,0 +1,218 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/now_playing_root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/summary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/now_playing_progress"
|
||||
style="@android:style/Widget.Material.ProgressBar.Horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="-6dp"
|
||||
android:layout_marginBottom="-6dp"
|
||||
android:progress="40"
|
||||
android:progressTint="@color/colorPrimary" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="?attr/actionBarSize"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:layout_marginEnd="16dp">
|
||||
|
||||
<com.github.apognu.otter.views.SquareImageView
|
||||
android:id="@+id/now_playing_cover"
|
||||
android:layout_width="?attr/actionBarSize"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
tools:src="@tools:sample/avatars" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/now_playing_buffering"
|
||||
android:layout_width="?attr/actionBarSize"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:indeterminate="true"
|
||||
android:indeterminateTint="@color/controlForeground"
|
||||
android:visibility="gone" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_weight="2"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/now_playing_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/itemTitle"
|
||||
tools:text="Supermassive Black Hole" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/now_playing_album"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="Muse" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/now_playing_toggle"
|
||||
style="@style/AppTheme.OutlinedButton"
|
||||
android:layout_width="?attr/actionBarSize"
|
||||
android:layout_height="match_parent"
|
||||
app:icon="@drawable/play" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_next"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="?attr/actionBarSize"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@string/control_next"
|
||||
android:src="@drawable/next" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/now_playing_details"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<com.github.apognu.otter.views.SquareImageView
|
||||
android:id="@+id/now_playing_details_cover"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center"
|
||||
android:adjustViewBounds="true"
|
||||
android:src="@drawable/ottershape"
|
||||
tools:src="@tools:sample/avatars" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_details_favorite"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="56dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_margin="8dp"
|
||||
android:contentDescription="@string/alt_album_cover"
|
||||
android:src="@drawable/favorite" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/now_playing_details_controls"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="32dp"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="32dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/now_playing_details_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/itemTitle"
|
||||
android:textSize="18sp"
|
||||
tools:text="Supermassive Black Hole" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/now_playing_details_artist"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="Muse" />
|
||||
|
||||
<SeekBar
|
||||
android:id="@+id/now_playing_details_progress"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:max="100"
|
||||
android:progressBackgroundTint="#cacaca"
|
||||
android:progressTint="@color/controlForeground"
|
||||
android:thumbTint="@color/controlForeground" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/now_playing_details_progress_current"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/now_playing_details_progress_duration"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textAlignment="textEnd" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="64dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_details_previous"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="64dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:contentDescription="@string/control_previous"
|
||||
android:src="@drawable/previous" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/now_playing_details_toggle"
|
||||
style="@style/AppTheme.OutlinedButton"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="64dp"
|
||||
app:cornerRadius="64dp"
|
||||
app:icon="@drawable/play"
|
||||
app:iconSize="32dp" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/now_playing_details_next"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="64dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:contentDescription="@string/control_next"
|
||||
android:src="@drawable/next" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
7
app/src/main/res/layout/preference_category.xml
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@android:id/title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
android:textColor="@color/controlForeground" />
|
46
app/src/main/res/layout/row_album.xml
Normal file
@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:transitionGroup="true"
|
||||
tools:showIn="@layout/fragment_albums">
|
||||
|
||||
<com.github.apognu.otter.views.SquareImageView
|
||||
android:id="@+id/art"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
tools:src="@tools:sample/avatars" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
style="@style/AppTheme.ItemTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:ellipsize="end"
|
||||
android:lines="1"
|
||||
tools:text="Absolution" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/artist"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:lines="1"
|
||||
tools:text="Muse" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
28
app/src/main/res/layout/row_album_grid.xml
Normal file
@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp"
|
||||
android:transitionGroup="true"
|
||||
tools:showIn="@layout/fragment_albums_grid">
|
||||
|
||||
<com.github.apognu.otter.views.SquareImageView
|
||||
android:id="@+id/cover"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
tools:src="@tools:sample/avatars" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:lines="1"
|
||||
android:textAlignment="center"
|
||||
tools:text="Black holes and revelations" />
|
||||
|
||||
</LinearLayout>
|
47
app/src/main/res/layout/row_artist.xml
Normal file
@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:transitionGroup="true"
|
||||
tools:showIn="@layout/fragment_artists">
|
||||
|
||||
<com.github.apognu.otter.views.SquareImageView
|
||||
android:id="@+id/art"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:scaleType="centerCrop"
|
||||
tools:src="@tools:sample/avatars" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/name"
|
||||
style="@style/AppTheme.ItemTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:ellipsize="end"
|
||||
android:lines="1"
|
||||
tools:text="Muse" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/albums"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:lines="1"
|
||||
tools:text="2 album(s)" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
24
app/src/main/res/layout/row_licence.xml
Normal file
@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp"
|
||||
tools:showIn="@layout/activity_licences">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/name"
|
||||
style="@style/AppTheme.ItemTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="Super library" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/licence"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="MIT License" />
|
||||
|
||||
</LinearLayout>
|
114
app/src/main/res/layout/row_playlist.xml
Normal file
@ -0,0 +1,114 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:transitionGroup="true"
|
||||
tools:showIn="@layout/fragment_playlists">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/covers"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintDimensionRatio="1:1"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<androidx.constraintlayout.widget.Guideline
|
||||
android:id="@+id/horizontal"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintGuide_percent=".50" />
|
||||
|
||||
<androidx.constraintlayout.widget.Guideline
|
||||
android:id="@+id/vertical"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
app:layout_constraintGuide_percent=".50" />
|
||||
|
||||
<com.github.apognu.otter.views.SquareImageView
|
||||
android:id="@+id/cover_top_left"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:src="@drawable/cover"
|
||||
app:layout_constraintBottom_toBottomOf="@id/vertical"
|
||||
app:layout_constraintDimensionRatio="1:1"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="@id/horizontal"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:src="@tools:sample/avatars" />
|
||||
|
||||
<com.github.apognu.otter.views.SquareImageView
|
||||
android:id="@+id/cover_top_right"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:src="@drawable/cover"
|
||||
app:layout_constraintBottom_toBottomOf="@id/vertical"
|
||||
app:layout_constraintDimensionRatio="1:1"
|
||||
app:layout_constraintLeft_toLeftOf="@id/horizontal"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:src="@tools:sample/avatars" />
|
||||
|
||||
<com.github.apognu.otter.views.SquareImageView
|
||||
android:id="@+id/cover_bottom_left"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:src="@drawable/cover"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintDimensionRatio="1:1"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="@id/horizontal"
|
||||
app:layout_constraintTop_toTopOf="@id/vertical"
|
||||
tools:src="@tools:sample/avatars" />
|
||||
|
||||
<com.github.apognu.otter.views.SquareImageView
|
||||
android:id="@+id/cover_bottom_right"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:src="@drawable/cover"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintDimensionRatio="1:1"
|
||||
app:layout_constraintLeft_toLeftOf="@id/horizontal"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/vertical"
|
||||
tools:src="@tools:sample/avatars" />
|
||||
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toRightOf="@id/covers"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/name"
|
||||
style="@style/AppTheme.ItemTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="4dp"
|
||||
tools:text="Waking up playlist" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/summary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="103 tracks • 1h58" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
72
app/src/main/res/layout/row_track.xml
Normal file
@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:transitionGroup="true"
|
||||
tools:showIn="@layout/fragment_tracks">
|
||||
|
||||
<com.github.apognu.otter.views.SquareImageView
|
||||
android:id="@+id/handle"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:src="@drawable/reorder"
|
||||
android:tint="#787878"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<com.github.apognu.otter.views.SquareImageView
|
||||
android:id="@+id/cover"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
tools:src="@tools:sample/avatars" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
style="@style/AppTheme.ItemTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:ellipsize="end"
|
||||
android:lines="1"
|
||||
tools:text="Absolution" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/artist"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:lines="1"
|
||||
tools:text="Muse" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/favorite"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:contentDescription="@string/manage_add_to_favorites"
|
||||
android:src="@drawable/favorite" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/actions"
|
||||
style="@style/IconButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:src="@drawable/more" />
|
||||
|
||||
</LinearLayout>
|
8
app/src/main/res/menu/row_queue.xml
Normal file
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item
|
||||
android:id="@+id/queue_remove"
|
||||
android:title="@string/playback_queue_remove_item" />
|
||||
|
||||
</menu>
|
16
app/src/main/res/menu/row_track.xml
Normal file
@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item
|
||||
android:id="@+id/track_add_to_queue"
|
||||
android:title="@string/playback_queue_add_item" />
|
||||
|
||||
<item
|
||||
android:id="@+id/track_play_next"
|
||||
android:title="@string/playback_queue_play_next" />
|
||||
|
||||
<item
|
||||
android:id="@+id/track_add_toçplaylist"
|
||||
android:title="@string/manage_add_to_playlist" />
|
||||
|
||||
</menu>
|
31
app/src/main/res/menu/toolbar.xml
Normal file
@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/nav_queue"
|
||||
android:icon="@drawable/queue"
|
||||
android:title="@string/playback_queue"
|
||||
app:showAsAction="ifRoom" />
|
||||
|
||||
<item
|
||||
android:id="@+id/cast"
|
||||
android:iconTint="@android:color/white"
|
||||
android:title="@string/toolbar_cast"
|
||||
app:actionProviderClass="androidx.mediarouter.app.MediaRouteActionProvider"
|
||||
app:showAsAction="ifRoom" />
|
||||
|
||||
<item
|
||||
android:id="@+id/nav_search"
|
||||
android:icon="@drawable/search"
|
||||
android:title="@string/toolbar_search"
|
||||
app:showAsAction="ifRoom" />
|
||||
|
||||
<item
|
||||
android:id="@+id/settings"
|
||||
android:icon="@drawable/settings"
|
||||
android:iconTint="@android:color/white"
|
||||
android:title="@string/title_settings"
|
||||
app:showAsAction="never" />
|
||||
|
||||
</menu>
|
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 2.1 KiB |