@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
*.iml |
||||
.gradle |
||||
/local.properties |
||||
/.idea |
||||
.DS_Store |
||||
/build |
||||
/captures |
||||
.externalNativeBuild |
@ -0,0 +1,21 @@
@@ -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. |
@ -0,0 +1,61 @@
@@ -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' |
||||
} |
@ -0,0 +1,21 @@
@@ -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 |
@ -0,0 +1,45 @@
@@ -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> |
@ -0,0 +1,17 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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)) |
||||
} |
||||
} |
@ -0,0 +1,90 @@
@@ -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 |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,117 @@
@@ -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 @@
@@ -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 |
||||
} |
||||
} |
@ -0,0 +1,113 @@
@@ -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) |
@ -0,0 +1,26 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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) |
||||
} |
||||
} |
After Width: | Height: | Size: 696 B |
After Width: | Height: | Size: 469 B |
After Width: | Height: | Size: 946 B |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 1.9 KiB |
@ -0,0 +1,9 @@
@@ -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> |
After Width: | Height: | Size: 12 KiB |
@ -0,0 +1,9 @@
@@ -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> |
@ -0,0 +1,8 @@
@@ -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> |
@ -0,0 +1,9 @@
@@ -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> |
@ -0,0 +1,9 @@
@@ -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> |
After Width: | Height: | Size: 54 KiB |
@ -0,0 +1,9 @@
@@ -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> |
@ -0,0 +1,9 @@
@@ -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> |
@ -0,0 +1,9 @@
@@ -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> |
@ -0,0 +1,9 @@
@@ -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> |
@ -0,0 +1,9 @@
@@ -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> |
@ -0,0 +1,9 @@
@@ -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> |
@ -0,0 +1,9 @@
@@ -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> |
@ -0,0 +1,31 @@
@@ -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> |
@ -0,0 +1,97 @@
@@ -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> |
@ -0,0 +1,44 @@
@@ -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> |
@ -0,0 +1,68 @@
@@ -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> |
@ -0,0 +1,21 @@
@@ -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> |
@ -0,0 +1,15 @@
@@ -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> |
@ -0,0 +1,109 @@
@@ -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> |
@ -0,0 +1,45 @@
@@ -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> |
@ -0,0 +1,48 @@
@@ -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> |
@ -0,0 +1,26 @@
@@ -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> |
@ -0,0 +1,59 @@
@@ -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> |
@ -0,0 +1,47 @@
@@ -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> |
@ -0,0 +1,45 @@
@@ -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> |
@ -0,0 +1,206 @@
@@ -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> |
@ -0,0 +1,218 @@
@@ -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> |
@ -0,0 +1,7 @@
@@ -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" /> |
@ -0,0 +1,46 @@
@@ -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> |
@ -0,0 +1,28 @@
@@ -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> |
@ -0,0 +1,47 @@
@@ -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> |
@ -0,0 +1,24 @@
@@ -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> |
@ -0,0 +1,114 @@
@@ -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> |
@ -0,0 +1,72 @@
@@ -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> |
@ -0,0 +1,8 @@
@@ -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> |
@ -0,0 +1,16 @@
@@ -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> |
@ -0,0 +1,31 @@
@@ -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> |
@ -0,0 +1,5 @@
@@ -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> |
@ -0,0 +1,5 @@
@@ -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> |
After Width: | Height: | Size: 2.1 KiB |