committed by
Antoine POPINEAU
commit
5f495f54e5
No known key found for this signature in database
GPG Key ID: A78AC64694F84063
129 changed files with 6734 additions and 0 deletions
-
8.gitignore
-
21LICENSE
-
2app/.gitignore
-
61app/build.gradle
-
21app/proguard-rules.pro
-
45app/src/main/AndroidManifest.xml
-
17app/src/main/java/com/github/apognu/otter/Otter.kt
-
102app/src/main/java/com/github/apognu/otter/activities/LicencesActivity.kt
-
97app/src/main/java/com/github/apognu/otter/activities/LoginActivity.kt
-
303app/src/main/java/com/github/apognu/otter/activities/MainActivity.kt
-
66app/src/main/java/com/github/apognu/otter/activities/SearchActivity.kt
-
121app/src/main/java/com/github/apognu/otter/activities/SettingsActivity.kt
-
55app/src/main/java/com/github/apognu/otter/adapters/AlbumsAdapter.kt
-
52app/src/main/java/com/github/apognu/otter/adapters/AlbumsGridAdapter.kt
-
65app/src/main/java/com/github/apognu/otter/adapters/ArtistsAdapter.kt
-
44app/src/main/java/com/github/apognu/otter/adapters/BrowseTabsAdapter.kt
-
183app/src/main/java/com/github/apognu/otter/adapters/FavoritesAdapter.kt
-
179app/src/main/java/com/github/apognu/otter/adapters/PlaylistTracksAdapter.kt
-
65app/src/main/java/com/github/apognu/otter/adapters/PlaylistsAdapter.kt
-
32app/src/main/java/com/github/apognu/otter/adapters/SearchResultsAdapter.kt
-
206app/src/main/java/com/github/apognu/otter/adapters/TracksAdapter.kt
-
93app/src/main/java/com/github/apognu/otter/fragments/AlbumsFragment.kt
-
60app/src/main/java/com/github/apognu/otter/fragments/AlbumsGridFragment.kt
-
58app/src/main/java/com/github/apognu/otter/fragments/ArtistsFragment.kt
-
34app/src/main/java/com/github/apognu/otter/fragments/BrowseFragment.kt
-
71app/src/main/java/com/github/apognu/otter/fragments/FavoritesFragment.kt
-
80app/src/main/java/com/github/apognu/otter/fragments/FunkwhaleFragment.kt
-
23app/src/main/java/com/github/apognu/otter/fragments/LoginDialog.kt
-
120app/src/main/java/com/github/apognu/otter/fragments/PlaylistTracksFragment.kt
-
55app/src/main/java/com/github/apognu/otter/fragments/PlaylistsFragment.kt
-
89app/src/main/java/com/github/apognu/otter/fragments/QueueFragment.kt
-
122app/src/main/java/com/github/apognu/otter/fragments/TracksFragment.kt
-
125app/src/main/java/com/github/apognu/otter/playback/MediaControlsManager.kt
-
442app/src/main/java/com/github/apognu/otter/playback/PlayerService.kt
-
158app/src/main/java/com/github/apognu/otter/playback/QueueManager.kt
-
32app/src/main/java/com/github/apognu/otter/repositories/AlbumsRepository.kt
-
18app/src/main/java/com/github/apognu/otter/repositories/ArtistsRepository.kt
-
55app/src/main/java/com/github/apognu/otter/repositories/FavoritesRepository.kt
-
102app/src/main/java/com/github/apognu/otter/repositories/HttpUpstream.kt
-
18app/src/main/java/com/github/apognu/otter/repositories/PlaylistTracksRepository.kt
-
18app/src/main/java/com/github/apognu/otter/repositories/PlaylistsRepository.kt
-
75app/src/main/java/com/github/apognu/otter/repositories/Repository.kt
-
35app/src/main/java/com/github/apognu/otter/repositories/SearchRepository.kt
-
33app/src/main/java/com/github/apognu/otter/repositories/TracksRepository.kt
-
75app/src/main/java/com/github/apognu/otter/utils/AppContext.kt
-
90app/src/main/java/com/github/apognu/otter/utils/Data.kt
-
117app/src/main/java/com/github/apognu/otter/utils/EventBus.kt
-
88app/src/main/java/com/github/apognu/otter/utils/Extensions.kt
-
113app/src/main/java/com/github/apognu/otter/utils/Models.kt
-
26app/src/main/java/com/github/apognu/otter/utils/Util.kt
-
79app/src/main/java/com/github/apognu/otter/views/ExplodeReveal.kt
-
240app/src/main/java/com/github/apognu/otter/views/NowPlayingView.kt
-
17app/src/main/java/com/github/apognu/otter/views/SquareImageView.kt
-
BINapp/src/main/res/drawable-hdpi/ottericon.png
-
BINapp/src/main/res/drawable-mdpi/ottericon.png
-
BINapp/src/main/res/drawable-xhdpi/ottericon.png
-
BINapp/src/main/res/drawable-xxhdpi/ottericon.png
-
BINapp/src/main/res/drawable-xxxhdpi/ottericon.png
-
9app/src/main/res/drawable/add.xml
-
BINapp/src/main/res/drawable/cover.png
-
9app/src/main/res/drawable/favorite.xml
-
8app/src/main/res/drawable/login_input.xml
-
9app/src/main/res/drawable/more.xml
-
9app/src/main/res/drawable/next.xml
-
BINapp/src/main/res/drawable/ottershape.png
-
9app/src/main/res/drawable/pause.xml
-
9app/src/main/res/drawable/play.xml
-
9app/src/main/res/drawable/previous.xml
-
9app/src/main/res/drawable/queue.xml
-
9app/src/main/res/drawable/reorder.xml
-
9app/src/main/res/drawable/search.xml
-
9app/src/main/res/drawable/settings.xml
-
31app/src/main/res/layout/activity_licences.xml
-
97app/src/main/res/layout/activity_login.xml
-
44app/src/main/res/layout/activity_main.xml
-
68app/src/main/res/layout/activity_search.xml
-
21app/src/main/res/layout/activity_settings.xml
-
15app/src/main/res/layout/dialog_login.xml
-
109app/src/main/res/layout/fragment_albums.xml
-
45app/src/main/res/layout/fragment_albums_grid.xml
-
48app/src/main/res/layout/fragment_artists.xml
-
26app/src/main/res/layout/fragment_browse.xml
-
59app/src/main/res/layout/fragment_favorites.xml
-
47app/src/main/res/layout/fragment_playlists.xml
-
45app/src/main/res/layout/fragment_queue.xml
-
206app/src/main/res/layout/fragment_tracks.xml
-
218app/src/main/res/layout/partial_now_playing.xml
-
7app/src/main/res/layout/preference_category.xml
-
46app/src/main/res/layout/row_album.xml
-
28app/src/main/res/layout/row_album_grid.xml
-
47app/src/main/res/layout/row_artist.xml
-
24app/src/main/res/layout/row_licence.xml
-
114app/src/main/res/layout/row_playlist.xml
-
72app/src/main/res/layout/row_track.xml
-
8app/src/main/res/menu/row_queue.xml
-
16app/src/main/res/menu/row_track.xml
-
31app/src/main/res/menu/toolbar.xml
-
5app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
-
5app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
-
BINapp/src/main/res/mipmap-hdpi/ic_launcher.png
@ -0,0 +1,8 @@ |
|||
*.iml |
|||
.gradle |
|||
/local.properties |
|||
/.idea |
|||
.DS_Store |
|||
/build |
|||
/captures |
|||
.externalNativeBuild |
@ -0,0 +1,21 @@ |
|||
MIT License |
|||
|
|||
Copyright (c) 2019 Antoine POPINEAU |
|||
|
|||
Permission is hereby granted, free of charge, to any person obtaining a copy |
|||
of this software and associated documentation files (the "Software"), to deal |
|||
in the Software without restriction, including without limitation the rights |
|||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
|||
copies of the Software, and to permit persons to whom the Software is |
|||
furnished to do so, subject to the following conditions: |
|||
|
|||
The above copyright notice and this permission notice shall be included in all |
|||
copies or substantial portions of the Software. |
|||
|
|||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
|||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
|||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
|||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
|||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|||
SOFTWARE. |
@ -0,0 +1,2 @@ |
|||
/build |
|||
/release |
@ -0,0 +1,61 @@ |
|||
apply plugin: 'com.android.application' |
|||
apply plugin: 'kotlin-android' |
|||
apply plugin: 'kotlin-android-extensions' |
|||
|
|||
android { |
|||
compileOptions { |
|||
sourceCompatibility JavaVersion.VERSION_1_8 |
|||
targetCompatibility JavaVersion.VERSION_1_8 |
|||
} |
|||
|
|||
kotlinOptions { |
|||
jvmTarget = JavaVersion.VERSION_1_8 |
|||
} |
|||
|
|||
compileSdkVersion 29 |
|||
|
|||
defaultConfig { |
|||
applicationId "com.github.apognu.otter" |
|||
minSdkVersion 23 |
|||
targetSdkVersion 29 |
|||
versionCode 4 |
|||
versionName "1.0.3" |
|||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" |
|||
} |
|||
|
|||
buildTypes { |
|||
release { |
|||
minifyEnabled false |
|||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' |
|||
} |
|||
} |
|||
} |
|||
|
|||
dependencies { |
|||
implementation fileTree(dir: 'libs', include: ['*.jar']) |
|||
|
|||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" |
|||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.2' |
|||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.2' |
|||
|
|||
implementation 'androidx.appcompat:appcompat:1.1.0' |
|||
implementation 'androidx.core:core-ktx:1.2.0-beta01' |
|||
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.0.0' |
|||
implementation 'androidx.preference:preference:1.1.0' |
|||
implementation 'androidx.recyclerview:recyclerview:1.0.0' |
|||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.0.0' |
|||
implementation 'com.google.android.material:material:1.1.0-beta01' |
|||
implementation 'com.android.support.constraint:constraint-layout:1.1.3' |
|||
|
|||
implementation 'com.google.android.exoplayer:exoplayer:2.10.3' |
|||
implementation 'com.google.android.exoplayer:extension-mediasession:2.10.6' |
|||
implementation 'com.google.android.exoplayer:extension-cast:2.10.6' |
|||
implementation 'com.aliassadi:power-preference-lib:1.4.1' |
|||
implementation 'com.github.kittinunf.fuel:fuel:2.1.0' |
|||
implementation 'com.github.kittinunf.fuel:fuel-coroutines:2.1.0' |
|||
implementation 'com.github.kittinunf.fuel:fuel-android:2.1.0' |
|||
implementation 'com.github.kittinunf.fuel:fuel-gson:2.1.0' |
|||
implementation 'com.google.code.gson:gson:2.8.5' |
|||
implementation 'com.squareup.picasso:picasso:2.71828' |
|||
implementation 'jp.wasabeef:picasso-transformations:2.2.1' |
|||
} |
@ -0,0 +1,21 @@ |
|||
# Add project specific ProGuard rules here. |
|||
# You can control the set of applied configuration files using the |
|||
# proguardFiles setting in build.gradle. |
|||
# |
|||
# For more details, see |
|||
# http://developer.android.com/guide/developing/tools/proguard.html |
|||
|
|||
# If your project uses WebView with JS, uncomment the following |
|||
# and specify the fully qualified class name to the JavaScript interface |
|||
# class: |
|||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview { |
|||
# public *; |
|||
#} |
|||
|
|||
# Uncomment this to preserve the line number information for |
|||
# debugging stack traces. |
|||
#-keepattributes SourceFile,LineNumberTable |
|||
|
|||
# If you keep the line number information, uncomment this to |
|||
# hide the original source file name. |
|||
#-renamesourcefileattribute SourceFile |
@ -0,0 +1,45 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<manifest |
|||
xmlns:android="http://schemas.android.com/apk/res/android" |
|||
package="com.github.apognu.otter"> |
|||
|
|||
<uses-permission android:name="android.permission.INTERNET"/> |
|||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> |
|||
|
|||
<permission android:name="android.permission.MEDIA_CONTENT_CONTROL"/> |
|||
|
|||
<application |
|||
android:name="com.github.apognu.otter.Otter" |
|||
android:allowBackup="false" |
|||
android:icon="@mipmap/ic_launcher" |
|||
android:label="@string/app_name" |
|||
android:roundIcon="@mipmap/ic_launcher_round" |
|||
android:supportsRtl="true" |
|||
android:screenOrientation="portrait" |
|||
android:theme="@style/AppTheme"> |
|||
|
|||
<!-- <meta-data |
|||
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME" |
|||
android:value="com.google.android.exoplayer2.ext.cast.DefaultCastOptionsProvider"/> --> |
|||
|
|||
<activity android:name="com.github.apognu.otter.activities.LoginActivity" android:noHistory="true" android:launchMode="singleInstance"> |
|||
<intent-filter> |
|||
<action android:name="android.intent.action.MAIN"/> |
|||
<action android:name="android.intent.action.VIEW"/> |
|||
|
|||
<category android:name="android.intent.category.LAUNCHER"/> |
|||
</intent-filter> |
|||
</activity> |
|||
|
|||
<activity android:name="com.github.apognu.otter.activities.MainActivity"/> |
|||
<activity android:name="com.github.apognu.otter.activities.SearchActivity" android:launchMode="singleTop"/> |
|||
<activity android:name="com.github.apognu.otter.activities.SettingsActivity"/> |
|||
<activity android:name="com.github.apognu.otter.activities.LicencesActivity"/> |
|||
|
|||
<service android:name="com.github.apognu.otter.playback.PlayerService"/> |
|||
|
|||
<receiver android:name="com.github.apognu.otter.playback.MediaControlActionReceiver"/> |
|||
|
|||
</application> |
|||
|
|||
</manifest> |
@ -0,0 +1,17 @@ |
|||
package com.github.apognu.otter |
|||
|
|||
import android.app.Application |
|||
import androidx.appcompat.app.AppCompatDelegate |
|||
import com.preference.PowerPreference |
|||
|
|||
class Otter : Application() { |
|||
override fun onCreate() { |
|||
super.onCreate() |
|||
|
|||
when (PowerPreference.getDefaultFile().getString("night_mode")) { |
|||
"on" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) |
|||
"off" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) |
|||
else -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,102 @@ |
|||
package com.github.apognu.otter.activities |
|||
|
|||
import android.content.Intent |
|||
import android.net.Uri |
|||
import android.os.Bundle |
|||
import android.view.LayoutInflater |
|||
import android.view.View |
|||
import android.view.ViewGroup |
|||
import androidx.appcompat.app.AppCompatActivity |
|||
import androidx.recyclerview.widget.LinearLayoutManager |
|||
import androidx.recyclerview.widget.RecyclerView |
|||
import com.github.apognu.otter.R |
|||
import kotlinx.android.synthetic.main.activity_licences.* |
|||
import kotlinx.android.synthetic.main.row_licence.view.* |
|||
|
|||
class LicencesActivity : AppCompatActivity() { |
|||
data class Licence(val name: String, val licence: String, val url: String) |
|||
|
|||
interface OnLicenceClickListener { |
|||
fun onClick(url: String) |
|||
} |
|||
|
|||
override fun onCreate(savedInstanceState: Bundle?) { |
|||
super.onCreate(savedInstanceState) |
|||
|
|||
setContentView(R.layout.activity_licences) |
|||
|
|||
LicencesAdapter(OnLicenceClick()).also { |
|||
licences.layoutManager = LinearLayoutManager(this) |
|||
licences.adapter = it |
|||
} |
|||
} |
|||
|
|||
private inner class LicencesAdapter(val listener: OnLicenceClickListener) : RecyclerView.Adapter<LicencesAdapter.ViewHolder>() { |
|||
val licences = listOf( |
|||
Licence( |
|||
"ExoPlayer", |
|||
"Apache License 2.0", |
|||
"https://github.com/google/ExoPlayer/blob/release-v2/LICENSE" |
|||
), |
|||
Licence( |
|||
"Fuel", |
|||
"MIT License", |
|||
"https://github.com/kittinunf/fuel/blob/master/LICENSE.md" |
|||
), |
|||
Licence( |
|||
"Gson", |
|||
"Apache License 2.0", |
|||
"https://github.com/google/gson/blob/master/LICENSE" |
|||
), |
|||
Licence( |
|||
"Picasso", |
|||
"Apache License 2.0", |
|||
"https://github.com/square/picasso/blob/master/LICENSE.txt" |
|||
), |
|||
Licence( |
|||
"Picasso Transformations", |
|||
"Apache License 2.0", |
|||
"https://github.com/wasabeef/picasso-transformations/blob/master/LICENSE" |
|||
), |
|||
Licence( |
|||
"PowerPreference", |
|||
"Apache License 2.0", |
|||
"https://github.com/AliAsadi/PowerPreference/blob/master/LICENSE" |
|||
) |
|||
) |
|||
|
|||
override fun getItemCount() = licences.size |
|||
|
|||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { |
|||
val view = LayoutInflater.from(this@LicencesActivity).inflate(R.layout.row_licence, parent, false) |
|||
|
|||
return ViewHolder(view).also { |
|||
view.setOnClickListener(it) |
|||
} |
|||
} |
|||
|
|||
override fun onBindViewHolder(holder: ViewHolder, position: Int) { |
|||
val item = licences[position] |
|||
|
|||
holder.name.text = item.name |
|||
holder.licence.text = item.licence |
|||
} |
|||
|
|||
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view), View.OnClickListener { |
|||
val name = view.name |
|||
val licence = view.licence |
|||
|
|||
override fun onClick(view: View?) { |
|||
listener.onClick(licences[layoutPosition].url) |
|||
} |
|||
} |
|||
} |
|||
|
|||
inner class OnLicenceClick : OnLicenceClickListener { |
|||
override fun onClick(url: String) { |
|||
Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { |
|||
startActivity(this) |
|||
} |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,97 @@ |
|||
package com.github.apognu.otter.activities |
|||
|
|||
import android.content.Context |
|||
import android.content.Intent |
|||
import android.net.Uri |
|||
import androidx.appcompat.app.AppCompatActivity |
|||
import com.github.apognu.otter.R |
|||
import com.github.apognu.otter.fragments.LoginDialog |
|||
import com.github.apognu.otter.utils.AppContext |
|||
import com.github.apognu.otter.utils.log |
|||
import com.github.kittinunf.fuel.Fuel |
|||
import com.github.kittinunf.fuel.coroutines.awaitObjectResult |
|||
import com.github.kittinunf.fuel.gson.gsonDeserializerOf |
|||
import com.preference.PowerPreference |
|||
import kotlinx.android.synthetic.main.activity_login.* |
|||
import kotlinx.coroutines.Dispatchers.Main |
|||
import kotlinx.coroutines.GlobalScope |
|||
import kotlinx.coroutines.launch |
|||
|
|||
data class FwCredentials(val token: String) |
|||
|
|||
class LoginActivity : AppCompatActivity() { |
|||
override fun onResume() { |
|||
super.onResume() |
|||
|
|||
getSharedPreferences(AppContext.PREFS_CREDENTIALS, Context.MODE_PRIVATE).apply { |
|||
when (contains("access_token")) { |
|||
true -> Intent(this@LoginActivity, MainActivity::class.java).apply { |
|||
flags = Intent.FLAG_ACTIVITY_NO_ANIMATION |
|||
|
|||
startActivity(this) |
|||
} |
|||
|
|||
false -> setContentView(R.layout.activity_login) |
|||
} |
|||
} |
|||
|
|||
login?.setOnClickListener { |
|||
val hostname = hostname.text.toString().trim() |
|||
val username = username.text.toString() |
|||
val password = password.text.toString() |
|||
|
|||
try { |
|||
if (hostname.isEmpty()) throw Exception(getString(R.string.login_error_hostname)) |
|||
|
|||
val url = Uri.parse(hostname) |
|||
|
|||
if (url.scheme != "https") { |
|||
throw Exception(getString(R.string.login_error_hostname_https)) |
|||
} |
|||
} catch (e: Exception) { |
|||
val message = |
|||
if (e.message?.isEmpty() == true) getString(R.string.login_error_hostname) |
|||
else e.message |
|||
|
|||
hostname_field.error = message |
|||
|
|||
return@setOnClickListener |
|||
} |
|||
|
|||
hostname_field.error = "" |
|||
|
|||
val body = mapOf( |
|||
"username" to username, |
|||
"password" to password |
|||
).toList() |
|||
|
|||
val dialog = LoginDialog().apply { |
|||
show(supportFragmentManager, "LoginDialog") |
|||
} |
|||
|
|||
GlobalScope.launch(Main) { |
|||
val result = Fuel.post("$hostname/api/v1/token", body) |
|||
.awaitObjectResult(gsonDeserializerOf(FwCredentials::class.java)) |
|||
|
|||
result.fold( |
|||
{ data -> |
|||
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply { |
|||
setString("hostname", hostname) |
|||
setString("username", username) |
|||
setString("password", password) |
|||
setString("access_token", data.token) |
|||
} |
|||
|
|||
dialog.dismiss() |
|||
startActivity(Intent(this@LoginActivity, MainActivity::class.java)) |
|||
}, |
|||
{ error -> |
|||
dialog.dismiss() |
|||
|
|||
hostname_field.error = error.localizedMessage |
|||
} |
|||
) |
|||
} |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,303 @@ |
|||
package com.github.apognu.otter.activities |
|||
|
|||
import android.animation.Animator |
|||
import android.animation.AnimatorListenerAdapter |
|||
import android.annotation.SuppressLint |
|||
import android.content.Intent |
|||
import android.os.Bundle |
|||
import android.view.Menu |
|||
import android.view.MenuItem |
|||
import android.view.View |
|||
import android.view.ViewGroup |
|||
import android.widget.SeekBar |
|||
import androidx.appcompat.app.AppCompatActivity |
|||
import androidx.fragment.app.DialogFragment |
|||
import androidx.fragment.app.Fragment |
|||
import androidx.fragment.app.FragmentManager |
|||
import com.github.apognu.otter.R |
|||
import com.github.apognu.otter.fragments.BrowseFragment |
|||
import com.github.apognu.otter.fragments.QueueFragment |
|||
import com.github.apognu.otter.playback.MediaControlsManager |
|||
import com.github.apognu.otter.playback.PlayerService |
|||
import com.github.apognu.otter.repositories.FavoritesRepository |
|||
import com.github.apognu.otter.repositories.Repository |
|||
import com.github.apognu.otter.utils.* |
|||
import com.preference.PowerPreference |
|||
import com.squareup.picasso.Picasso |
|||
import kotlinx.android.synthetic.main.activity_main.* |
|||
import kotlinx.android.synthetic.main.partial_now_playing.* |
|||
import kotlinx.coroutines.Dispatchers.IO |
|||
import kotlinx.coroutines.Dispatchers.Main |
|||
import kotlinx.coroutines.GlobalScope |
|||
import kotlinx.coroutines.launch |
|||
|
|||
class MainActivity : AppCompatActivity() { |
|||
private val favoriteRepository = FavoritesRepository(this) |
|||
|
|||
override fun onCreate(savedInstanceState: Bundle?) { |
|||
super.onCreate(savedInstanceState) |
|||
|
|||
AppContext.init(this) |
|||
|
|||
setContentView(R.layout.activity_main) |
|||
setSupportActionBar(appbar) |
|||
|
|||
when (intent.action) { |
|||
MediaControlsManager.NOTIFICATION_ACTION_OPEN_QUEUE.toString() -> launchDialog(QueueFragment()) |
|||
} |
|||
|
|||
supportFragmentManager |
|||
.beginTransaction() |
|||
.replace(R.id.container, BrowseFragment()) |
|||
.commit() |
|||
|
|||
startService(Intent(this, PlayerService::class.java)) |
|||
|
|||
watchEventBus() |
|||
|
|||
CommandBus.send(Command.RefreshService) |
|||
} |
|||
|
|||
override fun onResume() { |
|||
super.onResume() |
|||
|
|||
now_playing_toggle.setOnClickListener { |
|||
CommandBus.send(Command.ToggleState) |
|||
} |
|||
|
|||
now_playing_next.setOnClickListener { |
|||
CommandBus.send(Command.NextTrack) |
|||
} |
|||
|
|||
now_playing_details_previous.setOnClickListener { |
|||
CommandBus.send(Command.PreviousTrack) |
|||
} |
|||
|
|||
now_playing_details_next.setOnClickListener { |
|||
CommandBus.send(Command.NextTrack) |
|||
} |
|||
|
|||
now_playing_details_toggle.setOnClickListener { |
|||
CommandBus.send(Command.ToggleState) |
|||
} |
|||
|
|||
now_playing_details_progress.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { |
|||
override fun onStopTrackingTouch(view: SeekBar?) {} |
|||
|
|||
override fun onStartTrackingTouch(view: SeekBar?) {} |
|||
|
|||
override fun onProgressChanged(view: SeekBar?, progress: Int, fromUser: Boolean) { |
|||
if (fromUser) { |
|||
CommandBus.send(Command.Seek(progress)) |
|||
} |
|||
} |
|||
}) |
|||
} |
|||
|
|||
override fun onBackPressed() { |
|||
if (now_playing.isOpened()) { |
|||
now_playing.close() |
|||
return |
|||
} |
|||
|
|||
super.onBackPressed() |
|||
} |
|||
|
|||
override fun onCreateOptionsMenu(menu: Menu?): Boolean { |
|||
menuInflater.inflate(R.menu.toolbar, menu) |
|||
|
|||
// CastButtonFactory.setUpMediaRouteButton(this, menu, R.id.cast) |
|||
|
|||
return true |
|||
} |
|||
|
|||
override fun onOptionsItemSelected(item: MenuItem): Boolean { |
|||
when (item.itemId) { |
|||
android.R.id.home -> { |
|||
now_playing.close() |
|||
|
|||
(supportFragmentManager.fragments.last() as? BrowseFragment)?.let { |
|||
it.selectTabAt(0) |
|||
|
|||
return true |
|||
} |
|||
|
|||
launchFragment(BrowseFragment()) |
|||
} |
|||
|
|||
R.id.nav_queue -> launchDialog(QueueFragment()) |
|||
R.id.nav_search -> startActivity(Intent(this, SearchActivity::class.java)) |
|||
R.id.settings -> startActivity(Intent(this, SettingsActivity::class.java)) |
|||
} |
|||
|
|||
return true |
|||
} |
|||
|
|||
private fun launchFragment(fragment: Fragment) { |
|||
supportFragmentManager.fragments.lastOrNull()?.also { oldFragment -> |
|||
oldFragment.enterTransition = null |
|||
oldFragment.exitTransition = null |
|||
|
|||
supportFragmentManager.popBackStackImmediate(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) |
|||
} |
|||
|
|||
supportFragmentManager |
|||
.beginTransaction() |
|||
.setCustomAnimations(0, 0, 0, 0) |
|||
.replace(R.id.container, fragment) |
|||
.commit() |
|||
} |
|||
|
|||
private fun launchDialog(fragment: DialogFragment) { |
|||
supportFragmentManager.beginTransaction().let { |
|||
fragment.show(it, "") |
|||
} |
|||
} |
|||
|
|||
@SuppressLint("NewApi") |
|||
private fun watchEventBus() { |
|||
GlobalScope.launch(Main) { |
|||
for (message in EventBus.asChannel<Event>()) { |
|||
when (message) { |
|||
is Event.LogOut -> { |
|||
PowerPreference.clearAllData() |
|||
|
|||
startActivity(Intent(this@MainActivity, LoginActivity::class.java).apply { |
|||
flags = Intent.FLAG_ACTIVITY_NO_HISTORY |
|||
}) |
|||
|
|||
finish() |
|||
} |
|||
|
|||
is Event.PlaybackError -> toast(message.message) |
|||
|
|||
is Event.Buffering -> { |
|||
when (message.value) { |
|||
true -> now_playing_buffering.visibility = View.VISIBLE |
|||
false -> now_playing_buffering.visibility = View.GONE |
|||
} |
|||
} |
|||
|
|||
is Event.PlaybackStopped -> { |
|||
if (now_playing.visibility == View.VISIBLE) { |
|||
(container.layoutParams as? ViewGroup.MarginLayoutParams)?.let { |
|||
it.bottomMargin = it.bottomMargin / 2 |
|||
} |
|||
|
|||
now_playing.animate() |
|||
.alpha(0.0f) |
|||
.setDuration(400) |
|||
.setListener(object : AnimatorListenerAdapter() { |
|||
override fun onAnimationEnd(animator: Animator?) { |
|||
now_playing.visibility = View.GONE |
|||
} |
|||
}) |
|||
.start() |
|||
} |
|||
} |
|||
|
|||
is Event.TrackPlayed -> { |
|||
message.track?.let { track -> |
|||
if (now_playing.visibility == View.GONE) { |
|||
now_playing.visibility = View.VISIBLE |
|||
now_playing.alpha = 0f |
|||
|
|||
now_playing.animate() |
|||
.alpha(1.0f) |
|||
.setDuration(400) |
|||
.setListener(null) |
|||
.start() |
|||
|
|||
(container.layoutParams as? ViewGroup.MarginLayoutParams)?.let { |
|||
it.bottomMargin = it.bottomMargin * 2 |
|||
} |
|||
} |
|||
|
|||
now_playing_title.text = track.title |
|||
now_playing_album.text = track.artist.name |
|||
now_playing_toggle.icon = getDrawable(R.drawable.pause) |
|||
now_playing_progress.progress = 0 |
|||
|
|||
now_playing_details_title.text = track.title |
|||
now_playing_details_artist.text = track.artist.name |
|||
now_playing_details_toggle.icon = getDrawable(R.drawable.pause) |
|||
now_playing_details_progress.progress = 0 |
|||
|
|||
Picasso.get() |
|||
.load(normalizeUrl(track.album.cover.original)) |
|||
.fit() |
|||
.centerCrop() |
|||
.into(now_playing_cover) |
|||
|
|||
Picasso.get() |
|||
.load(normalizeUrl(track.album.cover.original)) |
|||
.fit() |
|||
.centerCrop() |
|||
.into(now_playing_details_cover) |
|||
|
|||
favoriteRepository.fetch().untilNetwork(IO) { favorites -> |
|||
GlobalScope.launch(Main) { |
|||
val favorites = favorites.map { it.track.id } |
|||
|
|||
track.favorite = favorites.contains(track.id) |
|||
when (track.favorite) { |
|||
true -> now_playing_details_favorite.setColorFilter(resources.getColor(R.color.colorFavorite)) |
|||
false -> now_playing_details_favorite.setColorFilter(resources.getColor(R.color.controlForeground)) |
|||
} |
|||
} |
|||
} |
|||
|
|||
now_playing_details_favorite.setOnClickListener { |
|||
when (track.favorite) { |
|||
true -> { |
|||
favoriteRepository.deleteFavorite(track.id) |
|||
now_playing_details_favorite.setColorFilter(resources.getColor(R.color.controlForeground)) |
|||
} |
|||
|
|||
false -> { |
|||
favoriteRepository.addFavorite(track.id) |
|||
now_playing_details_favorite.setColorFilter(resources.getColor(R.color.colorFavorite)) |
|||
} |
|||
} |
|||
|
|||
track.favorite = !track.favorite |
|||
|
|||
favoriteRepository.fetch(Repository.Origin.Network.origin) |
|||
} |
|||
} |
|||
} |
|||
|
|||
is Event.StateChanged -> { |
|||
when (message.playing) { |
|||
true -> { |
|||
now_playing_toggle.icon = getDrawable(R.drawable.pause) |
|||
now_playing_details_toggle.icon = getDrawable(R.drawable.pause) |
|||
} |
|||
|
|||
false -> { |
|||
now_playing_toggle.icon = getDrawable(R.drawable.play) |
|||
now_playing_details_toggle.icon = getDrawable(R.drawable.play) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
GlobalScope.launch(Main) { |
|||
for ((current, duration, percent) in ProgressBus.asChannel()) { |
|||
now_playing_progress.progress = percent |
|||
now_playing_details_progress.progress = percent |
|||
|
|||
val currentMins = (current / 1000) / 60 |
|||
val currentSecs = (current / 1000) % 60 |
|||
|
|||
val durationMins = duration / 60 |
|||
val durationSecs = duration % 60 |
|||
|
|||
now_playing_details_progress_current.text = "%02d:%02d".format(currentMins, currentSecs) |
|||
now_playing_details_progress_duration.text = "%02d:%02d".format(durationMins, durationSecs) |
|||
} |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,66 @@ |
|||
package com.github.apognu.otter.activities |
|||
|
|||
import android.os.Bundle |
|||
import android.view.View |
|||
import androidx.appcompat.app.AppCompatActivity |
|||
import androidx.recyclerview.widget.LinearLayoutManager |
|||
import com.github.apognu.otter.R |
|||
import com.github.apognu.otter.adapters.TracksAdapter |
|||
import com.github.apognu.otter.repositories.Repository |
|||
import com.github.apognu.otter.repositories.SearchRepository |
|||
import com.github.apognu.otter.utils.untilNetwork |
|||
import kotlinx.android.synthetic.main.activity_search.* |
|||
|
|||
class SearchActivity : AppCompatActivity() { |
|||
private lateinit var adapter: TracksAdapter |
|||
|
|||
lateinit var repository: SearchRepository |
|||
|
|||
override fun onCreate(savedInstanceState: Bundle?) { |
|||
super.onCreate(savedInstanceState) |
|||
|
|||
setContentView(R.layout.activity_search) |
|||
|
|||
adapter = TracksAdapter(this).also { |
|||
results.layoutManager = LinearLayoutManager(this) |
|||
results.adapter = it |
|||
} |
|||
} |
|||
|
|||
override fun onResume() { |
|||
super.onResume() |
|||
|
|||
search.requestFocus() |
|||
|
|||
search.setOnQueryTextListener(object : androidx.appcompat.widget.SearchView.OnQueryTextListener { |
|||
override fun onQueryTextSubmit(query: String?): Boolean { |
|||
query?.let { |
|||
repository = SearchRepository(this@SearchActivity, it.toLowerCase()) |
|||
|
|||
search_spinner.visibility = View.VISIBLE |
|||
search_no_results.visibility = View.GONE |
|||
|
|||
adapter.data.clear() |
|||
adapter.notifyDataSetChanged() |
|||
|
|||
repository.fetch(Repository.Origin.Network.origin).untilNetwork { tracks -> |
|||
search_spinner.visibility = View.GONE |
|||
search_empty.visibility = View.GONE |
|||
|
|||
when (tracks.isEmpty()) { |
|||
true -> search_no_results.visibility = View.VISIBLE |
|||
false -> adapter.data = tracks.toMutableList() |
|||
} |
|||
|
|||
adapter.notifyDataSetChanged() |
|||
} |
|||
} |
|||
|
|||
return true |
|||
} |
|||
|
|||
override fun onQueryTextChange(newText: String?) = true |
|||
|
|||
}) |
|||
} |
|||
} |
@ -0,0 +1,121 @@ |
|||
package com.github.apognu.otter.activities |
|||
|
|||
import android.content.Intent |
|||
import android.content.SharedPreferences |
|||
import android.os.Bundle |
|||
import androidx.appcompat.app.AlertDialog |
|||
import androidx.appcompat.app.AppCompatActivity |
|||
import androidx.appcompat.app.AppCompatDelegate |
|||
import androidx.preference.ListPreference |
|||
import androidx.preference.Preference |
|||
import androidx.preference.PreferenceFragmentCompat |
|||
import androidx.preference.SeekBarPreference |
|||
import com.github.apognu.otter.R |
|||
import com.github.apognu.otter.utils.AppContext |
|||
import com.preference.PowerPreference |
|||
|
|||
class SettingsActivity : AppCompatActivity() { |
|||
override fun onCreate(savedInstanceState: Bundle?) { |
|||
super.onCreate(savedInstanceState) |
|||
|
|||
setContentView(R.layout.activity_settings) |
|||
|
|||
supportFragmentManager |
|||
.beginTransaction() |
|||
.replace( |
|||
R.id.container, |
|||
SettingsFragment() |
|||
) |
|||
.commit() |
|||
} |
|||
|
|||
fun getThemeResId(): Int = R.style.AppTheme |
|||
} |
|||
|
|||
class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedPreferenceChangeListener { |
|||
override fun onResume() { |
|||
super.onResume() |
|||
|
|||
preferenceScreen.sharedPreferences.registerOnSharedPreferenceChangeListener(this) |
|||
} |
|||
|
|||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { |
|||
setPreferencesFromResource(R.xml.settings, rootKey) |
|||
|
|||
updateValues() |
|||
} |
|||
|
|||
override fun onPreferenceTreeClick(preference: Preference?): Boolean { |
|||
when (preference?.key) { |
|||
"oss_licences" -> startActivity(Intent(activity, LicencesActivity::class.java)) |
|||
"logout" -> { |
|||
context?.let { context -> |
|||
AlertDialog.Builder(context) |
|||
.setTitle(context.getString(R.string.logout_title)) |
|||
.setMessage(context.getString(R.string.logout_content)) |
|||
.setPositiveButton(android.R.string.yes) { _, _ -> |
|||
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).clear() |
|||
|
|||
Intent(context, LoginActivity::class.java).apply { |
|||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK |
|||
|
|||
startActivity(this) |
|||
activity?.finish() |
|||
} |
|||
} |
|||
.setNegativeButton(android.R.string.no, null) |
|||
.show() |
|||
} |
|||
} |
|||
} |
|||
|
|||
updateValues() |
|||
|
|||
return super.onPreferenceTreeClick(preference) |
|||
} |
|||
|
|||
override fun onSharedPreferenceChanged(preferences: SharedPreferences?, key: String?) { |
|||
updateValues() |
|||
} |
|||
|
|||
private fun updateValues() { |
|||
(activity as? AppCompatActivity)?.let { activity -> |
|||
preferenceManager.findPreference<ListPreference>("media_quality")?.let { |
|||
it.summary = when (it.value) { |
|||
"quality" -> activity.getString(R.string.settings_media_quality_summary_quality) |
|||
"size" -> activity.getString(R.string.settings_media_quality_summary_size) |
|||
else -> activity.getString(R.string.settings_media_quality_summary_size) |
|||
} |
|||
} |
|||
|
|||
preferenceManager.findPreference<ListPreference>("night_mode")?.let { |
|||
when (it.value) { |
|||
"on" -> { |
|||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) |
|||
activity.delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES |
|||
|
|||
it.summary = getString(R.string.settings_night_mode_on_summary) |
|||
} |
|||
|
|||
"off" -> { |
|||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) |
|||
activity.delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_NO |
|||
|
|||
it.summary = getString(R.string.settings_night_mode_off_summary) |
|||
} |
|||
|
|||
else -> { |
|||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) |
|||
activity.delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM |
|||
|
|||
it.summary = getString(R.string.settings_night_mode_system_summary) |
|||
} |
|||
} |
|||
} |
|||
|
|||
preferenceManager.findPreference<SeekBarPreference>("media_cache_size")?.let { |
|||
it.summary = getString(R.string.settings_media_cache_size_summary, it.value) |
|||
} |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,55 @@ |
|||
package com.github.apognu.otter.adapters |
|||
|
|||
import android.content.Context |
|||
import android.view.LayoutInflater |
|||
import android.view.View |
|||
import android.view.ViewGroup |
|||
import androidx.recyclerview.widget.RecyclerView |
|||
import com.github.apognu.otter.R |
|||
import com.github.apognu.otter.fragments.FunkwhaleAdapter |
|||
import com.github.apognu.otter.utils.Album |
|||
import com.github.apognu.otter.utils.normalizeUrl |
|||
import com.squareup.picasso.Picasso |
|||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation |
|||
import kotlinx.android.synthetic.main.row_album.view.* |
|||
import kotlinx.android.synthetic.main.row_artist.view.art |
|||
|
|||
class AlbumsAdapter(val context: Context?, val listener: OnAlbumClickListener) : FunkwhaleAdapter<Album, AlbumsAdapter.ViewHolder>() { |
|||
interface OnAlbumClickListener { |
|||
fun onClick(view: View?, album: Album) |
|||
} |
|||
|
|||
override fun getItemCount() = data.size |
|||
|
|||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { |
|||
val view = LayoutInflater.from(context).inflate(R.layout.row_album, parent, false) |
|||
|
|||
return ViewHolder(view, listener).also { |
|||
view.setOnClickListener(it) |
|||
} |
|||
} |
|||
|
|||
override fun onBindViewHolder(holder: ViewHolder, position: Int) { |
|||
val album = data[position] |
|||
|
|||
Picasso.get() |
|||
.load(normalizeUrl(album.cover.original)) |
|||
.fit() |
|||
.placeholder(R.drawable.cover) |
|||
.transform(RoundedCornersTransformation(16, 0)) |
|||
.into(holder.art) |
|||
|
|||
holder.title.text = album.title |
|||
holder.artist.text = album.artist.name |
|||
} |
|||
|
|||
inner class ViewHolder(view: View, val listener: OnAlbumClickListener) : RecyclerView.ViewHolder(view), View.OnClickListener { |
|||
val art = view.art |
|||
val title = view.title |
|||
val artist = view.artist |
|||
|
|||
override fun onClick(view: View?) { |
|||
listener.onClick(view, data[layoutPosition]) |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,52 @@ |
|||
package com.github.apognu.otter.adapters |
|||
|
|||
import android.content.Context |
|||
import android.view.LayoutInflater |
|||
import android.view.View |
|||
import android.view.ViewGroup |
|||
import androidx.recyclerview.widget.RecyclerView |
|||
import com.github.apognu.otter.R |
|||
import com.github.apognu.otter.fragments.FunkwhaleAdapter |
|||
import com.github.apognu.otter.utils.Album |
|||
import com.github.apognu.otter.utils.normalizeUrl |
|||
import com.squareup.picasso.Picasso |
|||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation |
|||
import kotlinx.android.synthetic.main.row_album_grid.view.* |
|||
|
|||
class AlbumsGridAdapter(val context: Context?, val listener: OnAlbumClickListener) : FunkwhaleAdapter<Album, AlbumsGridAdapter.ViewHolder>() { |
|||
interface OnAlbumClickListener { |
|||
fun onClick(view: View?, album: Album) |
|||
} |
|||
|
|||
override fun getItemCount() = data.size |
|||
|
|||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { |
|||
val view = LayoutInflater.from(context).inflate(R.layout.row_album_grid, parent, false) |
|||
|
|||
return ViewHolder(view, listener).also { |
|||
view.setOnClickListener(it) |
|||
} |
|||
} |
|||
|
|||
override fun onBindViewHolder(holder: ViewHolder, position: Int) { |
|||
val album = data[position] |
|||
|
|||
Picasso.get() |
|||
.load(normalizeUrl(album.cover.original)) |
|||
.fit() |
|||
.placeholder(R.drawable.cover) |
|||
.transform(RoundedCornersTransformation(24, 0)) |
|||
.into(holder.cover) |
|||
|
|||
holder.title.text = album.title |
|||
} |
|||
|
|||
inner class ViewHolder(view: View, val listener: OnAlbumClickListener) : RecyclerView.ViewHolder(view), View.OnClickListener { |
|||
val cover = view.cover |
|||
val title = view.title |
|||
|
|||
override fun onClick(view: View?) { |
|||
listener.onClick(view, data[layoutPosition]) |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,65 @@ |
|||
package com.github.apognu.otter.adapters |
|||
|
|||
import android.content.Context |
|||
import android.view.LayoutInflater |
|||
import android.view.View |
|||
import android.view.ViewGroup |
|||
import androidx.recyclerview.widget.RecyclerView |
|||
import com.github.apognu.otter.R |
|||
import com.github.apognu.otter.fragments.FunkwhaleAdapter |
|||
import com.github.apognu.otter.utils.Artist |
|||
import com.github.apognu.otter.utils.normalizeUrl |
|||
import com.squareup.picasso.Picasso |
|||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation |
|||
import kotlinx.android.synthetic.main.row_artist.view.* |
|||
|
|||
class ArtistsAdapter(val context: Context?, val listener: OnArtistClickListener) : FunkwhaleAdapter<Artist, ArtistsAdapter.ViewHolder>() { |
|||
interface OnArtistClickListener { |
|||
fun onClick(holder: View?, artist: Artist) |
|||
} |
|||
|
|||
override fun getItemCount() = data.size |
|||
|
|||
override fun getItemId(position: Int) = data[position].id.toLong() |
|||
|
|||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { |
|||
val view = LayoutInflater.from(context).inflate(R.layout.row_artist, parent, false) |
|||
|
|||
return ViewHolder(view, listener).also { |
|||
view.setOnClickListener(it) |
|||
} |
|||
} |
|||
|
|||
override fun onBindViewHolder(holder: ViewHolder, position: Int) { |
|||
val artist = data[position] |
|||
|
|||
artist.albums?.let { albums -> |
|||
if (albums.isNotEmpty()) { |
|||
Picasso.get() |
|||
.load(normalizeUrl(albums[0].cover.original)) |
|||
.fit() |
|||
.placeholder(R.drawable.cover) |
|||
.transform(RoundedCornersTransformation(16, 0)) |
|||
.into(holder.art) |
|||
} |
|||
} |
|||
|
|||
holder.name.text = artist.name |
|||
|
|||
artist.albums?.let { |
|||
context?.let { |
|||
holder.albums.text = context.resources.getQuantityString(R.plurals.album_count, artist.albums.size, artist.albums.size) |
|||
} |
|||
} |
|||
} |
|||
|
|||
inner class ViewHolder(view: View, val listener: OnArtistClickListener) : RecyclerView.ViewHolder(view), View.OnClickListener { |
|||
val art = view.art |
|||
val name = view.name |
|||
val albums = view.albums |
|||
|
|||
override fun onClick(view: View?) { |
|||
listener.onClick(view, data[layoutPosition]) |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,44 @@ |
|||
package com.github.apognu.otter.adapters |
|||
|
|||
import androidx.fragment.app.Fragment |
|||
import androidx.fragment.app.FragmentManager |
|||
import androidx.fragment.app.FragmentPagerAdapter |
|||
import com.github.apognu.otter.R |
|||
import com.github.apognu.otter.fragments.AlbumsGridFragment |
|||
import com.github.apognu.otter.fragments.ArtistsFragment |
|||
import com.github.apognu.otter.fragments.FavoritesFragment |
|||
import com.github.apognu.otter.fragments.PlaylistsFragment |
|||
|
|||
class BrowseTabsAdapter(val context: Fragment, manager: FragmentManager) : FragmentPagerAdapter(manager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { |
|||
var tabs = mutableListOf<Fragment>() |
|||
|
|||
override fun getCount() = 4 |
|||
|
|||
override fun getItem(position: Int): Fragment { |
|||
tabs.getOrNull(position)?.let { |
|||
return it |
|||
} |
|||
|
|||
val fragment = when (position) { |
|||
0 -> ArtistsFragment() |
|||
1 -> AlbumsGridFragment() |
|||
2 -> PlaylistsFragment() |
|||
3 -> FavoritesFragment() |
|||
else -> ArtistsFragment() |
|||
} |
|||
|
|||
tabs.add(position, fragment) |
|||
|
|||
return fragment |
|||
} |
|||
|
|||
override fun getPageTitle(position: Int): String { |
|||
return when (position) { |
|||
0 -> context.getString(R.string.artists) |
|||
1 -> context.getString(R.string.albums) |
|||
2 -> context.getString(R.string.playlists) |
|||
3 -> context.getString(R.string.favorites) |
|||
else -> "" |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,183 @@ |
|||
package com.github.apognu.otter.adapters |
|||
|
|||
import android.annotation.SuppressLint |
|||
import android.content.Context |
|||
import android.graphics.Color |
|||
import android.graphics.Typeface |
|||
import android.graphics.drawable.ColorDrawable |
|||
import android.os.Build |
|||
import android.view.Gravity |
|||
import android.view.LayoutInflater |
|||
import android.view.View |
|||
import android.view.ViewGroup |
|||
import androidx.appcompat.widget.PopupMenu |
|||
import androidx.recyclerview.widget.ItemTouchHelper |
|||
import androidx.recyclerview.widget.RecyclerView |
|||
import com.github.apognu.otter.R |
|||
import com.github.apognu.otter.fragments.FunkwhaleAdapter |
|||
import com.github.apognu.otter.utils.* |
|||
import com.squareup.picasso.Picasso |
|||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation |
|||
import kotlinx.android.synthetic.main.row_track.view.* |
|||
import java.util.* |
|||
|
|||
class FavoritesAdapter(private val context: Context?, val favoriteListener: OnFavoriteListener, val fromQueue: Boolean = false) : FunkwhaleAdapter<Favorite, FavoritesAdapter.ViewHolder>() { |
|||
interface OnFavoriteListener { |
|||
fun onToggleFavorite(id: Int, state: Boolean) |
|||
} |
|||
|
|||
var currentTrack: Track? = null |
|||
|
|||
override fun getItemCount() = data.size |
|||
|
|||
override fun getItemId(position: Int): Long { |
|||
return data[position].track.id.toLong() |
|||
} |
|||
|
|||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { |
|||
val view = LayoutInflater.from(context).inflate(R.layout.row_track, parent, false) |
|||
|
|||
return ViewHolder(view, context).also { |
|||
view.setOnClickListener(it) |
|||
} |
|||
} |
|||
|
|||
@SuppressLint("NewApi") |
|||
override fun onBindViewHolder(holder: ViewHolder, position: Int) { |
|||
val favorite = data[position] |
|||
|
|||
Picasso.get() |
|||
.load(normalizeUrl(favorite.track.album.cover.original)) |
|||
.fit() |
|||
.placeholder(R.drawable.cover) |
|||
.transform(RoundedCornersTransformation(16, 0)) |
|||
.into(holder.cover) |
|||
|
|||
holder.title.text = favorite.track.title |
|||
holder.artist.text = favorite.track.artist.name |
|||
|
|||
Build.VERSION_CODES.P.onApi( |
|||
{ |
|||
holder.title.setTypeface(holder.title.typeface, Typeface.DEFAULT.weight) |
|||
holder.artist.setTypeface(holder.artist.typeface, Typeface.DEFAULT.weight) |
|||
}, |
|||
{ |
|||
holder.title.setTypeface(holder.title.typeface, Typeface.NORMAL) |
|||
holder.artist.setTypeface(holder.artist.typeface, Typeface.NORMAL) |
|||
}) |
|||
|
|||
|
|||
if (favorite.track == currentTrack || favorite.track.current) { |
|||
holder.title.setTypeface(holder.title.typeface, Typeface.BOLD) |
|||
holder.artist.setTypeface(holder.artist.typeface, Typeface.BOLD) |
|||
} |
|||
|
|||
context?.let { |
|||
when (favorite.track.favorite) { |
|||
true -> holder.favorite.setColorFilter(context.resources.getColor(R.color.colorFavorite)) |
|||
false -> holder.favorite.setColorFilter(context.resources.getColor(R.color.colorSelected)) |
|||
} |
|||
|
|||
holder.favorite.setOnClickListener { |
|||
favoriteListener.onToggleFavorite(favorite.track.id, !favorite.track.favorite) |
|||
|
|||
data.remove(favorite) |
|||
notifyItemRemoved(holder.adapterPosition) |
|||
} |
|||
} |
|||
|
|||
holder.actions.setOnClickListener { |
|||
context?.let { context -> |
|||
PopupMenu(context, holder.actions, Gravity.START, R.attr.actionOverflowMenuStyle, 0).apply { |
|||
inflate(if (fromQueue) R.menu.row_queue else R.menu.row_track) |
|||
|
|||
setOnMenuItemClickListener { |
|||
when (it.itemId) { |
|||
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(favorite.track))) |
|||
R.id.track_play_next -> CommandBus.send(Command.PlayNext(favorite.track)) |
|||
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(favorite.track)) |
|||
} |
|||
|
|||
true |
|||
} |
|||
|
|||
show() |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
fun onItemMove(oldPosition: Int, newPosition: Int) { |
|||
if (oldPosition < newPosition) { |
|||
for (i in oldPosition.rangeTo(newPosition - 1)) { |
|||
Collections.swap(data, i, i + 1) |
|||
} |
|||
} else { |
|||
for (i in newPosition.downTo(oldPosition)) { |
|||
Collections.swap(data, i, i - 1) |
|||
} |
|||
} |
|||
|
|||
notifyItemMoved(oldPosition, newPosition) |
|||
CommandBus.send(Command.MoveFromQueue(oldPosition, newPosition)) |
|||
} |
|||
|
|||
inner class ViewHolder(view: View, val context: Context?) : RecyclerView.ViewHolder(view), View.OnClickListener { |
|||
val handle = view.handle |
|||
val cover = view.cover |
|||
val title = view.title |
|||
val artist = view.artist |
|||
|
|||
val favorite = view.favorite |
|||
val actions = view.actions |
|||
|
|||
override fun onClick(view: View?) { |
|||
when (fromQueue) { |
|||
true -> CommandBus.send(Command.PlayTrack(layoutPosition)) |
|||
false -> { |
|||
data.subList(layoutPosition, data.size).plus(data.subList(0, layoutPosition)).apply { |
|||
CommandBus.send(Command.ReplaceQueue(this.map { it.track })) |
|||
|
|||
context.toast("All tracks were added to your queue") |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
inner class TouchHelperCallback : ItemTouchHelper.Callback() { |
|||
override fun isLongPressDragEnabled() = false |
|||
|
|||
override fun isItemViewSwipeEnabled() = false |
|||
|
|||
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) = |
|||
makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) |
|||
|
|||
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { |
|||
onItemMove(viewHolder.adapterPosition, target.adapterPosition) |
|||
|
|||
return true |
|||
} |
|||
|
|||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} |
|||
|
|||
@SuppressLint("NewApi") |
|||
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { |
|||
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) { |
|||
context?.let { |
|||
Build.VERSION_CODES.M.onApi( |
|||
{ viewHolder?.itemView?.background = ColorDrawable(context.resources.getColor(R.color.colorSelected, null)) }, |
|||
{ viewHolder?.itemView?.background = ColorDrawable(context.resources.getColor(R.color.colorSelected)) }) |
|||
} |
|||
} |
|||
|
|||
super.onSelectedChanged(viewHolder, actionState) |
|||
} |
|||
|
|||
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { |
|||
viewHolder.itemView.background = ColorDrawable(Color.TRANSPARENT) |
|||
|
|||
super.clearView(recyclerView, viewHolder) |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,179 @@ |
|||
package com.github.apognu.otter.adapters |
|||
|
|||
import android.annotation.SuppressLint |
|||
import android.content.Context |
|||
import android.graphics.Color |
|||
import android.graphics.Typeface |
|||
import android.graphics.drawable.ColorDrawable |
|||
import android.os.Build |
|||
import android.view.* |
|||
import androidx.appcompat.widget.PopupMenu |
|||
import androidx.recyclerview.widget.ItemTouchHelper |
|||
import androidx.recyclerview.widget.RecyclerView |
|||
import com.github.apognu.otter.R |
|||
import com.github.apognu.otter.fragments.FunkwhaleAdapter |
|||
import com.github.apognu.otter.utils.* |
|||
import com.squareup.picasso.Picasso |
|||
import jp.wasabeef.picasso.transformations.RoundedCornersTransformation |
|||
import kotlinx.android.synthetic.main.row_track.view.* |
|||
import java.util.* |
|||
|
|||
class PlaylistTracksAdapter(private val context: Context?, val fromQueue: Boolean = false) : FunkwhaleAdapter<PlaylistTrack, PlaylistTracksAdapter.ViewHolder>() { |
|||
private lateinit var touchHelper: ItemTouchHelper |
|||
|
|||
var currentTrack: Track? = null |
|||
|
|||
override fun getItemCount() = data.size |
|||
|
|||
override fun getItemId(position: Int): Long { |
|||
return data[position].track.id.toLong() |
|||
} |
|||
|
|||
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { |
|||
super.onAttachedToRecyclerView(recyclerView) |
|||
|
|||
if (fromQueue) { |
|||
touchHelper = ItemTouchHelper(TouchHelperCallback()).also { |
|||
it.attachToRecyclerView(recyclerView) |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { |
|||
val view = LayoutInflater.from(context).inflate(R.layout.row_track, parent, false) |
|||
|
|||
return ViewHolder(view, context).also { |
|||
view.setOnClickListener(it) |
|||
} |
|||
} |
|||
|
|||
@SuppressLint("NewApi") |
|||
override fun onBindViewHolder(holder: ViewHolder, position: Int) { |
|||
val track = data[position] |
|||
|
|||
Picasso.get() |
|||
.load(normalizeUrl(track.track.album.cover.original)) |
|||
.fit() |
|||
.placeholder(R.drawable.cover) |
|||
.transform(RoundedCornersTransformation(16, 0)) |
|||
.into(holder.cover) |
|||
|
|||
holder.title.text = track.track.title |
|||
holder.artist.text = track.track.artist.name |
|||
|
|||
Build.VERSION_CODES.P.onApi( |
|||
{ |
|||
holder.title.setTypeface(holder.title.typeface, Typeface.DEFAULT.weight) |
|||
holder.artist.setTypeface(holder.artist.typeface, Typeface.DEFAULT.weight) |
|||
}, |
|||
{ |
|||
holder.title.setTypeface(holder.title.typeface, Typeface.NORMAL) |
|||
holder.artist.setTypeface(holder.artist.typeface, Typeface.NORMAL) |
|||
}) |
|||
|
|||
|
|||
if (track.track == currentTrack || track.track.current) { |
|||
holder.title.setTypeface(holder.title.typeface, Typeface.BOLD) |
|||
holder.artist.setTypeface(holder.artist.typeface, Typeface.BOLD) |
|||
} |
|||
|
|||
holder.actions.setOnClickListener { |
|||
context?.let { context -> |
|||
PopupMenu(context, holder.actions, Gravity.START, R.attr.actionOverflowMenuStyle, 0).apply { |
|||
inflate(if (fromQueue) R.menu.row_queue else R.menu.row_track) |
|||
|
|||
setOnMenuItemClickListener { |
|||
when (it.itemId) { |
|||
R.id.track_add_to_queue -> CommandBus.send(Command.AddToQueue(listOf(track.track))) |
|||
R.id.track_play_next -> CommandBus.send(Command.PlayNext(track.track)) |
|||
R.id.queue_remove -> CommandBus.send(Command.RemoveFromQueue(track.track)) |
|||
} |
|||
|
|||
true |
|||
} |
|||
|
|||
show() |
|||
} |
|||
} |
|||
} |
|||
|
|||
if (fromQueue) { |
|||
holder.handle.visibility = View.VISIBLE |
|||
|
|||
holder.handle.setOnTouchListener { _, event -> |
|||
if (event.actionMasked == MotionEvent.ACTION_DOWN) { |
|||
touchHelper.startDrag(holder) |
|||
} |
|||
|
|||
true |
|||
} |
|||
} |
|||
} |
|||
|
|||
fun onItemMove(oldPosition: Int, newPosition: Int) { |
|||
if (oldPosition < newPosition) { |
|||
for (i in oldPosition.rangeTo(newPosition - 1)) { |
|||
Collections.swap(data, i, i + 1) |
|||
} |
|||
} else { |
|||
for (i in newPosition.downTo(oldPosition)) { |
|||
Collections.swap(data, i, i - 1) |
|||
} |
|||
} |
|||
|
|||
notifyItemMoved(oldPosition, newPosition) |
|||
CommandBus.send(Command.MoveFromQueue(oldPosition, newPosition)) |
|||
} |
|||
|
|||
inner class ViewHolder(view: View, val context: Context?) : RecyclerView.ViewHolder(view), View.OnClickListener { |
|||
val handle = view.handle |
|||
val cover = view.cover |
|||
val title = view.title |
|||
val artist = view.artist |
|||
val actions = view.actions |
|||
|
|||
override fun onClick(view: View?) { |
|||
when (fromQueue) { |
|||
true -> CommandBus.send(Command.PlayTrack(layoutPosition)) |
|||
false -> { |
|||
data.subList(layoutPosition, data.size).plus(data.subList(0, layoutPosition)).apply { |
|||
CommandBus.send(Command.ReplaceQueue(this.map { it.track })) |
|||
|
|||
context.toast("All tracks were added to your queue") |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
inner class TouchHelperCallback : ItemTouchHelper.Callback() { |
|||
override fun isLongPressDragEnabled() = false |
|||
|
|||
override fun isItemViewSwipeEnabled() = false |
|||
|
|||
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) = |
|||
makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) |
|||
|
|||
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { |
|||
onItemMove(viewHolder.adapterPosition, target.adapterPosition) |
|||
|
|||
return true |
|||
} |
|||
|
|||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} |
|||
|
|||
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { |
|||
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) { |
|||
viewHolder?.itemView?.background = ColorDrawable(Color.argb(255, 100, 100, 100)) |
|||
} |
|||
|
|||
super.onSelectedChanged(viewHolder, actionState) |
|||
} |
|||
|
|||
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { |
|||
viewHolder.itemView.background = ColorDrawable(Color.TRANSPARENT) |
|||
|
|||
super.clearView(recyclerView, viewHolder) |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,65 @@ |
|||
package com.github.apognu.otter.adapters |
|||
|
|||
import android.content.Context |
|||
import android.view.LayoutInflater |
|||
import android.view.View |
|||
import android.view.ViewGroup |
|||
import androidx.recyclerview.widget.RecyclerView |
|||
import com.github.apognu.otter.R |
|||
import com.github.apognu.otter.fragments.FunkwhaleAdapter |
|||
import com.github.apognu.otter.utils.Playlist |
|||
import com.squareup.picasso.Picasso |
|||
import kotlinx.android.synthetic.main.row_playlist.view.* |
|||
|
|||
class PlaylistsAdapter(val context: Context?, val listener: OnPlaylistClickListener) : FunkwhaleAdapter<Playlist, PlaylistsAdapter.ViewHolder>() { |
|||
interface OnPlaylistClickListener { |
|||
fun onClick(holder: View?, playlist: Playlist) |
|||
} |
|||
|
|||
override fun getItemCount() = data.size |
|||
|
|||
override fun getItemId(position: Int) = data[position].id.toLong() |
|||
|
|||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { |
|||
val view = LayoutInflater.from(context).inflate(R.layout.row_playlist, parent, false) |
|||
|
|||
return ViewHolder(view, listener).also { |
|||
view.setOnClickListener(it) |
|||
} |
|||
} |
|||
|
|||
override fun onBindViewHolder(holder: ViewHolder, position: Int) { |
|||
val playlist = data[position] |
|||
|
|||
holder.name.text = playlist.name |
|||
holder.summary.text = "${playlist.tracks_count} tracks • ${playlist.duration} seconds" |
|||
|
|||
playlist.album_covers.shuffled().take(4).forEachIndexed { index, url -> |
|||
val imageView = when (index) { |
|||
0 -> holder.cover_top_left |
|||
1 -> holder.cover_top_right |
|||
2 -> holder.cover_bottom_left |
|||
3 -> holder.cover_bottom_right |
|||
else -> holder.cover_top_left |
|||
} |
|||
|
|||
Picasso.get() |
|||
.load(url) |
|||
.into(imageView) |
|||
} |
|||
} |
|||
|
|||
inner class ViewHolder(view: View, val listener: OnPlaylistClickListener) : RecyclerView.ViewHolder(view), View.OnClickListener { |
|||
val name = view.name |
|||
val summary = view.summary |
|||
|
|||
val cover_top_left = view.cover_top_left |
|||
val cover_top_right = view.cover_top_right |
|||
val cover_bottom_left = view.cover_bottom_left |
|||
val cover_bottom_right = view.cover_bottom_right |
|||
|
|||
override fun onClick(view: View?) { |
|||
listener.onClick(view, data[layoutPosition]) |
|||
} |
|||
} |
|||