commit 5f495f54e5f08e18d1085eacf436ca279aa5a4b1 Author: Antoine POPINEAU Date: Mon Aug 19 16:50:33 2019 +0200 Initial commit. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09b993d --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8b0beb2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Antoine POPINEAU + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..67e07b8 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,2 @@ +/build +/release diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..99a72d7 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,61 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 + } + + compileSdkVersion 29 + + defaultConfig { + applicationId "com.github.apognu.otter" + minSdkVersion 23 + targetSdkVersion 29 + versionCode 4 + versionName "1.0.3" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.2' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.2' + + implementation 'androidx.appcompat:appcompat:1.1.0' + implementation 'androidx.core:core-ktx:1.2.0-beta01' + implementation 'androidx.coordinatorlayout:coordinatorlayout:1.0.0' + implementation 'androidx.preference:preference:1.1.0' + implementation 'androidx.recyclerview:recyclerview:1.0.0' + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.0.0' + implementation 'com.google.android.material:material:1.1.0-beta01' + implementation 'com.android.support.constraint:constraint-layout:1.1.3' + + implementation 'com.google.android.exoplayer:exoplayer:2.10.3' + implementation 'com.google.android.exoplayer:extension-mediasession:2.10.6' + implementation 'com.google.android.exoplayer:extension-cast:2.10.6' + implementation 'com.aliassadi:power-preference-lib:1.4.1' + implementation 'com.github.kittinunf.fuel:fuel:2.1.0' + implementation 'com.github.kittinunf.fuel:fuel-coroutines:2.1.0' + implementation 'com.github.kittinunf.fuel:fuel-android:2.1.0' + implementation 'com.github.kittinunf.fuel:fuel-gson:2.1.0' + implementation 'com.google.code.gson:gson:2.8.5' + implementation 'com.squareup.picasso:picasso:2.71828' + implementation 'jp.wasabeef:picasso-transformations:2.2.1' +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a289d53 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/Otter.kt b/app/src/main/java/com/github/apognu/otter/Otter.kt new file mode 100644 index 0000000..4632159 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/Otter.kt @@ -0,0 +1,17 @@ +package com.github.apognu.otter + +import android.app.Application +import androidx.appcompat.app.AppCompatDelegate +import com.preference.PowerPreference + +class Otter : Application() { + override fun onCreate() { + super.onCreate() + + when (PowerPreference.getDefaultFile().getString("night_mode")) { + "on" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + "off" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + else -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/activities/LicencesActivity.kt b/app/src/main/java/com/github/apognu/otter/activities/LicencesActivity.kt new file mode 100644 index 0000000..1433ec0 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/activities/LicencesActivity.kt @@ -0,0 +1,102 @@ +package com.github.apognu.otter.activities + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.github.apognu.otter.R +import kotlinx.android.synthetic.main.activity_licences.* +import kotlinx.android.synthetic.main.row_licence.view.* + +class LicencesActivity : AppCompatActivity() { + data class Licence(val name: String, val licence: String, val url: String) + + interface OnLicenceClickListener { + fun onClick(url: String) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(R.layout.activity_licences) + + LicencesAdapter(OnLicenceClick()).also { + licences.layoutManager = LinearLayoutManager(this) + licences.adapter = it + } + } + + private inner class LicencesAdapter(val listener: OnLicenceClickListener) : RecyclerView.Adapter() { + 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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/activities/LoginActivity.kt b/app/src/main/java/com/github/apognu/otter/activities/LoginActivity.kt new file mode 100644 index 0000000..6d5a540 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/activities/LoginActivity.kt @@ -0,0 +1,97 @@ +package com.github.apognu.otter.activities + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.appcompat.app.AppCompatActivity +import com.github.apognu.otter.R +import com.github.apognu.otter.fragments.LoginDialog +import com.github.apognu.otter.utils.AppContext +import com.github.apognu.otter.utils.log +import com.github.kittinunf.fuel.Fuel +import com.github.kittinunf.fuel.coroutines.awaitObjectResult +import com.github.kittinunf.fuel.gson.gsonDeserializerOf +import com.preference.PowerPreference +import kotlinx.android.synthetic.main.activity_login.* +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +data class FwCredentials(val token: String) + +class LoginActivity : AppCompatActivity() { + override fun onResume() { + super.onResume() + + getSharedPreferences(AppContext.PREFS_CREDENTIALS, Context.MODE_PRIVATE).apply { + when (contains("access_token")) { + true -> Intent(this@LoginActivity, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NO_ANIMATION + + startActivity(this) + } + + false -> setContentView(R.layout.activity_login) + } + } + + login?.setOnClickListener { + val hostname = hostname.text.toString().trim() + val username = username.text.toString() + val password = password.text.toString() + + try { + if (hostname.isEmpty()) throw Exception(getString(R.string.login_error_hostname)) + + val url = Uri.parse(hostname) + + if (url.scheme != "https") { + throw Exception(getString(R.string.login_error_hostname_https)) + } + } catch (e: Exception) { + val message = + if (e.message?.isEmpty() == true) getString(R.string.login_error_hostname) + else e.message + + hostname_field.error = message + + return@setOnClickListener + } + + hostname_field.error = "" + + val body = mapOf( + "username" to username, + "password" to password + ).toList() + + val dialog = LoginDialog().apply { + show(supportFragmentManager, "LoginDialog") + } + + GlobalScope.launch(Main) { + val result = Fuel.post("$hostname/api/v1/token", body) + .awaitObjectResult(gsonDeserializerOf(FwCredentials::class.java)) + + result.fold( + { data -> + PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply { + setString("hostname", hostname) + setString("username", username) + setString("password", password) + setString("access_token", data.token) + } + + dialog.dismiss() + startActivity(Intent(this@LoginActivity, MainActivity::class.java)) + }, + { error -> + dialog.dismiss() + + hostname_field.error = error.localizedMessage + } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/activities/MainActivity.kt b/app/src/main/java/com/github/apognu/otter/activities/MainActivity.kt new file mode 100644 index 0000000..427373b --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/activities/MainActivity.kt @@ -0,0 +1,303 @@ +package com.github.apognu.otter.activities + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.SeekBar +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import com.github.apognu.otter.R +import com.github.apognu.otter.fragments.BrowseFragment +import com.github.apognu.otter.fragments.QueueFragment +import com.github.apognu.otter.playback.MediaControlsManager +import com.github.apognu.otter.playback.PlayerService +import com.github.apognu.otter.repositories.FavoritesRepository +import com.github.apognu.otter.repositories.Repository +import com.github.apognu.otter.utils.* +import com.preference.PowerPreference +import com.squareup.picasso.Picasso +import kotlinx.android.synthetic.main.activity_main.* +import kotlinx.android.synthetic.main.partial_now_playing.* +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +class MainActivity : AppCompatActivity() { + private val favoriteRepository = FavoritesRepository(this) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + AppContext.init(this) + + setContentView(R.layout.activity_main) + setSupportActionBar(appbar) + + when (intent.action) { + MediaControlsManager.NOTIFICATION_ACTION_OPEN_QUEUE.toString() -> launchDialog(QueueFragment()) + } + + supportFragmentManager + .beginTransaction() + .replace(R.id.container, BrowseFragment()) + .commit() + + startService(Intent(this, PlayerService::class.java)) + + watchEventBus() + + CommandBus.send(Command.RefreshService) + } + + override fun onResume() { + super.onResume() + + now_playing_toggle.setOnClickListener { + CommandBus.send(Command.ToggleState) + } + + now_playing_next.setOnClickListener { + CommandBus.send(Command.NextTrack) + } + + now_playing_details_previous.setOnClickListener { + CommandBus.send(Command.PreviousTrack) + } + + now_playing_details_next.setOnClickListener { + CommandBus.send(Command.NextTrack) + } + + now_playing_details_toggle.setOnClickListener { + CommandBus.send(Command.ToggleState) + } + + now_playing_details_progress.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onStopTrackingTouch(view: SeekBar?) {} + + override fun onStartTrackingTouch(view: SeekBar?) {} + + override fun onProgressChanged(view: SeekBar?, progress: Int, fromUser: Boolean) { + if (fromUser) { + CommandBus.send(Command.Seek(progress)) + } + } + }) + } + + override fun onBackPressed() { + if (now_playing.isOpened()) { + now_playing.close() + return + } + + super.onBackPressed() + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.toolbar, menu) + + // CastButtonFactory.setUpMediaRouteButton(this, menu, R.id.cast) + + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + now_playing.close() + + (supportFragmentManager.fragments.last() as? BrowseFragment)?.let { + it.selectTabAt(0) + + return true + } + + launchFragment(BrowseFragment()) + } + + R.id.nav_queue -> launchDialog(QueueFragment()) + R.id.nav_search -> startActivity(Intent(this, SearchActivity::class.java)) + R.id.settings -> startActivity(Intent(this, SettingsActivity::class.java)) + } + + return true + } + + private fun launchFragment(fragment: Fragment) { + supportFragmentManager.fragments.lastOrNull()?.also { oldFragment -> + oldFragment.enterTransition = null + oldFragment.exitTransition = null + + supportFragmentManager.popBackStackImmediate(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + } + + supportFragmentManager + .beginTransaction() + .setCustomAnimations(0, 0, 0, 0) + .replace(R.id.container, fragment) + .commit() + } + + private fun launchDialog(fragment: DialogFragment) { + supportFragmentManager.beginTransaction().let { + fragment.show(it, "") + } + } + + @SuppressLint("NewApi") + private fun watchEventBus() { + GlobalScope.launch(Main) { + for (message in EventBus.asChannel()) { + 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) + } + } + } +} diff --git a/app/src/main/java/com/github/apognu/otter/activities/SearchActivity.kt b/app/src/main/java/com/github/apognu/otter/activities/SearchActivity.kt new file mode 100644 index 0000000..c7fd556 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/activities/SearchActivity.kt @@ -0,0 +1,66 @@ +package com.github.apognu.otter.activities + +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.LinearLayoutManager +import com.github.apognu.otter.R +import com.github.apognu.otter.adapters.TracksAdapter +import com.github.apognu.otter.repositories.Repository +import com.github.apognu.otter.repositories.SearchRepository +import com.github.apognu.otter.utils.untilNetwork +import kotlinx.android.synthetic.main.activity_search.* + +class SearchActivity : AppCompatActivity() { + private lateinit var adapter: TracksAdapter + + lateinit var repository: SearchRepository + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(R.layout.activity_search) + + adapter = TracksAdapter(this).also { + results.layoutManager = LinearLayoutManager(this) + results.adapter = it + } + } + + override fun onResume() { + super.onResume() + + search.requestFocus() + + search.setOnQueryTextListener(object : androidx.appcompat.widget.SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + query?.let { + repository = SearchRepository(this@SearchActivity, it.toLowerCase()) + + search_spinner.visibility = View.VISIBLE + search_no_results.visibility = View.GONE + + adapter.data.clear() + adapter.notifyDataSetChanged() + + repository.fetch(Repository.Origin.Network.origin).untilNetwork { tracks -> + search_spinner.visibility = View.GONE + search_empty.visibility = View.GONE + + when (tracks.isEmpty()) { + true -> search_no_results.visibility = View.VISIBLE + false -> adapter.data = tracks.toMutableList() + } + + adapter.notifyDataSetChanged() + } + } + + return true + } + + override fun onQueryTextChange(newText: String?) = true + + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/activities/SettingsActivity.kt b/app/src/main/java/com/github/apognu/otter/activities/SettingsActivity.kt new file mode 100644 index 0000000..8138344 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/activities/SettingsActivity.kt @@ -0,0 +1,121 @@ +package com.github.apognu.otter.activities + +import android.content.Intent +import android.content.SharedPreferences +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.preference.ListPreference +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.SeekBarPreference +import com.github.apognu.otter.R +import com.github.apognu.otter.utils.AppContext +import com.preference.PowerPreference + +class SettingsActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(R.layout.activity_settings) + + supportFragmentManager + .beginTransaction() + .replace( + R.id.container, + SettingsFragment() + ) + .commit() + } + + fun getThemeResId(): Int = R.style.AppTheme +} + +class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedPreferenceChangeListener { + override fun onResume() { + super.onResume() + + preferenceScreen.sharedPreferences.registerOnSharedPreferenceChangeListener(this) + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.settings, rootKey) + + updateValues() + } + + override fun onPreferenceTreeClick(preference: Preference?): Boolean { + when (preference?.key) { + "oss_licences" -> startActivity(Intent(activity, LicencesActivity::class.java)) + "logout" -> { + context?.let { context -> + AlertDialog.Builder(context) + .setTitle(context.getString(R.string.logout_title)) + .setMessage(context.getString(R.string.logout_content)) + .setPositiveButton(android.R.string.yes) { _, _ -> + PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).clear() + + Intent(context, LoginActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + + startActivity(this) + activity?.finish() + } + } + .setNegativeButton(android.R.string.no, null) + .show() + } + } + } + + updateValues() + + return super.onPreferenceTreeClick(preference) + } + + override fun onSharedPreferenceChanged(preferences: SharedPreferences?, key: String?) { + updateValues() + } + + private fun updateValues() { + (activity as? AppCompatActivity)?.let { activity -> + preferenceManager.findPreference("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("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("media_cache_size")?.let { + it.summary = getString(R.string.settings_media_cache_size_summary, it.value) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/adapters/AlbumsAdapter.kt b/app/src/main/java/com/github/apognu/otter/adapters/AlbumsAdapter.kt new file mode 100644 index 0000000..f82d1b9 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/adapters/AlbumsAdapter.kt @@ -0,0 +1,55 @@ +package com.github.apognu.otter.adapters + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.github.apognu.otter.R +import com.github.apognu.otter.fragments.FunkwhaleAdapter +import com.github.apognu.otter.utils.Album +import com.github.apognu.otter.utils.normalizeUrl +import com.squareup.picasso.Picasso +import jp.wasabeef.picasso.transformations.RoundedCornersTransformation +import kotlinx.android.synthetic.main.row_album.view.* +import kotlinx.android.synthetic.main.row_artist.view.art + +class AlbumsAdapter(val context: Context?, val listener: OnAlbumClickListener) : FunkwhaleAdapter() { + 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]) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/adapters/AlbumsGridAdapter.kt b/app/src/main/java/com/github/apognu/otter/adapters/AlbumsGridAdapter.kt new file mode 100644 index 0000000..afbc227 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/adapters/AlbumsGridAdapter.kt @@ -0,0 +1,52 @@ +package com.github.apognu.otter.adapters + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.github.apognu.otter.R +import com.github.apognu.otter.fragments.FunkwhaleAdapter +import com.github.apognu.otter.utils.Album +import com.github.apognu.otter.utils.normalizeUrl +import com.squareup.picasso.Picasso +import jp.wasabeef.picasso.transformations.RoundedCornersTransformation +import kotlinx.android.synthetic.main.row_album_grid.view.* + +class AlbumsGridAdapter(val context: Context?, val listener: OnAlbumClickListener) : FunkwhaleAdapter() { + 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]) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/adapters/ArtistsAdapter.kt b/app/src/main/java/com/github/apognu/otter/adapters/ArtistsAdapter.kt new file mode 100644 index 0000000..ecb2859 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/adapters/ArtistsAdapter.kt @@ -0,0 +1,65 @@ +package com.github.apognu.otter.adapters + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.github.apognu.otter.R +import com.github.apognu.otter.fragments.FunkwhaleAdapter +import com.github.apognu.otter.utils.Artist +import com.github.apognu.otter.utils.normalizeUrl +import com.squareup.picasso.Picasso +import jp.wasabeef.picasso.transformations.RoundedCornersTransformation +import kotlinx.android.synthetic.main.row_artist.view.* + +class ArtistsAdapter(val context: Context?, val listener: OnArtistClickListener) : FunkwhaleAdapter() { + 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]) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/adapters/BrowseTabsAdapter.kt b/app/src/main/java/com/github/apognu/otter/adapters/BrowseTabsAdapter.kt new file mode 100644 index 0000000..e554667 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/adapters/BrowseTabsAdapter.kt @@ -0,0 +1,44 @@ +package com.github.apognu.otter.adapters + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentPagerAdapter +import com.github.apognu.otter.R +import com.github.apognu.otter.fragments.AlbumsGridFragment +import com.github.apognu.otter.fragments.ArtistsFragment +import com.github.apognu.otter.fragments.FavoritesFragment +import com.github.apognu.otter.fragments.PlaylistsFragment + +class BrowseTabsAdapter(val context: Fragment, manager: FragmentManager) : FragmentPagerAdapter(manager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + var tabs = mutableListOf() + + 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 -> "" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/adapters/FavoritesAdapter.kt b/app/src/main/java/com/github/apognu/otter/adapters/FavoritesAdapter.kt new file mode 100644 index 0000000..8fceb78 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/adapters/FavoritesAdapter.kt @@ -0,0 +1,183 @@ +package com.github.apognu.otter.adapters + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.ColorDrawable +import android.os.Build +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.PopupMenu +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.github.apognu.otter.R +import com.github.apognu.otter.fragments.FunkwhaleAdapter +import com.github.apognu.otter.utils.* +import com.squareup.picasso.Picasso +import jp.wasabeef.picasso.transformations.RoundedCornersTransformation +import kotlinx.android.synthetic.main.row_track.view.* +import java.util.* + +class FavoritesAdapter(private val context: Context?, val favoriteListener: OnFavoriteListener, val fromQueue: Boolean = false) : FunkwhaleAdapter() { + 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) + } + } +} diff --git a/app/src/main/java/com/github/apognu/otter/adapters/PlaylistTracksAdapter.kt b/app/src/main/java/com/github/apognu/otter/adapters/PlaylistTracksAdapter.kt new file mode 100644 index 0000000..0229ee0 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/adapters/PlaylistTracksAdapter.kt @@ -0,0 +1,179 @@ +package com.github.apognu.otter.adapters + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.ColorDrawable +import android.os.Build +import android.view.* +import androidx.appcompat.widget.PopupMenu +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.github.apognu.otter.R +import com.github.apognu.otter.fragments.FunkwhaleAdapter +import com.github.apognu.otter.utils.* +import com.squareup.picasso.Picasso +import jp.wasabeef.picasso.transformations.RoundedCornersTransformation +import kotlinx.android.synthetic.main.row_track.view.* +import java.util.* + +class PlaylistTracksAdapter(private val context: Context?, val fromQueue: Boolean = false) : FunkwhaleAdapter() { + 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) + } + } +} diff --git a/app/src/main/java/com/github/apognu/otter/adapters/PlaylistsAdapter.kt b/app/src/main/java/com/github/apognu/otter/adapters/PlaylistsAdapter.kt new file mode 100644 index 0000000..ed410dd --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/adapters/PlaylistsAdapter.kt @@ -0,0 +1,65 @@ +package com.github.apognu.otter.adapters + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.github.apognu.otter.R +import com.github.apognu.otter.fragments.FunkwhaleAdapter +import com.github.apognu.otter.utils.Playlist +import com.squareup.picasso.Picasso +import kotlinx.android.synthetic.main.row_playlist.view.* + +class PlaylistsAdapter(val context: Context?, val listener: OnPlaylistClickListener) : FunkwhaleAdapter() { + 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]) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/adapters/SearchResultsAdapter.kt b/app/src/main/java/com/github/apognu/otter/adapters/SearchResultsAdapter.kt new file mode 100644 index 0000000..aa25302 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/adapters/SearchResultsAdapter.kt @@ -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() { + var tracks: List = 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/adapters/TracksAdapter.kt b/app/src/main/java/com/github/apognu/otter/adapters/TracksAdapter.kt new file mode 100644 index 0000000..7079e92 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/adapters/TracksAdapter.kt @@ -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() { + 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) + } + } +} diff --git a/app/src/main/java/com/github/apognu/otter/fragments/AlbumsFragment.kt b/app/src/main/java/com/github/apognu/otter/fragments/AlbumsFragment.kt new file mode 100644 index 0000000..de746b8 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/fragments/AlbumsFragment.kt @@ -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() { + 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() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/fragments/AlbumsGridFragment.kt b/app/src/main/java/com/github/apognu/otter/fragments/AlbumsGridFragment.kt new file mode 100644 index 0000000..c463d97 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/fragments/AlbumsGridFragment.kt @@ -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() { + 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() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/fragments/ArtistsFragment.kt b/app/src/main/java/com/github/apognu/otter/fragments/ArtistsFragment.kt new file mode 100644 index 0000000..46884b0 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/fragments/ArtistsFragment.kt @@ -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() { + 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() + } + } + } +} diff --git a/app/src/main/java/com/github/apognu/otter/fragments/BrowseFragment.kt b/app/src/main/java/com/github/apognu/otter/fragments/BrowseFragment.kt new file mode 100644 index 0000000..33eec35 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/fragments/BrowseFragment.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/fragments/FavoritesFragment.kt b/app/src/main/java/com/github/apognu/otter/fragments/FavoritesFragment.kt new file mode 100644 index 0000000..dc72bfe --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/fragments/FavoritesFragment.kt @@ -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() { + 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()?.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()) { + when (message) { + is Event.TrackPlayed -> { + GlobalScope.launch(Main) { + RequestBus.send(Request.GetCurrentTrack).wait()?.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) + } + } + + } +} diff --git a/app/src/main/java/com/github/apognu/otter/fragments/FunkwhaleFragment.kt b/app/src/main/java/com/github/apognu/otter/fragments/FunkwhaleFragment.kt new file mode 100644 index 0000000..3f725c5 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/fragments/FunkwhaleFragment.kt @@ -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 : RecyclerView.Adapter() { + var data: MutableList = mutableListOf() +} + +abstract class FunkwhaleFragment> : Fragment() { + abstract val viewRes: Int + abstract val recycler: RecyclerView + open val layoutManager: RecyclerView.LayoutManager get() = LinearLayoutManager(context) + + lateinit var repository: Repository + 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) {} +} diff --git a/app/src/main/java/com/github/apognu/otter/fragments/LoginDialog.kt b/app/src/main/java/com/github/apognu/otter/fragments/LoginDialog.kt new file mode 100644 index 0000000..4fd4682 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/fragments/LoginDialog.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/fragments/PlaylistTracksFragment.kt b/app/src/main/java/com/github/apognu/otter/fragments/PlaylistTracksFragment.kt new file mode 100644 index 0000000..505b5b7 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/fragments/PlaylistTracksFragment.kt @@ -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() { + 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()?.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) { + 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()) { + when (message) { + is Event.TrackPlayed -> { + GlobalScope.launch(Main) { + RequestBus.send(Request.GetCurrentTrack).wait()?.let { response -> + adapter.currentTrack = response.track + adapter.notifyDataSetChanged() + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/fragments/PlaylistsFragment.kt b/app/src/main/java/com/github/apognu/otter/fragments/PlaylistsFragment.kt new file mode 100644 index 0000000..b24e925 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/fragments/PlaylistsFragment.kt @@ -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() { + 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() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/fragments/QueueFragment.kt b/app/src/main/java/com/github/apognu/otter/fragments/QueueFragment.kt new file mode 100644 index 0000000..8f2a7e5 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/fragments/QueueFragment.kt @@ -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(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()?.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()) { + when (message) { + is Event.TrackPlayed -> refresh() + is Event.QueueChanged -> refresh() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/fragments/TracksFragment.kt b/app/src/main/java/com/github/apognu/otter/fragments/TracksFragment.kt new file mode 100644 index 0000000..3b19c08 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/fragments/TracksFragment.kt @@ -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() { + 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()?.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()) { + when (message) { + is Event.TrackPlayed -> { + GlobalScope.launch(Main) { + RequestBus.send(Request.GetCurrentTrack).wait()?.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) + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/playback/MediaControlsManager.kt b/app/src/main/java/com/github/apognu/otter/playback/MediaControlsManager.kt new file mode 100644 index 0000000..1855ebc --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/playback/MediaControlsManager.kt @@ -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 + ) + } + } +} diff --git a/app/src/main/java/com/github/apognu/otter/playback/PlayerService.kt b/app/src/main/java/com/github/apognu/otter/playback/PlayerService.kt new file mode 100644 index 0000000..31ff816 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/playback/PlayerService.kt @@ -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() + + 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(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()) { + 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 { + 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 + } + } + } + } +} diff --git a/app/src/main/java/com/github/apognu/otter/playback/QueueManager.kt b/app/src/main/java/com/github/apognu/otter/playback/QueueManager.kt new file mode 100644 index 0000000..0d9d59e --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/playback/QueueManager.kt @@ -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 = 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) { + 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) { + 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/repositories/AlbumsRepository.kt b/app/src/main/java/com/github/apognu/otter/repositories/AlbumsRepository.kt new file mode 100644 index 0000000..fbbff60 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/repositories/AlbumsRepository.kt @@ -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() { + override val cacheId: String by lazy { + if (artistId == null) "albums" + else "albums-artist-$artistId" + } + + override val upstream: Upstream by lazy { + val url = + if (artistId == null) "/api/v1/albums?playable=true" + else "/api/v1/albums?playable=true&artist=$artistId" + + HttpUpstream>( + HttpUpstream.Behavior.Progressive, + url, + object : TypeToken() {}.type + ) + } + + override fun cache(data: List) = AlbumsCache(data) + override fun uncache(reader: BufferedReader) = gsonDeserializerOf(AlbumsCache::class.java).deserialize(reader) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/repositories/ArtistsRepository.kt b/app/src/main/java/com/github/apognu/otter/repositories/ArtistsRepository.kt new file mode 100644 index 0000000..3676a67 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/repositories/ArtistsRepository.kt @@ -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() { + override val cacheId = "artists" + override val upstream = HttpUpstream>(HttpUpstream.Behavior.Progressive, "/api/v1/artists?playable=true", object : TypeToken() {}.type) + + override fun cache(data: List) = ArtistsCache(data) + override fun uncache(reader: BufferedReader) = gsonDeserializerOf(ArtistsCache::class.java).deserialize(reader) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/repositories/FavoritesRepository.kt b/app/src/main/java/com/github/apognu/otter/repositories/FavoritesRepository.kt new file mode 100644 index 0000000..8cf7d29 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/repositories/FavoritesRepository.kt @@ -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() { + override val cacheId = "favorites" + override val upstream = HttpUpstream>(HttpUpstream.Behavior.AtOnce, "/api/v1/favorites/tracks?playable=true", object : TypeToken() {}.type) + + override fun cache(data: List) = FavoritesCache(data) + override fun uncache(reader: BufferedReader) = gsonDeserializerOf(FavoritesCache::class.java).deserialize(reader) + + override fun onDataFetched(data: List) = 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() + } + } +} diff --git a/app/src/main/java/com/github/apognu/otter/repositories/HttpUpstream.kt b/app/src/main/java/com/github/apognu/otter/repositories/HttpUpstream.kt new file mode 100644 index 0000000..66879f3 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/repositories/HttpUpstream.kt @@ -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>(private val behavior: Behavior, private val url: String, private val type: Type) : Upstream { + enum class Behavior { + AtOnce, Progressive + } + + private var _channel: Channel>? = null + private val channel: Channel> + get() { + if (_channel?.isClosedForSend ?: true) { + _channel = Channel() + } + + return _channel!! + } + + override fun fetch(data: List): Channel>? { + 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>(val type: Type) : ResponseDeserializable { + override fun deserialize(reader: Reader): T? { + return Gson().fromJson(reader, type) + } + } + + suspend fun get(url: String): Result { + val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("access_token") + + val (_, response, result) = Fuel + .get(normalizeUrl(url)) + .header("Authorization", "Bearer $token") + .awaitObjectResponseResult(GenericDeserializer(type)) + + if (response.statusCode == 401) { + return retryGet(url) + } + + return result + } + + private suspend fun retryGet(url: String): Result { + 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)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/repositories/PlaylistTracksRepository.kt b/app/src/main/java/com/github/apognu/otter/repositories/PlaylistTracksRepository.kt new file mode 100644 index 0000000..6456b57 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/repositories/PlaylistTracksRepository.kt @@ -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() { + override val cacheId = "tracks-playlist-$playlistId" + override val upstream = HttpUpstream>(HttpUpstream.Behavior.AtOnce, "/api/v1/playlists/$playlistId/tracks?playable=true", object : TypeToken() {}.type) + + override fun cache(data: List) = PlaylistTracksCache(data) + override fun uncache(reader: BufferedReader) = gsonDeserializerOf(PlaylistTracksCache::class.java).deserialize(reader) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/repositories/PlaylistsRepository.kt b/app/src/main/java/com/github/apognu/otter/repositories/PlaylistsRepository.kt new file mode 100644 index 0000000..30c597a --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/repositories/PlaylistsRepository.kt @@ -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() { + override val cacheId = "tracks-playlists" + override val upstream = HttpUpstream>(HttpUpstream.Behavior.Progressive, "/api/v1/playlists?playable=true", object : TypeToken() {}.type) + + override fun cache(data: List) = PlaylistsCache(data) + override fun uncache(reader: BufferedReader) = gsonDeserializerOf(PlaylistsCache::class.java).deserialize(reader) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/repositories/Repository.kt b/app/src/main/java/com/github/apognu/otter/repositories/Repository.kt new file mode 100644 index 0000000..8b225e0 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/repositories/Repository.kt @@ -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 { + fun fetch(data: List = listOf()): Channel>? +} + +abstract class Repository> { + enum class Origin(val origin: Int) { + Cache(0b01), + Network(0b10) + } + + data class Response(val origin: Origin, val data: List) + + abstract val context: Context? + abstract val cacheId: String? + abstract val upstream: Upstream + + private var _channel: Channel>? = null + private val channel: Channel> + get() { + if (_channel?.isClosedForSend ?: true) { + _channel = Channel(10) + } + + return _channel!! + } + + protected open fun cache(data: List): C? = null + protected open fun uncache(reader: BufferedReader): C? = null + + fun fetch(upstreams: Int = Origin.Cache.origin and Origin.Network.origin, from: List = listOf()): Channel> { + 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) { + 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) = data +} diff --git a/app/src/main/java/com/github/apognu/otter/repositories/SearchRepository.kt b/app/src/main/java/com/github/apognu/otter/repositories/SearchRepository.kt new file mode 100644 index 0000000..0713d79 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/repositories/SearchRepository.kt @@ -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() { + override val cacheId: String? = null + override val upstream = HttpUpstream>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks?playable=true&q=$query", object : TypeToken() {}.type) + + override fun cache(data: List) = TracksCache(data) + override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader) + + var query: String? = null + + override fun onDataFetched(data: List): List = 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/repositories/TracksRepository.kt b/app/src/main/java/com/github/apognu/otter/repositories/TracksRepository.kt new file mode 100644 index 0000000..a6f16e8 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/repositories/TracksRepository.kt @@ -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() { + override val cacheId = "tracks-album-$albumId" + override val upstream = HttpUpstream>(HttpUpstream.Behavior.AtOnce, "/api/v1/tracks?playable=true&album=$albumId", object : TypeToken() {}.type) + + override fun cache(data: List) = TracksCache(data) + override fun uncache(reader: BufferedReader) = gsonDeserializerOf(TracksCache::class.java).deserialize(reader) + + override fun onDataFetched(data: List): List = 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/utils/AppContext.kt b/app/src/main/java/com/github/apognu/otter/utils/AppContext.kt new file mode 100644 index 0000000..2c41537 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/utils/AppContext.kt @@ -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)) + } +} diff --git a/app/src/main/java/com/github/apognu/otter/utils/Data.kt b/app/src/main/java/com/github/apognu/otter/utils/Data.kt new file mode 100644 index 0000000..0082d82 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/utils/Data.kt @@ -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 get(url: String): Result { + 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 retryGet(url: String): Result { + 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/utils/EventBus.kt b/app/src/main/java/com/github/apognu/otter/utils/EventBus.kt new file mode 100644 index 0000000..19201cc --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/utils/EventBus.kt @@ -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) : Command() + class PlayNext(val track: Track) : Command() + class ReplaceQueue(val queue: List) : 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? = null) { + object GetState : Request() + object GetQueue : Request() + object GetCurrentTrack : Request() +} + +sealed class Response { + class State(val playing: Boolean) : Response() + class Queue(val queue: List) : Response() + class CurrentTrack(val track: Track?) : Response() +} + +object EventBus { + private var bus: BroadcastChannel = BroadcastChannel(10) + + fun send(event: Event) { + GlobalScope.launch { + bus.offer(event) + } + } + + fun get() = bus + + inline fun asChannel(): ReceiveChannel { + return get().openSubscription().filter { it is T }.map { it as T } + } +} + +object CommandBus { + private var bus: Channel = Channel(10) + + fun send(command: Command) { + GlobalScope.launch { + bus.offer(command) + } + } + + fun asChannel() = bus +} + +object RequestBus { + private var bus: BroadcastChannel = BroadcastChannel(10) + + fun send(request: Request): Channel { + return Channel().also { + GlobalScope.launch(Main) { + request.channel = it + + bus.offer(request) + } + } + } + + fun get() = bus + + inline fun asChannel(): ReceiveChannel { + return get().openSubscription().filter { it is T }.map { it as T } + } +} + +object ProgressBus { + private val bus: BroadcastChannel> = ConflatedBroadcastChannel() + + fun send(current: Int, duration: Int, percent: Int) { + GlobalScope.launch { + bus.send(Triple(current, duration, percent)) + } + } + + fun asChannel(): ReceiveChannel> { + return bus.openSubscription() + } +} + +suspend inline fun Channel.wait(): T? { + return when (val response = this.receive()) { + is T -> response + else -> null + } +} diff --git a/app/src/main/java/com/github/apognu/otter/utils/Extensions.kt b/app/src/main/java/com/github/apognu/otter/utils/Extensions.kt new file mode 100644 index 0000000..283c664 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/utils/Extensions.kt @@ -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 Channel>.await(context: CoroutineContext = Main, crossinline callback: (data: List) -> Unit) { + GlobalScope.launch(context) { + this@await.receive().also { + callback(it.data) + close() + } + } +} + +inline fun Channel>.untilNetwork(context: CoroutineContext = Main, crossinline callback: (data: List) -> 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 Int.onApi(block: () -> T) { + if (Build.VERSION.SDK_INT >= this) { + block() + } +} + +fun Int.onApi(block: () -> T, elseBlock: (() -> U)) { + if (Build.VERSION.SDK_INT >= this) { + block() + } else { + elseBlock() + } +} + +fun Int.onApiForResult(block: () -> T, elseBlock: (() -> T)): T { + if (Build.VERSION.SDK_INT >= this) { + return block() + } else { + return elseBlock() + } +} + +fun T.applyOnApi(api: Int, block: T.() -> T): T { + if (Build.VERSION.SDK_INT >= api) { + return block() + } else { + return this + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/utils/Models.kt b/app/src/main/java/com/github/apognu/otter/utils/Models.kt new file mode 100644 index 0000000..d34ff52 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/utils/Models.kt @@ -0,0 +1,113 @@ +package com.github.apognu.otter.utils + +import com.preference.PowerPreference + +sealed class CacheItem(val data: List) +class ArtistsCache(data: List) : CacheItem(data) +class AlbumsCache(data: List) : CacheItem(data) +class TracksCache(data: List) : CacheItem(data) +class PlaylistsCache(data: List) : CacheItem(data) +class PlaylistTracksCache(data: List) : CacheItem(data) +class FavoritesCache(data: List) : CacheItem(data) +class QueueCache(data: List) : CacheItem(data) + +abstract class FunkwhaleResponse { + abstract val count: Int + abstract val next: String? + + abstract fun getData(): List +} + +data class ArtistsResponse(override val count: Int, override val next: String?, val results: List) : FunkwhaleResponse() { + override fun getData() = results +} + +data class AlbumsResponse(override val count: Int, override val next: String?, val results: AlbumList) : FunkwhaleResponse() { + override fun getData() = results +} + +data class TracksResponse(override val count: Int, override val next: String?, val results: List) : FunkwhaleResponse() { + override fun getData() = results +} + +data class FavoritesResponse(override val count: Int, override val next: String?, val results: List) : FunkwhaleResponse() { + override fun getData() = results +} + +data class PlaylistsResponse(override val count: Int, override val next: String?, val results: List) : FunkwhaleResponse() { + override fun getData() = results +} + +data class PlaylistTracksResponse(override val count: Int, override val next: String?, val results: List) : FunkwhaleResponse() { + override fun getData() = results +} + +data class Covers(val original: String) + +typealias AlbumList = List + +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? +) { + 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 +) { + 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, + val tracks_count: Int, + val duration: Int +) + +data class PlaylistTrack(val track: Track) \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/utils/Util.kt b/app/src/main/java/com/github/apognu/otter/utils/Util.kt new file mode 100644 index 0000000..ba40f9e --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/utils/Util.kt @@ -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() +} diff --git a/app/src/main/java/com/github/apognu/otter/views/ExplodeReveal.kt b/app/src/main/java/com/github/apognu/otter/views/ExplodeReveal.kt new file mode 100644 index 0000000..8692d89 --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/views/ExplodeReveal.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/views/NowPlayingView.kt b/app/src/main/java/com/github/apognu/otter/views/NowPlayingView.kt new file mode 100644 index 0000000..a64256f --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/views/NowPlayingView.kt @@ -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() + } + } + } +} diff --git a/app/src/main/java/com/github/apognu/otter/views/SquareImageView.kt b/app/src/main/java/com/github/apognu/otter/views/SquareImageView.kt new file mode 100644 index 0000000..79a247e --- /dev/null +++ b/app/src/main/java/com/github/apognu/otter/views/SquareImageView.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable-hdpi/ottericon.png b/app/src/main/res/drawable-hdpi/ottericon.png new file mode 100644 index 0000000..09f5018 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ottericon.png differ diff --git a/app/src/main/res/drawable-mdpi/ottericon.png b/app/src/main/res/drawable-mdpi/ottericon.png new file mode 100644 index 0000000..6c172a9 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ottericon.png differ diff --git a/app/src/main/res/drawable-xhdpi/ottericon.png b/app/src/main/res/drawable-xhdpi/ottericon.png new file mode 100644 index 0000000..70efa59 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ottericon.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ottericon.png b/app/src/main/res/drawable-xxhdpi/ottericon.png new file mode 100644 index 0000000..743d312 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ottericon.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ottericon.png b/app/src/main/res/drawable-xxxhdpi/ottericon.png new file mode 100644 index 0000000..ff16169 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ottericon.png differ diff --git a/app/src/main/res/drawable/add.xml b/app/src/main/res/drawable/add.xml new file mode 100644 index 0000000..fb601c0 --- /dev/null +++ b/app/src/main/res/drawable/add.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/cover.png b/app/src/main/res/drawable/cover.png new file mode 100644 index 0000000..e1b7511 Binary files /dev/null and b/app/src/main/res/drawable/cover.png differ diff --git a/app/src/main/res/drawable/favorite.xml b/app/src/main/res/drawable/favorite.xml new file mode 100644 index 0000000..62fa213 --- /dev/null +++ b/app/src/main/res/drawable/favorite.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/login_input.xml b/app/src/main/res/drawable/login_input.xml new file mode 100644 index 0000000..df9aa73 --- /dev/null +++ b/app/src/main/res/drawable/login_input.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/more.xml b/app/src/main/res/drawable/more.xml new file mode 100644 index 0000000..063c9a7 --- /dev/null +++ b/app/src/main/res/drawable/more.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/next.xml b/app/src/main/res/drawable/next.xml new file mode 100644 index 0000000..9ce21d3 --- /dev/null +++ b/app/src/main/res/drawable/next.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ottershape.png b/app/src/main/res/drawable/ottershape.png new file mode 100644 index 0000000..8e7aed9 Binary files /dev/null and b/app/src/main/res/drawable/ottershape.png differ diff --git a/app/src/main/res/drawable/pause.xml b/app/src/main/res/drawable/pause.xml new file mode 100644 index 0000000..117a814 --- /dev/null +++ b/app/src/main/res/drawable/pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/play.xml b/app/src/main/res/drawable/play.xml new file mode 100644 index 0000000..0345329 --- /dev/null +++ b/app/src/main/res/drawable/play.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/previous.xml b/app/src/main/res/drawable/previous.xml new file mode 100644 index 0000000..414fb2d --- /dev/null +++ b/app/src/main/res/drawable/previous.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/queue.xml b/app/src/main/res/drawable/queue.xml new file mode 100644 index 0000000..06f0e68 --- /dev/null +++ b/app/src/main/res/drawable/queue.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/reorder.xml b/app/src/main/res/drawable/reorder.xml new file mode 100644 index 0000000..35b4da3 --- /dev/null +++ b/app/src/main/res/drawable/reorder.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/search.xml b/app/src/main/res/drawable/search.xml new file mode 100644 index 0000000..3ad6951 --- /dev/null +++ b/app/src/main/res/drawable/search.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/settings.xml b/app/src/main/res/drawable/settings.xml new file mode 100644 index 0000000..59c2f3c --- /dev/null +++ b/app/src/main/res/drawable/settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_licences.xml b/app/src/main/res/layout/activity_licences.xml new file mode 100644 index 0000000..52bfd76 --- /dev/null +++ b/app/src/main/res/layout/activity_licences.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml new file mode 100644 index 0000000..525293b --- /dev/null +++ b/app/src/main/res/layout/activity_login.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..10d4afa --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_search.xml b/app/src/main/res/layout/activity_search.xml new file mode 100644 index 0000000..454acda --- /dev/null +++ b/app/src/main/res/layout/activity_search.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 0000000..8cdb7b6 --- /dev/null +++ b/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,21 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_login.xml b/app/src/main/res/layout/dialog_login.xml new file mode 100644 index 0000000..4f9bade --- /dev/null +++ b/app/src/main/res/layout/dialog_login.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_albums.xml b/app/src/main/res/layout/fragment_albums.xml new file mode 100644 index 0000000..0e7a5ff --- /dev/null +++ b/app/src/main/res/layout/fragment_albums.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_albums_grid.xml b/app/src/main/res/layout/fragment_albums_grid.xml new file mode 100644 index 0000000..019b2b4 --- /dev/null +++ b/app/src/main/res/layout/fragment_albums_grid.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_artists.xml b/app/src/main/res/layout/fragment_artists.xml new file mode 100644 index 0000000..8654848 --- /dev/null +++ b/app/src/main/res/layout/fragment_artists.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_browse.xml b/app/src/main/res/layout/fragment_browse.xml new file mode 100644 index 0000000..78b3cd1 --- /dev/null +++ b/app/src/main/res/layout/fragment_browse.xml @@ -0,0 +1,26 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_favorites.xml b/app/src/main/res/layout/fragment_favorites.xml new file mode 100644 index 0000000..0b6e54e --- /dev/null +++ b/app/src/main/res/layout/fragment_favorites.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_playlists.xml b/app/src/main/res/layout/fragment_playlists.xml new file mode 100644 index 0000000..b85c10c --- /dev/null +++ b/app/src/main/res/layout/fragment_playlists.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_queue.xml b/app/src/main/res/layout/fragment_queue.xml new file mode 100644 index 0000000..39be80a --- /dev/null +++ b/app/src/main/res/layout/fragment_queue.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_tracks.xml b/app/src/main/res/layout/fragment_tracks.xml new file mode 100644 index 0000000..f2a1191 --- /dev/null +++ b/app/src/main/res/layout/fragment_tracks.xml @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/partial_now_playing.xml b/app/src/main/res/layout/partial_now_playing.xml new file mode 100644 index 0000000..8a34c4d --- /dev/null +++ b/app/src/main/res/layout/partial_now_playing.xml @@ -0,0 +1,218 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/preference_category.xml b/app/src/main/res/layout/preference_category.xml new file mode 100644 index 0000000..62b423a --- /dev/null +++ b/app/src/main/res/layout/preference_category.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/row_album.xml b/app/src/main/res/layout/row_album.xml new file mode 100644 index 0000000..01b5b96 --- /dev/null +++ b/app/src/main/res/layout/row_album.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/row_album_grid.xml b/app/src/main/res/layout/row_album_grid.xml new file mode 100644 index 0000000..e7e78fc --- /dev/null +++ b/app/src/main/res/layout/row_album_grid.xml @@ -0,0 +1,28 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/row_artist.xml b/app/src/main/res/layout/row_artist.xml new file mode 100644 index 0000000..028a9e6 --- /dev/null +++ b/app/src/main/res/layout/row_artist.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/row_licence.xml b/app/src/main/res/layout/row_licence.xml new file mode 100644 index 0000000..2859c3b --- /dev/null +++ b/app/src/main/res/layout/row_licence.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/row_playlist.xml b/app/src/main/res/layout/row_playlist.xml new file mode 100644 index 0000000..f59f6f9 --- /dev/null +++ b/app/src/main/res/layout/row_playlist.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/row_track.xml b/app/src/main/res/layout/row_track.xml new file mode 100644 index 0000000..712e0d3 --- /dev/null +++ b/app/src/main/res/layout/row_track.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/row_queue.xml b/app/src/main/res/menu/row_queue.xml new file mode 100644 index 0000000..2d21bfa --- /dev/null +++ b/app/src/main/res/menu/row_queue.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/row_track.xml b/app/src/main/res/menu/row_track.xml new file mode 100644 index 0000000..b555645 --- /dev/null +++ b/app/src/main/res/menu/row_track.xml @@ -0,0 +1,16 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/toolbar.xml b/app/src/main/res/menu/toolbar.xml new file mode 100644 index 0000000..c9237a9 --- /dev/null +++ b/app/src/main/res/menu/toolbar.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2b7f809 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..2b7f809 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..7e83ca2 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..40419d1 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..c0faf57 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..69f67ed Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..fa7f764 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..5c2710b Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..02f106d Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..4b2cac3 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..77137bc Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..b32cc41 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..466f617 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..4b1b704 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..b6de01b Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..df294f6 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..dbb7b2e Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..586bd8e --- /dev/null +++ b/app/src/main/res/values-fr/strings.xml @@ -0,0 +1,77 @@ + + + Otter + + Veuillez saisir les détails de votre instance Funkwhale pour accéder à son contenu + Nom d\'hôte + Nom d\'utilisateur + Mot de passe + Se connecter + Connexion + + Cela ne semble pas être un nom d\hôte valide + Le nom d\'hôte Funkwhale devrait être sécurisé à travers HTTPS + + Cast + Rechercher + + Paramètres + Licences open source + + Recherchez des artistes, albums ou morceaux + Saisissez vos termes de recherche ci-dessus et validez pour rechercher dans votre collection. + Aucun résultat n\'a été trouvé pour votre recherche + + Général + Qualité des médias + Meilleure qualité + Fichiers plus légers + Les pistes de meilleure qualité seront utilisées + Les pistes les plus légères seront utilisées + Taille du cache + %d Go seront utilisés pour mettre en cache les pistes pour la lecture hors-ligne + Autres + Mode nuit + Toujours activé (mode sombre) + Le mode nuit sera toujours préféré + Toujours désactivé (mode clair) + Le mode jour sera toujours préféré + Suivre les préférences du système + Le mode nuit suivra les préférence système + Déconnexion + + Artistes + Albums + Playlists + Favoris + + Contrôle de lecture + Contrôler la lecture musicale + Lecture aléatoire + Ajouter + Votre liste de lecture est vide + Retirer + Ajouter à la liste de lecture + Prochaine écoute + + Ajouter à une playlist + Add to favorites + + Lecture / pause + Piste précédente + Piste suivante + + Cette piste ne peut pas être jouée + + + %d album + %d albums + + + Logo de l\'application + Image de l\'artiste + Couverture de l\'album + + Déconnexion + Etes-vous certains de vouloir vous déconnecter de votre instance Funkwhale ? + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..cc9facb --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,10 @@ + + + #121212 + #71a5cd + + #525252 + #caffffff + + #caffffff + \ No newline at end of file diff --git a/app/src/main/res/values/array.xml b/app/src/main/res/values/array.xml new file mode 100644 index 0000000..413f260 --- /dev/null +++ b/app/src/main/res/values/array.xml @@ -0,0 +1,24 @@ + + + + @string/settings_media_quality_quality + @string/settings_media_quality_size + + + + quality + size + + + + @string/settings_night_mode_on + @string/settings_night_mode_off + @string/settings_night_mode_system + + + + on + off + system + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..ee90bdf --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,16 @@ + + + @android:color/background_light + @android:color/background_light + + #327eae + #3d3e40 + #d35400 + + #dadada + #e17055 + + @android:color/black + + @color/colorPrimary + diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..02a0efd --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + @color/colorPrimary + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..5269a06 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,77 @@ + + + Otter + + Please enter the details of your Funkwhale instance to access its content + Host name + Username + Password + Log in + Logging in + + This could not be understood as a valid URL + The Funkwhale hostname should be secure through HTTPS + + Cast + Search + + Settings + Open source licences + + Search artists, albums and tracks + Enter your search terms above and hit enter to search your collection + No results were found for your query + + General + Media quality + Best quality + Smallest size + Best available track will be played + Smallest available track will be played + Media cache size + %d GB will be used to store tracks for offline playback + Other + Night mode + Always on (dark mode) + Night mode will always be preferred + Always off (light mode) + Light mode will always be preferred + Follow system settings + Night mode will follow system settings + Sign out + + Artists + Albums + Playlists + Favorites + + Media controls + Control media playback + Shuffle + Queue + Your queue is empty + Remove + Add to queue + Play next + + Add to playlist + Ajouter aux favoris + + Toggle playback + Previous track + Next track + + This track could not be played + + + %d album + %d albums + + + Application logo + Artist art + Album cover + + Sign out + Are you sure you want to sign out of your Funkwhale instance? + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..3783e6e --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/settings.xml b/app/src/main/res/xml/settings.xml new file mode 100644 index 0000000..240407f --- /dev/null +++ b/app/src/main/res/xml/settings.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..dfbafcc --- /dev/null +++ b/build.gradle @@ -0,0 +1,38 @@ +buildscript { + ext.kotlin_version = '1.3.50' + + repositories { + google() + jcenter() + + } + + dependencies { + classpath 'com.android.tools.build:gradle:3.5.1' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +plugins { + id 'org.jlleitschuh.gradle.ktlint' version '8.1.0' +} + +allprojects { + repositories { + google() + jcenter() + } +} + +subprojects { + apply plugin: 'org.jlleitschuh.gradle.ktlint' + + ktlint { + debug = false + verbose = false + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..23339e0 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,21 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f6b961f Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..8adb0b4 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Aug 28 14:01:52 CEST 2019 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..cccdd3d --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..e95643d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..e7b4def --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +include ':app'