diff --git a/app/build.gradle b/app/build.gradle index bb6f26b..a24b95c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,4 +1,5 @@ apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' android { compileSdkVersion rootProject.ext.compileSdkVersion as Integer @@ -24,8 +25,10 @@ android { dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation "androidx.core:core-ktx:1.3.1" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - testImplementation 'junit:junit:4.12' + testImplementation 'junit:junit:4.13.1' implementation project(':audiowidget') // Support @@ -35,3 +38,7 @@ dependencies { // Glide implementation rootProject.ext.glideDependencies.glide } + +repositories { + mavenCentral() +} diff --git a/app/src/main/java/com/cleveroad/audiowidget/example/BaseAsyncTaskLoader.java b/app/src/main/java/com/cleveroad/audiowidget/example/BaseAsyncTaskLoader.kt similarity index 61% rename from app/src/main/java/com/cleveroad/audiowidget/example/BaseAsyncTaskLoader.java rename to app/src/main/java/com/cleveroad/audiowidget/example/BaseAsyncTaskLoader.kt index 173c446..f55da14 100644 --- a/app/src/main/java/com/cleveroad/audiowidget/example/BaseAsyncTaskLoader.java +++ b/app/src/main/java/com/cleveroad/audiowidget/example/BaseAsyncTaskLoader.kt @@ -1,104 +1,87 @@ -package com.cleveroad.audiowidget.example; +package com.cleveroad.audiowidget.example -import android.content.Context; - -import androidx.loader.content.AsyncTaskLoader; +import android.content.Context +import androidx.loader.content.AsyncTaskLoader /** * Base AsyncTaskLoader implementation */ -abstract class BaseAsyncTaskLoader extends AsyncTaskLoader { - protected T mData; - - public BaseAsyncTaskLoader(Context context) { - super(context); - } +internal abstract class BaseAsyncTaskLoader(context: Context) : AsyncTaskLoader(context) { + private var mData: T? = null /** * Called when there is new data to deliver to the client. The * super class will take care of delivering it; the implementation * here just adds a little more logic. */ - @Override - public void deliverResult(T data) { - if (isReset()) { + override fun deliverResult(data: T?) { + if (isReset) { // An async query came in while the loader is stopped. We // don't need the result. - if (data != null) { - onReleaseResources(data); - } + data?.let { onReleaseResources(it) } } - T oldData = mData; - mData = data; - - if (isStarted()) { + val oldData = mData + mData = data + if (isStarted) { // If the Loader is currently started, we can immediately // deliver its results. - super.deliverResult(data); + super.deliverResult(data) } // At this point we can release the resources associated with // 'oldData' if needed; now that the new result is delivered we // know that it is no longer in use. - if (oldData != null) { - onReleaseResources(oldData); - } + oldData?.let { onReleaseResources(it) } } /** * Handles a request to start the Loader. */ - @Override - protected void onStartLoading() { + override fun onStartLoading() { if (mData != null) { // If we currently have a result available, deliver it // immediately. - deliverResult(mData); + deliverResult(mData) } - if (takeContentChanged() || mData == null) { // If the data has changed since the last time it was loaded // or is not currently available, start a load. - forceLoad(); + forceLoad() } } /** * Handles a request to stop the Loader. */ - @Override - protected void onStopLoading() { + override fun onStopLoading() { // Attempt to cancel the current load task if possible. - cancelLoad(); + cancelLoad() } /** * Handles a request to cancel a load. */ - @Override - public void onCanceled(T data) { - super.onCanceled(data); - + override fun onCanceled(data: T?) { + super.onCanceled(data) // At this point we can release the resources associated with 'apps' // if needed. - onReleaseResources(data); + onReleaseResources(data) } /** * Handles a request to completely reset the Loader. */ - @Override - protected void onReset() { - super.onReset(); + override fun onReset() { + super.onReset() // Ensure the loader is stopped - onStopLoading(); + onStopLoading() // At this point we can release the resources associated with 'apps' // if needed. if (mData != null) { - onReleaseResources(mData); - mData = null; + onReleaseResources(mData) + mData = null } } @@ -106,8 +89,8 @@ protected void onReset() { * Helper function to take care of releasing resources associated * with an actively loaded data set. */ - protected void onReleaseResources(T apps) { + private fun onReleaseResources(apps: T?) { // For a simple List<> there is nothing to do. For something // like a Cursor, we would close it here. } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/cleveroad/audiowidget/example/BaseFilter.java b/app/src/main/java/com/cleveroad/audiowidget/example/BaseFilter.java deleted file mode 100644 index f36383c..0000000 --- a/app/src/main/java/com/cleveroad/audiowidget/example/BaseFilter.java +++ /dev/null @@ -1,155 +0,0 @@ -package com.cleveroad.audiowidget.example; - -import android.content.Context; -import android.database.DataSetObserver; -import android.text.Spannable; -import android.text.SpannableString; -import android.text.style.ForegroundColorSpan; -import android.widget.Filter; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import androidx.recyclerview.widget.RecyclerView; - -import java.util.Collections; -import java.util.List; - -/** - * Base filter that can be easily integrated with {@link BaseRecyclerViewAdapter}.

- * For iterating through adapter's data use {@link #getNonFilteredCount()} and {@link #getNonFilteredItem(int)}. - */ -abstract class BaseFilter extends Filter { - - private FilterableAdapter adapter; - private CharSequence lastConstraint; - private FilterResults lastResults; - private DataSetObserver dataSetObserver; - private RecyclerView.AdapterDataObserver adapterDataObserver; - private int highlightColor; - - public BaseFilter(@NonNull Context context) throws AssertionError { - highlightColor = ContextCompat.getColor(context, R.color.colorAccent); - } - - public BaseFilter(int highlightColor) throws AssertionError { - setHighlightColor(highlightColor); - } - - public BaseFilter setHighlightColor(int highlightColor) throws AssertionError { - this.highlightColor = highlightColor; - return this; - } - - void init(@NonNull FilterableAdapter adapter) throws AssertionError { - this.adapter = adapter; - dataSetObserver = new DataSetObserver() { - @Override - public void onChanged() { - super.onChanged(); - if (!isFiltered()) - return; - performFiltering(lastConstraint); - } - - @Override - public void onInvalidated() { - super.onInvalidated(); - if (!isFiltered()) - return; - lastResults = new FilterResults(); - lastResults.count = -1; - lastResults.values = Collections.emptyList(); - } - }; - adapterDataObserver = new RecyclerView.AdapterDataObserver() { - @Override - public void onChanged() { - super.onChanged(); - if (!isFiltered()) - return; - performFiltering(lastConstraint); - } - }; - } - - protected int getNonFilteredCount() { - return adapter.getNonFilteredCount(); - } - - protected T getNonFilteredItem(int position) { - return adapter.getNonFilteredItem(position); - } - - @NonNull - @Override - protected final FilterResults performFiltering(CharSequence constraint) { - return performFilteringImpl(constraint); - } - - /** - * Perform filtering as always. Returned {@link FilterResults} object must be non-null. - * @param constraint the constraint used to filter the data - * @return filtering results.
- * You can set {@link FilterResults#count} to -1 to specify that no filtering was applied.
- * {@link FilterResults#values} must be instance of {@link List}. - */ - @NonNull - protected abstract FilterResults performFilteringImpl(CharSequence constraint); - - @Override - protected final void publishResults(CharSequence constraint, FilterResults results) throws AssertionError { - lastConstraint = constraint; - lastResults = results; - adapter.notifyDataSetChanged(); - } - - public boolean isFiltered() { - return lastResults != null && lastResults.count > -1; - } - - @SuppressWarnings("unchecked") - public T getItem(int position) throws ArrayIndexOutOfBoundsException { - return ((List)lastResults.values).get(position); - } - - public int getCount() { - return lastResults.count; - } - - public DataSetObserver getDataSetObserver() { - return dataSetObserver; - } - - public RecyclerView.AdapterDataObserver getAdapterDataObserver() { - return adapterDataObserver; - } - - public Spannable highlightFilteredSubstring(String name) { - SpannableString string = new SpannableString(name); - if (!isFiltered()) - return string; - String filteredString = lastConstraint.toString().trim().toLowerCase(); - String lowercase = name.toLowerCase(); - int length = filteredString.length(); - int index = -1, prevIndex; - do { - prevIndex = index; - index = lowercase.indexOf(filteredString, prevIndex + 1); - if (index == -1) { - break; - } - string.setSpan(new ForegroundColorSpan(highlightColor), index, index + length, 0); - } while (true); - return string; - } - - interface FilterableAdapter { - int getNonFilteredCount(); - T getNonFilteredItem(int position); - void notifyDataSetChanged(); - void withFilter(@Nullable BaseFilter filter); - boolean isFiltered(); - Spannable highlightFilteredSubstring(String text); - } -} diff --git a/app/src/main/java/com/cleveroad/audiowidget/example/BaseFilter.kt b/app/src/main/java/com/cleveroad/audiowidget/example/BaseFilter.kt new file mode 100644 index 0000000..e2fd706 --- /dev/null +++ b/app/src/main/java/com/cleveroad/audiowidget/example/BaseFilter.kt @@ -0,0 +1,129 @@ +package com.cleveroad.audiowidget.example + +import android.content.Context +import android.database.DataSetObserver +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.widget.Filter +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver + +/** + * Base filter that can be easily integrated with [BaseRecyclerViewAdapter].



+ * For iterating through adapter's data use [.getNonFilteredCount] and [.getNonFilteredItem]. + */ +internal abstract class BaseFilter : Filter { + private lateinit var adapter: FilterableAdapter + private var lastConstraint: CharSequence? = null + private var lastResults: FilterResults? = null + var dataSetObserver: DataSetObserver? = null + private set + var adapterDataObserver: AdapterDataObserver? = null + private set + private var highlightColor = 0 + + constructor(context: Context) { + highlightColor = ContextCompat.getColor(context, R.color.colorAccent) + } + + constructor(highlightColor: Int) { + setHighlightColor(highlightColor) + } + + fun setHighlightColor(highlightColor: Int): BaseFilter<*> { + this.highlightColor = highlightColor + return this + } + + fun init(adapter: FilterableAdapter) { + this.adapter = adapter + dataSetObserver = object : DataSetObserver() { + override fun onChanged() { + super.onChanged() + if (!isFiltered) return + performFiltering(lastConstraint!!) + } + + override fun onInvalidated() { + super.onInvalidated() + if (!isFiltered) return + lastResults = FilterResults().also { + it.count = -1 + it.values = emptyList() + } + } + } + adapterDataObserver = object : AdapterDataObserver() { + override fun onChanged() { + super.onChanged() + if (!isFiltered) return + performFiltering(lastConstraint!!) + } + } + } + + protected val nonFilteredCount: Int + get() = adapter.nonFilteredCount + + protected fun getNonFilteredItem(position: Int): T { + return adapter.getNonFilteredItem(position) + } + + override fun performFiltering(constraint: CharSequence): FilterResults { + return performFilteringImpl(constraint) + } + + /** + * Perform filtering as always. Returned [FilterResults] object must be non-null. + * @param constraint the constraint used to filter the data + * @return filtering results.

+ * You can set [FilterResults.count] to -1 to specify that no filtering was applied.

+ * [FilterResults.values] must be instance of [List]. + */ + protected abstract fun performFilteringImpl(constraint: CharSequence): FilterResults + + override fun publishResults(constraint: CharSequence, results: FilterResults) { + lastConstraint = constraint + lastResults = results + adapter.notifyDataSetChanged() + } + + val isFiltered: Boolean + get() = lastResults != null && lastResults!!.count > -1 + + fun getItem(position: Int): T { + return (lastResults!!.values as List)[position] + } + + val count: Int + get() = lastResults!!.count + + fun highlightFilteredSubstring(name: String): Spannable { + val string = SpannableString(name) + if (!isFiltered) return string + val filteredString = lastConstraint.toString().trim { it <= ' ' }.toLowerCase() + val lowercase = name.toLowerCase() + val length = filteredString.length + var index = -1 + var prevIndex: Int + do { + prevIndex = index + index = lowercase.indexOf(filteredString, prevIndex + 1) + if (index == -1) { + break + } + string.setSpan(ForegroundColorSpan(highlightColor), index, index + length, 0) + } while (true) + return string + } + + internal interface FilterableAdapter { + val nonFilteredCount: Int + fun getNonFilteredItem(position: Int): T + fun notifyDataSetChanged() + fun withFilter(filter: BaseFilter?) + val isFiltered: Boolean + fun highlightFilteredSubstring(text: String?): Spannable? + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cleveroad/audiowidget/example/BaseRecyclerViewAdapter.java b/app/src/main/java/com/cleveroad/audiowidget/example/BaseRecyclerViewAdapter.java deleted file mode 100644 index cf6b0f3..0000000 --- a/app/src/main/java/com/cleveroad/audiowidget/example/BaseRecyclerViewAdapter.java +++ /dev/null @@ -1,118 +0,0 @@ -package com.cleveroad.audiowidget.example; - -import android.content.Context; -import android.text.Spannable; -import android.text.SpannableString; -import android.view.LayoutInflater; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; - -/** - * Base adapter for recycler view - */ -abstract class BaseRecyclerViewAdapter - extends RecyclerView.Adapter implements BaseFilter.FilterableAdapter { - - private final Context context; - private final LayoutInflater inflater; - private final List data; - private BaseFilter filter; - - public BaseRecyclerViewAdapter(@NonNull final Context context) { - this.context = context.getApplicationContext(); - this.inflater = LayoutInflater.from(context); - data = new ArrayList<>(); - } - - public BaseRecyclerViewAdapter(@NonNull final Context context, @NonNull List data) { - this.context = context.getApplicationContext(); - this.inflater = LayoutInflater.from(context); - this.data = new ArrayList<>(data); - } - - @Override - public void withFilter(@Nullable BaseFilter filter) { - if (this.filter != null) - unregisterAdapterDataObserver(this.filter.getAdapterDataObserver()); - this.filter = filter; - if (this.filter != null) { - this.filter.init(this); - registerAdapterDataObserver(this.filter.getAdapterDataObserver()); - } - } - - protected Context getContext() { - return context; - } - - @Override - public int getItemCount() { - if (filter != null && filter.isFiltered()) - return filter.getCount(); - return data.size(); - } - - public TData getItem(final int position) throws ArrayIndexOutOfBoundsException { - if (filter != null && filter.isFiltered()) - return filter.getItem(position); - return data.get(position); - } - - @Override - public boolean isFiltered() { - return filter != null && filter.isFiltered(); - } - - @Override - public Spannable highlightFilteredSubstring(String text) { - return isFiltered() ? filter.highlightFilteredSubstring(text) : new SpannableString(text); - } - - @Override - public TData getNonFilteredItem(int position) { - return data.get(position); - } - - @Override - public int getNonFilteredCount() { - return data.size(); - } - - public boolean add(TData object) { - return data.add(object); - } - - public boolean remove(TData object) { - return data.remove(object); - } - - public TData remove(int position) { - return data.remove(position); - } - - public void clear() { - data.clear(); - } - - public boolean addAll(@NonNull Collection collection) { - return data.addAll(collection); - } - - public BaseFilter getFilter() { - return filter; - } - - public List getSnapshot() { - return new ArrayList<>(data); - } - - protected LayoutInflater getInflater() { - return inflater; - } -} diff --git a/app/src/main/java/com/cleveroad/audiowidget/example/BaseRecyclerViewAdapter.kt b/app/src/main/java/com/cleveroad/audiowidget/example/BaseRecyclerViewAdapter.kt new file mode 100644 index 0000000..4ddffc7 --- /dev/null +++ b/app/src/main/java/com/cleveroad/audiowidget/example/BaseRecyclerViewAdapter.kt @@ -0,0 +1,93 @@ +package com.cleveroad.audiowidget.example + +import android.content.Context +import android.text.Spannable +import android.text.SpannableString +import android.view.LayoutInflater +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.cleveroad.audiowidget.example.BaseFilter.FilterableAdapter +import java.util.* + +/** + * Base adapter for recycler view + */ +internal abstract class BaseRecyclerViewAdapter + : RecyclerView.Adapter, FilterableAdapter { + protected val context: Context + protected val inflater: LayoutInflater + private val data: MutableList + + var filter: BaseFilter? = null + private set + + val snapshot: List + get() = ArrayList(data) + + constructor(context: Context) { + this.context = context.applicationContext + inflater = LayoutInflater.from(context) + data = ArrayList() + } + + constructor(context: Context, data: List) { + this.context = context.applicationContext + inflater = LayoutInflater.from(context) + this.data = ArrayList(data) + } + + override fun withFilter(filter: BaseFilter?) { + this.filter?.adapterDataObserver?.let { + unregisterAdapterDataObserver(it) + } + this.filter = filter + this.filter?.let { + it.init(this) + registerAdapterDataObserver(it.adapterDataObserver!!) + } + } + + override fun getItemCount(): Int { + val filter = this.filter + return if (filter?.isFiltered == true) filter.count else data.size + } + + fun getItem(position: Int): TData { + val filter = this.filter + return if (filter?.isFiltered == true) filter.getItem(position) else data[position] + } + + override val isFiltered: Boolean + get() = filter?.isFiltered == true + + override fun highlightFilteredSubstring(text: String?): Spannable? { + return if (isFiltered) filter!!.highlightFilteredSubstring(text!!) else SpannableString(text) + } + + override fun getNonFilteredItem(position: Int): TData { + return data[position] + } + + override val nonFilteredCount: Int + get() = data.size + + fun add(`object`: TData): Boolean { + return data.add(`object`) + } + + fun remove(`object`: TData): Boolean { + return data.remove(`object`) + } + + fun remove(position: Int): TData { + return data.removeAt(position) + } + + fun clear() { + data.clear() + } + + fun addAll(collection: Collection): Boolean { + return data.addAll(collection) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cleveroad/audiowidget/example/ClickItemTouchListener.java b/app/src/main/java/com/cleveroad/audiowidget/example/ClickItemTouchListener.java deleted file mode 100644 index 82e4e83..0000000 --- a/app/src/main/java/com/cleveroad/audiowidget/example/ClickItemTouchListener.java +++ /dev/null @@ -1,155 +0,0 @@ -package com.cleveroad.audiowidget.example; - -import android.content.Context; -import android.os.Build; -import android.view.GestureDetector; -import android.view.GestureDetector.SimpleOnGestureListener; -import android.view.MotionEvent; -import android.view.View; - -import androidx.recyclerview.widget.RecyclerView; - -/** - * Helper class for detecting click on RecyclerView's item - */ -abstract class ClickItemTouchListener implements RecyclerView.OnItemTouchListener { - - private final GestureDetector mGestureDetector; - - ClickItemTouchListener(RecyclerView hostView) { - mGestureDetector = new ItemClickGestureDetector(hostView.getContext(), - new ItemClickGestureListener(hostView)); - } - - private boolean isAttachedToWindow(RecyclerView hostView) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { - return hostView.isAttachedToWindow(); - } else { - return (hostView.getHandler() != null); - } - } - - private boolean hasAdapter(RecyclerView hostView) { - return (hostView.getAdapter() != null); - } - - @Override - public boolean onInterceptTouchEvent(RecyclerView recyclerView, MotionEvent event) { - if (!isAttachedToWindow(recyclerView) || !hasAdapter(recyclerView)) { - return false; - } - - mGestureDetector.onTouchEvent(event); - return false; - } - - @Override - public void onTouchEvent(RecyclerView recyclerView, MotionEvent event) { - // We can silently track tap and and long presses by silently - // intercepting touch events in the host RecyclerView. - } - - abstract boolean performItemClick(RecyclerView parent, View view, int position, long id); - abstract boolean performItemLongClick(RecyclerView parent, View view, int position, long id); - - private static class ItemClickGestureDetector extends GestureDetector { - private final ItemClickGestureListener mGestureListener; - - public ItemClickGestureDetector(Context context, ItemClickGestureListener listener) { - super(context, listener); - mGestureListener = listener; - } - - @Override - public boolean onTouchEvent(MotionEvent event) { - final boolean handled = super.onTouchEvent(event); - -// final int action = event.getAction() & MotionEventCompat.ACTION_MASK; -// if (action == MotionEvent.ACTION_UP) { -// mGestureListener.dispatchSingleTapUpIfNeeded(event); -// } - - return handled; - } - } - - private class ItemClickGestureListener extends SimpleOnGestureListener { - private final RecyclerView mHostView; - private View mTargetChild; - - public ItemClickGestureListener(RecyclerView hostView) { - mHostView = hostView; - } - - public void dispatchSingleTapUpIfNeeded(MotionEvent event) { - // When the long press hook is called but the long press listener - // returns false, the target child will be left around to be - // handled later. In this case, we should still treat the gesture - // as potential item click. - if (mTargetChild != null) { - onSingleTapUp(event); - } - } - - @Override - public boolean onDown(MotionEvent event) { - final int x = (int) event.getX(); - final int y = (int) event.getY(); - - mTargetChild = mHostView.findChildViewUnder(x, y); - return (mTargetChild != null); - } - - @Override - public void onShowPress(MotionEvent event) { - if (mTargetChild != null) { - mTargetChild.setPressed(true); - } - } - - @Override - public boolean onSingleTapUp(MotionEvent event) { - boolean handled = false; - - if (mTargetChild != null) { - mTargetChild.setPressed(false); - - final int position = mHostView.getChildPosition(mTargetChild); - final long id = mHostView.getAdapter().getItemId(position); - handled = performItemClick(mHostView, mTargetChild, position, id); - - mTargetChild = null; - } - - return handled; - } - - @Override - public boolean onScroll(MotionEvent event, MotionEvent event2, float v, float v2) { - if (mTargetChild != null) { - mTargetChild.setPressed(false); - mTargetChild = null; - - return true; - } - - return false; - } - - @Override - public void onLongPress(MotionEvent event) { - if (mTargetChild == null) { - return; - } - - final int position = mHostView.getChildPosition(mTargetChild); - final long id = mHostView.getAdapter().getItemId(position); - final boolean handled = performItemLongClick(mHostView, mTargetChild, position, id); - - if (handled) { - mTargetChild.setPressed(false); - mTargetChild = null; - } - } - } -} diff --git a/app/src/main/java/com/cleveroad/audiowidget/example/ClickItemTouchListener.kt b/app/src/main/java/com/cleveroad/audiowidget/example/ClickItemTouchListener.kt new file mode 100644 index 0000000..8948c6f --- /dev/null +++ b/app/src/main/java/com/cleveroad/audiowidget/example/ClickItemTouchListener.kt @@ -0,0 +1,130 @@ +package com.cleveroad.audiowidget.example + +import android.content.Context +import android.os.Build +import android.view.GestureDetector +import android.view.GestureDetector.SimpleOnGestureListener +import android.view.MotionEvent +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener + +/** + * Helper class for detecting click on RecyclerView's item + */ +internal abstract class ClickItemTouchListener(hostView: RecyclerView) : OnItemTouchListener { + private val mGestureDetector: GestureDetector + private fun isAttachedToWindow(hostView: RecyclerView): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + hostView.isAttachedToWindow + } else { + hostView.handler != null + } + } + + private fun hasAdapter(hostView: RecyclerView): Boolean { + return hostView.adapter != null + } + + override fun onInterceptTouchEvent(recyclerView: RecyclerView, event: MotionEvent): Boolean { + if (!isAttachedToWindow(recyclerView) || !hasAdapter(recyclerView)) { + return false + } + mGestureDetector.onTouchEvent(event) + return false + } + + override fun onTouchEvent(recyclerView: RecyclerView, event: MotionEvent) { + // We can silently track tap and and long presses by silently + // intercepting touch events in the host RecyclerView. + } + + abstract fun performItemClick( + parent: RecyclerView?, + view: View, + position: Int, + id: Long + ): Boolean + + abstract fun performItemLongClick( + parent: RecyclerView?, + view: View, + position: Int, + id: Long + ): Boolean + + private class ItemClickGestureDetector( + context: Context?, + mGestureListener: ItemClickGestureListener + ) : GestureDetector(context, mGestureListener) + + private inner class ItemClickGestureListener(private val mHostView: RecyclerView) : + SimpleOnGestureListener() { + private var mTargetChild: View? = null + fun dispatchSingleTapUpIfNeeded(event: MotionEvent) { + // When the long press hook is called but the long press listener + // returns false, the target child will be left around to be + // handled later. In this case, we should still treat the gesture + // as potential item click. + if (mTargetChild != null) { + onSingleTapUp(event) + } + } + + override fun onDown(event: MotionEvent): Boolean { + val x = event.x.toInt() + val y = event.y.toInt() + mTargetChild = mHostView.findChildViewUnder(x.toFloat(), y.toFloat()) + return mTargetChild != null + } + + override fun onShowPress(event: MotionEvent) { + mTargetChild?.let { + it.isPressed = true + } + } + + override fun onSingleTapUp(event: MotionEvent): Boolean { + var handled = false + mTargetChild?.let { + it.isPressed = false + val position = mHostView.getChildLayoutPosition(it) + val id = mHostView.adapter!!.getItemId(position) + handled = performItemClick(mHostView, it, position, id) + mTargetChild = null + } + return handled + } + + override fun onScroll( + event: MotionEvent, + event2: MotionEvent, + v: Float, + v2: Float + ): Boolean { + return mTargetChild?.let { + it.isPressed = false + mTargetChild = null + true + } ?: false + } + + override fun onLongPress(event: MotionEvent) { + val target = mTargetChild ?: return + val position = mHostView.getChildLayoutPosition(target) + val id = mHostView.adapter!!.getItemId(position) + val handled = performItemLongClick(mHostView, target, position, id) + if (handled) { + target.isPressed = false + mTargetChild = null + } + } + } + + init { + mGestureDetector = ItemClickGestureDetector( + hostView.context, + ItemClickGestureListener(hostView) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cleveroad/audiowidget/example/EmptyViewObserver.java b/app/src/main/java/com/cleveroad/audiowidget/example/EmptyViewObserver.java deleted file mode 100644 index 903b094..0000000 --- a/app/src/main/java/com/cleveroad/audiowidget/example/EmptyViewObserver.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.cleveroad.audiowidget.example; - -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; - -import java.lang.ref.WeakReference; - -/** - * Simple observer for displaying and hiding empty view. - */ -final class EmptyViewObserver extends RecyclerView.AdapterDataObserver { - - private WeakReference viewWeakReference; - private WeakReference recyclerViewWeakReference; - - public EmptyViewObserver(@NonNull View view) { - viewWeakReference = new WeakReference<>(view); - } - - /** - * Bind observer to recycler view's adapter. This method must be called after setting adapter to recycler view. - * @param recyclerView instance of recycler view - */ - public void bind(@NonNull RecyclerView recyclerView) { - unbind(); - this.recyclerViewWeakReference = new WeakReference<>(recyclerView); - recyclerView.getAdapter().registerAdapterDataObserver(this); - } - - public void unbind() { - if (recyclerViewWeakReference == null) - return; - RecyclerView recyclerView = recyclerViewWeakReference.get(); - if (recyclerView != null) { - recyclerView.getAdapter().unregisterAdapterDataObserver(this); - recyclerViewWeakReference.clear(); - } - } - - @Override - public void onChanged() { - super.onChanged(); - somethingChanged(); - } - - @Override - public void onItemRangeChanged(int positionStart, int itemCount) { - super.onItemRangeChanged(positionStart, itemCount); - somethingChanged(); - } - - @Override - public void onItemRangeChanged(int positionStart, int itemCount, Object payload) { - super.onItemRangeChanged(positionStart, itemCount, payload); - somethingChanged(); - } - - @Override - public void onItemRangeInserted(int positionStart, int itemCount) { - super.onItemRangeInserted(positionStart, itemCount); - somethingChanged(); - } - - @Override - public void onItemRangeRemoved(int positionStart, int itemCount) { - super.onItemRangeRemoved(positionStart, itemCount); - somethingChanged(); - } - - private void somethingChanged() { - View view = viewWeakReference.get(); - RecyclerView recyclerView = recyclerViewWeakReference.get(); - if (view != null && recyclerView != null) { - if (recyclerView.getAdapter().getItemCount() == 0) { - view.setVisibility(View.VISIBLE); - } else { - view.setVisibility(View.GONE); - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/cleveroad/audiowidget/example/EmptyViewObserver.kt b/app/src/main/java/com/cleveroad/audiowidget/example/EmptyViewObserver.kt new file mode 100644 index 0000000..33e93d2 --- /dev/null +++ b/app/src/main/java/com/cleveroad/audiowidget/example/EmptyViewObserver.kt @@ -0,0 +1,71 @@ +package com.cleveroad.audiowidget.example + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver +import java.lang.ref.WeakReference + +/** + * Simple observer for displaying and hiding empty view. + */ +internal class EmptyViewObserver(view: View) : AdapterDataObserver() { + private val viewWeakReference: WeakReference = WeakReference(view) + private var recyclerViewWeakReference: WeakReference? = null + + /** + * Bind observer to recycler view's adapter. This method must be called after setting adapter to recycler view. + * @param recyclerView instance of recycler view + */ + fun bind(recyclerView: RecyclerView) { + unbind() + recyclerViewWeakReference = WeakReference(recyclerView) + recyclerView.adapter!!.registerAdapterDataObserver(this) + } + + fun unbind() { + recyclerViewWeakReference?.let { + val recyclerView = it.get() + if (recyclerView != null) { + recyclerView.adapter!!.unregisterAdapterDataObserver(this) + it.clear() + } + } + } + + override fun onChanged() { + super.onChanged() + somethingChanged() + } + + override fun onItemRangeChanged(positionStart: Int, itemCount: Int) { + super.onItemRangeChanged(positionStart, itemCount) + somethingChanged() + } + + override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) { + super.onItemRangeChanged(positionStart, itemCount, payload) + somethingChanged() + } + + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + super.onItemRangeInserted(positionStart, itemCount) + somethingChanged() + } + + override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { + super.onItemRangeRemoved(positionStart, itemCount) + somethingChanged() + } + + private fun somethingChanged() { + val view = viewWeakReference.get() + val recyclerView = recyclerViewWeakReference!!.get() + if (view != null && recyclerView != null) { + if (recyclerView.adapter!!.itemCount == 0) { + view.visibility = View.VISIBLE + } else { + view.visibility = View.GONE + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cleveroad/audiowidget/example/ItemClickSupport.java b/app/src/main/java/com/cleveroad/audiowidget/example/ItemClickSupport.java deleted file mode 100644 index 782f271..0000000 --- a/app/src/main/java/com/cleveroad/audiowidget/example/ItemClickSupport.java +++ /dev/null @@ -1,142 +0,0 @@ -package com.cleveroad.audiowidget.example; - -import android.view.HapticFeedbackConstants; -import android.view.SoundEffectConstants; -import android.view.View; - -import androidx.recyclerview.widget.RecyclerView; - -class ItemClickSupport { - /** - * Interface definition for a callback to be invoked when an item in the - * RecyclerView has been clicked. - */ - public interface OnItemClickListener { - /** - * Callback method to be invoked when an item in the RecyclerView - * has been clicked. - * - * @param parent The RecyclerView where the click happened. - * @param view The view within the RecyclerView that was clicked - * @param position The position of the view in the adapter. - * @param id The row id of the item that was clicked. - */ - void onItemClick(RecyclerView parent, View view, int position, long id); - } - - /** - * Interface definition for a callback to be invoked when an item in the - * RecyclerView has been clicked and held. - */ - public interface OnItemLongClickListener { - /** - * Callback method to be invoked when an item in the RecyclerView - * has been clicked and held. - * - * @param parent The RecyclerView where the click happened - * @param view The view within the RecyclerView that was clicked - * @param position The position of the view in the list - * @param id The row id of the item that was clicked - * - * @return true if the callback consumed the long click, false otherwise - */ - boolean onItemLongClick(RecyclerView parent, View view, int position, long id); - } - - private final RecyclerView mRecyclerView; - private final TouchListener mTouchListener; - - private OnItemClickListener mItemClickListener; - private OnItemLongClickListener mItemLongClickListener; - - private ItemClickSupport(RecyclerView recyclerView) { - mRecyclerView = recyclerView; - - mTouchListener = new TouchListener(recyclerView); - recyclerView.addOnItemTouchListener(mTouchListener); - } - - /** - * Register a callback to be invoked when an item in the - * RecyclerView has been clicked. - * - * @param listener The callback that will be invoked. - */ - public void setOnItemClickListener(OnItemClickListener listener) { - mItemClickListener = listener; - } - - /** - * Register a callback to be invoked when an item in the - * RecyclerView has been clicked and held. - * - * @param listener The callback that will be invoked. - */ - public void setOnItemLongClickListener(OnItemLongClickListener listener) { - if (!mRecyclerView.isLongClickable()) { - mRecyclerView.setLongClickable(true); - } - - mItemLongClickListener = listener; - } - - public static ItemClickSupport addTo(RecyclerView recyclerView) { - ItemClickSupport itemClickSupport = from(recyclerView); - if (itemClickSupport == null) { - itemClickSupport = new ItemClickSupport(recyclerView); - recyclerView.setTag(R.id.twowayview_item_click_support, itemClickSupport); - } - - return itemClickSupport; - } - - public static void removeFrom(RecyclerView recyclerView) { - final ItemClickSupport itemClickSupport = from(recyclerView); - if (itemClickSupport == null) { - return; - } - - recyclerView.removeOnItemTouchListener(itemClickSupport.mTouchListener); - recyclerView.setTag(R.id.twowayview_item_click_support, null); - } - - public static ItemClickSupport from(RecyclerView recyclerView) { - if (recyclerView == null) { - return null; - } - - return (ItemClickSupport) recyclerView.getTag(R.id.twowayview_item_click_support); - } - - private class TouchListener extends ClickItemTouchListener { - TouchListener(RecyclerView recyclerView) { - super(recyclerView); - } - - @Override - boolean performItemClick(RecyclerView parent, View view, int position, long id) { - if (mItemClickListener != null) { - view.playSoundEffect(SoundEffectConstants.CLICK); - mItemClickListener.onItemClick(parent, view, position, id); - return true; - } - - return false; - } - - @Override - boolean performItemLongClick(RecyclerView parent, View view, int position, long id) { - if (mItemLongClickListener != null) { - view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); - return mItemLongClickListener.onItemLongClick(parent, view, position, id); - } - - return false; - } - - @Override - public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { - - } - } -} diff --git a/app/src/main/java/com/cleveroad/audiowidget/example/ItemClickSupport.kt b/app/src/main/java/com/cleveroad/audiowidget/example/ItemClickSupport.kt new file mode 100644 index 0000000..dfa9bd5 --- /dev/null +++ b/app/src/main/java/com/cleveroad/audiowidget/example/ItemClickSupport.kt @@ -0,0 +1,130 @@ +package com.cleveroad.audiowidget.example + +import android.view.HapticFeedbackConstants +import android.view.SoundEffectConstants +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +internal class ItemClickSupport private constructor(private val mRecyclerView: RecyclerView) { + /** + * Interface definition for a callback to be invoked when an item in the + * RecyclerView has been clicked. + */ + interface OnItemClickListener { + /** + * Callback method to be invoked when an item in the RecyclerView + * has been clicked. + * + * @param parent The RecyclerView where the click happened. + * @param view The view within the RecyclerView that was clicked + * @param position The position of the view in the adapter. + * @param id The row id of the item that was clicked. + */ + fun onItemClick(parent: RecyclerView?, view: View?, position: Int, id: Long) + } + + /** + * Interface definition for a callback to be invoked when an item in the + * RecyclerView has been clicked and held. + */ + interface OnItemLongClickListener { + /** + * Callback method to be invoked when an item in the RecyclerView + * has been clicked and held. + * + * @param parent The RecyclerView where the click happened + * @param view The view within the RecyclerView that was clicked + * @param position The position of the view in the list + * @param id The row id of the item that was clicked + * + * @return true if the callback consumed the long click, false otherwise + */ + fun onItemLongClick(parent: RecyclerView?, view: View?, position: Int, id: Long): Boolean + } + + private val mTouchListener: TouchListener + private var mItemClickListener: OnItemClickListener? = null + private var mItemLongClickListener: OnItemLongClickListener? = null + + /** + * Register a callback to be invoked when an item in the + * RecyclerView has been clicked. + * + * @param listener The callback that will be invoked. + */ + fun setOnItemClickListener(listener: OnItemClickListener) { + mItemClickListener = listener + } + + /** + * Register a callback to be invoked when an item in the + * RecyclerView has been clicked and held. + * + * @param listener The callback that will be invoked. + */ + fun setOnItemLongClickListener(listener: OnItemLongClickListener?) { + if (!mRecyclerView.isLongClickable) { + mRecyclerView.isLongClickable = true + } + mItemLongClickListener = listener + } + + private inner class TouchListener constructor(recyclerView: RecyclerView) : + ClickItemTouchListener(recyclerView) { + override fun performItemClick( + parent: RecyclerView?, + view: View, + position: Int, + id: Long + ): Boolean { + return mItemClickListener?.let { + view.playSoundEffect(SoundEffectConstants.CLICK) + it.onItemClick(parent, view, position, id) + true + } ?: false + } + + override fun performItemLongClick( + parent: RecyclerView?, + view: View, + position: Int, + id: Long + ): Boolean { + if (mItemLongClickListener != null) { + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + return mItemLongClickListener!!.onItemLongClick(parent, view, position, id) + } + return false + } + + override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {} + } + + companion object { + fun addTo(recyclerView: RecyclerView): ItemClickSupport { + var itemClickSupport = from(recyclerView) + if (itemClickSupport == null) { + itemClickSupport = ItemClickSupport(recyclerView) + recyclerView.setTag(R.id.twowayview_item_click_support, itemClickSupport) + } + return itemClickSupport + } + + fun removeFrom(recyclerView: RecyclerView) { + val itemClickSupport = from(recyclerView) ?: return + recyclerView.removeOnItemTouchListener(itemClickSupport.mTouchListener) + recyclerView.setTag(R.id.twowayview_item_click_support, null) + } + + fun from(recyclerView: RecyclerView?): ItemClickSupport? { + return if (recyclerView == null) { + null + } else recyclerView.getTag(R.id.twowayview_item_click_support) as ItemClickSupport? + } + } + + init { + mTouchListener = TouchListener(mRecyclerView) + mRecyclerView.addOnItemTouchListener(mTouchListener) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cleveroad/audiowidget/example/MainActivity.java b/app/src/main/java/com/cleveroad/audiowidget/example/MainActivity.java deleted file mode 100644 index 8493f4a..0000000 --- a/app/src/main/java/com/cleveroad/audiowidget/example/MainActivity.java +++ /dev/null @@ -1,243 +0,0 @@ -package com.cleveroad.audiowidget.example; - -import android.Manifest; -import android.annotation.TargetApi; -import android.app.ActivityManager; -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.provider.Settings; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.widget.SearchView; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; -import androidx.core.view.MenuItemCompat; -import androidx.loader.app.LoaderManager; -import androidx.loader.content.Loader; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import java.util.Collection; - - -public class MainActivity extends AppCompatActivity implements LoaderManager.LoaderCallbacks>, - SearchView.OnQueryTextListener { - - private static final int MUSIC_LOADER_ID = 1; - private static final int OVERLAY_PERMISSION_REQ_CODE = 1; - private static final int EXT_STORAGE_PERMISSION_REQ_CODE = 2; - - private MusicAdapter adapter; - private EmptyViewObserver emptyViewObserver; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - RecyclerView recyclerView = findViewById(R.id.recycler_view); - View emptyView = findViewById(R.id.empty_view); - adapter = new MusicAdapter(this); - recyclerView.setLayoutManager(new LinearLayoutManager(this, RecyclerView.VERTICAL, false)); - recyclerView.setAdapter(adapter); - emptyViewObserver = new EmptyViewObserver(emptyView); - emptyViewObserver.bind(recyclerView); - MusicFilter filter = new MusicFilter(ContextCompat.getColor(this, R.color.colorAccent)); - adapter.withFilter(filter); - ItemClickSupport.addTo(recyclerView) - .setOnItemClickListener((parent, view, position, id) -> { - MusicItem item = adapter.getItem(position); - if (!isServiceRunning(MusicService.class)) { - MusicService.setTracks(MainActivity.this, adapter.getSnapshot().toArray(new MusicItem[adapter.getNonFilteredCount()])); - } - MusicService.playTrack(MainActivity.this, item); - }); - - // check if we can draw overlays - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(MainActivity.this)) { - DialogInterface.OnClickListener listener = new DialogInterface.OnClickListener() { - - @TargetApi(Build.VERSION_CODES.M) - @Override - public void onClick(DialogInterface dialog, int which) { - if (which == DialogInterface.BUTTON_POSITIVE) { - Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + getPackageName())); - startActivityForResult(intent, OVERLAY_PERMISSION_REQ_CODE); - } else if (which == DialogInterface.BUTTON_NEGATIVE) { - onPermissionsNotGranted(); - } - } - }; - new AlertDialog.Builder(MainActivity.this) - .setTitle(getString(R.string.permissions_title)) - .setMessage(getString(R.string.draw_over_permissions_message)) - .setPositiveButton(getString(R.string.btn_continue), listener) - .setNegativeButton(getString(R.string.btn_cancel), listener) - .setCancelable(false) - .show(); - return; - } - checkReadStoragePermission(); - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (requestCode == OVERLAY_PERMISSION_REQ_CODE) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(MainActivity.this)) { - onPermissionsNotGranted(); - } else { - checkReadStoragePermission(); - } - } - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - if (requestCode == EXT_STORAGE_PERMISSION_REQ_CODE) { - for (int i = 0; i < permissions.length; i++) { - if (Manifest.permission.READ_EXTERNAL_STORAGE.equals(permissions[i]) && - grantResults[i] == PackageManager.PERMISSION_GRANTED) { - loadMusic(); - return; - } - } - onPermissionsNotGranted(); - } - } - - @Override - protected void onResume() { - super.onResume(); - MusicService.setState(this, false); - } - - @Override - protected void onPause() { - super.onPause(); - MusicService.setState(this, true); - } - - /** - * Check if we have necessary permissions. - */ - @TargetApi(Build.VERSION_CODES.JELLY_BEAN) - private void checkReadStoragePermission() { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { - if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.READ_EXTERNAL_STORAGE)) { - DialogInterface.OnClickListener onClickListener = new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - if (which == DialogInterface.BUTTON_POSITIVE) { - ActivityCompat.requestPermissions(MainActivity.this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, EXT_STORAGE_PERMISSION_REQ_CODE); - } else if (which == DialogInterface.BUTTON_NEGATIVE) { - onPermissionsNotGranted(); - } - dialog.dismiss(); - } - }; - new AlertDialog.Builder(this) - .setTitle(R.string.permissions_title) - .setMessage(R.string.read_ext_permissions_message) - .setPositiveButton(R.string.btn_continue, onClickListener) - .setNegativeButton(R.string.btn_cancel, onClickListener) - .setCancelable(false) - .show(); - return; - } - ActivityCompat.requestPermissions(MainActivity.this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, EXT_STORAGE_PERMISSION_REQ_CODE); - return; - } - loadMusic(); - } - - /** - * Load music. - */ - private void loadMusic() { - getSupportLoaderManager().initLoader(MUSIC_LOADER_ID, null, this); - } - - /** - * Permissions not granted. Quit. - */ - private void onPermissionsNotGranted() { - Toast.makeText(this, R.string.toast_permissions_not_granted, Toast.LENGTH_SHORT).show(); - finish(); - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - menu.clear(); - getMenuInflater().inflate(R.menu.main, menu); - MenuItem searchItem = menu.findItem(R.id.item_search); - SearchView searchView = (SearchView) MenuItemCompat.getActionView(searchItem); - searchView.setOnQueryTextListener(this); - return true; - } - - @Override - public Loader> onCreateLoader(int id, Bundle args) { - if (id == MUSIC_LOADER_ID) - return new MusicLoader(this); - return null; - } - - @Override - public void onLoadFinished(Loader> loader, Collection data) { - adapter.addAll(data); - adapter.notifyItemRangeInserted(0, data.size()); - MusicService.setTracks(this, data.toArray(new MusicItem[data.size()])); - } - - @Override - public void onLoaderReset(Loader> loader) { - int size = adapter.getItemCount(); - adapter.clear(); - adapter.notifyItemRangeRemoved(0, size); - } - - @Override - public boolean onQueryTextSubmit(String query) { - return false; - } - - @Override - public boolean onQueryTextChange(String newText) { - adapter.getFilter().filter(newText); - return true; - } - - @Override - protected void onDestroy() { - emptyViewObserver.unbind(); - super.onDestroy(); - } - - /** - * Check if service is running. - * - * @param serviceClass - * @return - */ - private boolean isServiceRunning(@NonNull Class serviceClass) { - ActivityManager manager = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE); - for (ActivityManager.RunningServiceInfo service : manager.getRunningServices(Integer.MAX_VALUE)) { - if (serviceClass.getName().equals(service.service.getClassName())) { - return true; - } - } - return false; - } -} \ No newline at end of file diff --git a/app/src/main/java/com/cleveroad/audiowidget/example/MainActivity.kt b/app/src/main/java/com/cleveroad/audiowidget/example/MainActivity.kt new file mode 100644 index 0000000..5e5f208 --- /dev/null +++ b/app/src/main/java/com/cleveroad/audiowidget/example/MainActivity.kt @@ -0,0 +1,264 @@ +package com.cleveroad.audiowidget.example + +import android.Manifest +import android.annotation.TargetApi +import android.app.ActivityManager +import android.content.DialogInterface +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import android.view.Menu +import android.view.View +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.SearchView +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.view.MenuItemCompat +import androidx.loader.app.LoaderManager +import androidx.loader.content.Loader +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView + +class MainActivity : AppCompatActivity(), LoaderManager.LoaderCallbacks>, + SearchView.OnQueryTextListener { + private var adapter: MusicAdapter? = null + private var emptyViewObserver: EmptyViewObserver? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + val recyclerView = findViewById(R.id.recycler_view) + val emptyView = findViewById(R.id.empty_view) + val filter = MusicFilter(ContextCompat.getColor(this, R.color.colorAccent)) + val adapter = MusicAdapter(this).also { + this.adapter = it + } + adapter.withFilter(filter) + recyclerView.layoutManager = LinearLayoutManager(this, RecyclerView.VERTICAL, false) + recyclerView.adapter = adapter + emptyViewObserver = EmptyViewObserver(emptyView).also { + it.bind(recyclerView) + } + + ItemClickSupport.addTo(recyclerView) + .setOnItemClickListener(object : ItemClickSupport.OnItemClickListener { + override fun onItemClick( + parent: RecyclerView?, + view: View?, + position: Int, + id: Long + ) { + val item = adapter.getItem(position) + if (!isServiceRunning(MusicService::class.java)) { + MusicService.setTracks( + this@MainActivity, + adapter.snapshot.toTypedArray() + ) + } + MusicService.playTrack(this@MainActivity, item) + } + }) + + // check if we can draw overlays + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(this@MainActivity)) { + val listener = DialogInterface.OnClickListener { dialog, which -> + if (which == DialogInterface.BUTTON_POSITIVE) { + val intent = Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse( + "package:$packageName" + ) + ) + startActivityForResult(intent, OVERLAY_PERMISSION_REQ_CODE) + } else if (which == DialogInterface.BUTTON_NEGATIVE) { + onPermissionsNotGranted() + } + } + AlertDialog.Builder(this@MainActivity) + .setTitle(getString(R.string.permissions_title)) + .setMessage(getString(R.string.draw_over_permissions_message)) + .setPositiveButton(getString(R.string.btn_continue), listener) + .setNegativeButton(getString(R.string.btn_cancel), listener) + .setCancelable(false) + .show() + return + } + checkReadStoragePermission() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == OVERLAY_PERMISSION_REQ_CODE) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(this)) { + onPermissionsNotGranted() + } else { + checkReadStoragePermission() + } + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == EXT_STORAGE_PERMISSION_REQ_CODE) { + for (i in permissions.indices) { + if (Manifest.permission.READ_EXTERNAL_STORAGE == permissions[i] && + grantResults[i] == PackageManager.PERMISSION_GRANTED + ) { + loadMusic() + return + } + } + onPermissionsNotGranted() + } + } + + override fun onResume() { + super.onResume() + MusicService.setState(this, false) + } + + override fun onPause() { + super.onPause() + MusicService.setState(this, true) + } + + /** + * Check if we have necessary permissions. + */ + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + private fun checkReadStoragePermission() { + if (ContextCompat.checkSelfPermission( + this, + Manifest.permission.READ_EXTERNAL_STORAGE + ) != PackageManager.PERMISSION_GRANTED + ) { + if (ActivityCompat.shouldShowRequestPermissionRationale( + this, + Manifest.permission.READ_EXTERNAL_STORAGE + ) + ) { + val onClickListener = DialogInterface.OnClickListener { dialog, which -> + if (which == DialogInterface.BUTTON_POSITIVE) { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), + EXT_STORAGE_PERMISSION_REQ_CODE + ) + } else if (which == DialogInterface.BUTTON_NEGATIVE) { + onPermissionsNotGranted() + } + dialog.dismiss() + } + AlertDialog.Builder(this) + .setTitle(R.string.permissions_title) + .setMessage(R.string.read_ext_permissions_message) + .setPositiveButton(R.string.btn_continue, onClickListener) + .setNegativeButton(R.string.btn_cancel, onClickListener) + .setCancelable(false) + .show() + return + } + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), + EXT_STORAGE_PERMISSION_REQ_CODE + ) + return + } + loadMusic() + } + + /** + * Load music. + */ + private fun loadMusic() { + LoaderManager.getInstance(this).initLoader(MUSIC_LOADER_ID, null, this) + } + + /** + * Permissions not granted. Quit. + */ + private fun onPermissionsNotGranted() { + Toast.makeText(this, R.string.toast_permissions_not_granted, Toast.LENGTH_SHORT).show() + finish() + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menu.clear() + menuInflater.inflate(R.menu.main, menu) + val searchItem = menu.findItem(R.id.item_search) + val searchView = MenuItemCompat.getActionView(searchItem) as SearchView + searchView.setOnQueryTextListener(this) + return true + } + + override fun onCreateLoader(id: Int, args: Bundle?): Loader> { + return if (id == MUSIC_LOADER_ID) + MusicLoader(this) + else + throw Exception("Not expected $id") + } + + override fun onLoadFinished( + loader: Loader>, + data: Collection + ) { + adapter!!.let { + it.addAll(data) + it.notifyItemRangeInserted(0, data.size) + } + MusicService.setTracks(this, data.toTypedArray()) + } + + override fun onLoaderReset(loader: Loader>) { + adapter!!.let { + val size = it.itemCount + it.clear() + it.notifyItemRangeRemoved(0, size) + } + } + + override fun onQueryTextSubmit(query: String): Boolean { + return false + } + + override fun onQueryTextChange(newText: String): Boolean { + adapter?.filter?.filter(newText) + return true + } + + override fun onDestroy() { + emptyViewObserver?.unbind() + super.onDestroy() + } + + /** + * Check if service is running. + * + * @param serviceClass + * @return + */ + private fun isServiceRunning(serviceClass: Class<*>): Boolean { + val manager = getSystemService(ACTIVITY_SERVICE) as ActivityManager + for (service in manager.getRunningServices(Int.MAX_VALUE)) { + if (serviceClass.name == service.service.className) { + return true + } + } + return false + } + + companion object { + private const val MUSIC_LOADER_ID = 1 + private const val OVERLAY_PERMISSION_REQ_CODE = 1 + private const val EXT_STORAGE_PERMISSION_REQ_CODE = 2 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cleveroad/audiowidget/example/MusicAdapter.java b/app/src/main/java/com/cleveroad/audiowidget/example/MusicAdapter.java deleted file mode 100644 index 36fceb4..0000000 --- a/app/src/main/java/com/cleveroad/audiowidget/example/MusicAdapter.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.cleveroad.audiowidget.example; - -import android.content.Context; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; - -import com.bumptech.glide.Glide; -import com.bumptech.glide.request.RequestOptions; - -import java.util.Locale; - -/** - * Adapter for list of tracks. - */ -class MusicAdapter extends BaseRecyclerViewAdapter { - - public MusicAdapter(@NonNull Context context) { - super(context); - } - - @Override - public MusicViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - View view = getInflater().inflate(R.layout.item_music, parent, false); - return new MusicViewHolder(view); - } - - @Override - public void onBindViewHolder(MusicViewHolder holder, int position) { - MusicItem item = getItem(position); - holder.title.setText(getFilter().highlightFilteredSubstring(item.title())); - holder.artist.setText(getFilter().highlightFilteredSubstring(item.artist())); - holder.album.setText(getFilter().highlightFilteredSubstring(item.album())); - holder.duration.setText(convertDuration(item.duration())); - Glide.with(getContext()) - .asBitmap() - .load(item.albumArtUri()) - .apply((new RequestOptions()).circleCrop()) - .placeholder(R.drawable.aw_ic_default_album) - .error(R.drawable.aw_ic_default_album) - .into(holder.albumCover); - } - - private String convertDuration(long durationInMs) { - long durationInSeconds = durationInMs / 1000; - long seconds = durationInSeconds % 60; - long minutes = (durationInSeconds % 3600) / 60; - long hours = durationInSeconds / 3600; - if (hours > 0) { - return String.format(Locale.US, "%02d:%02d:%02d", hours, minutes, seconds); - } - return String.format(Locale.US, "%02d:%02d", minutes, seconds); - } - - static class MusicViewHolder extends RecyclerView.ViewHolder { - - TextView title; - TextView artist; - TextView album; - TextView duration; - ImageView albumCover; - - public MusicViewHolder(View itemView) { - super(itemView); - title = itemView.findViewById(R.id.title); - artist = itemView.findViewById(R.id.artist); - album = itemView.findViewById(R.id.album); - duration = itemView.findViewById(R.id.duration); - albumCover = itemView.findViewById(R.id.album_cover); - } - } -} diff --git a/app/src/main/java/com/cleveroad/audiowidget/example/MusicAdapter.kt b/app/src/main/java/com/cleveroad/audiowidget/example/MusicAdapter.kt new file mode 100644 index 0000000..910c9c7 --- /dev/null +++ b/app/src/main/java/com/cleveroad/audiowidget/example/MusicAdapter.kt @@ -0,0 +1,53 @@ +package com.cleveroad.audiowidget.example + +import android.content.Context +import android.text.format.DateUtils +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.bumptech.glide.Glide +import com.bumptech.glide.request.RequestOptions +import com.cleveroad.audiowidget.example.MusicAdapter.MusicViewHolder + +/** + * Adapter for list of tracks. + */ +internal class MusicAdapter(context: Context) : + BaseRecyclerViewAdapter(context) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MusicViewHolder { + val view = inflater.inflate(R.layout.item_music, parent, false) + return MusicViewHolder(view) + } + + override fun onBindViewHolder(holder: MusicViewHolder, position: Int) { + val item = getItem(position) + filter!!.let { + holder.title.text = it.highlightFilteredSubstring(item.title ?: "") + holder.artist.text = it.highlightFilteredSubstring(item.artist ?: "") + holder.album.text = it.highlightFilteredSubstring(item.album ?: "") + holder.duration.text = convertDuration(item.duration) + Glide.with(context) + .asBitmap() + .load(item.albumArtUri) + .apply(RequestOptions().circleCrop()) + .placeholder(R.drawable.aw_ic_default_album) + .error(R.drawable.aw_ic_default_album) + .into(holder.albumCover) + } + } + + private fun convertDuration(durationInMs: Long): String { + return DateUtils.formatElapsedTime(durationInMs) + } + + internal class MusicViewHolder(itemView: View) : ViewHolder(itemView) { + var title: TextView = itemView.findViewById(R.id.title) + var artist: TextView = itemView.findViewById(R.id.artist) + var album: TextView = itemView.findViewById(R.id.album) + var duration: TextView = itemView.findViewById(R.id.duration) + var albumCover: ImageView = itemView.findViewById(R.id.album_cover) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cleveroad/audiowidget/example/MusicFilter.java b/app/src/main/java/com/cleveroad/audiowidget/example/MusicFilter.java deleted file mode 100644 index 71de6f6..0000000 --- a/app/src/main/java/com/cleveroad/audiowidget/example/MusicFilter.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.cleveroad.audiowidget.example; - -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.util.ArrayList; -import java.util.List; - -/** - * Filter for list of tracks. - */ -class MusicFilter extends BaseFilter { - - public MusicFilter(int highlightColor) throws AssertionError { - super(highlightColor); - } - - @NonNull - @Override - protected FilterResults performFilteringImpl(CharSequence constraint) { - FilterResults results = new FilterResults(); - if (TextUtils.isEmpty(constraint) || TextUtils.isEmpty(constraint.toString().trim())) { - results.count = -1; - return results; - } - String str = constraint.toString().trim(); - List result = new ArrayList<>(); - int size = getNonFilteredCount(); - for (int i = 0; i < size; i++) { - MusicItem item = getNonFilteredItem(i); - if ( - check(str, item.title()) - || check(str, item.album()) - || check(str, item.artist()) - ) { - result.add(item); - } - } - results.count = result.size(); - results.values = result; - return results; - } - - private boolean check(@NonNull String what, @Nullable String where) { - if (TextUtils.isEmpty(where)) - return false; - where = where.toLowerCase(); - what = what.toLowerCase(); - return where.contains(what); - } -} diff --git a/app/src/main/java/com/cleveroad/audiowidget/example/MusicFilter.kt b/app/src/main/java/com/cleveroad/audiowidget/example/MusicFilter.kt new file mode 100644 index 0000000..5c017bf --- /dev/null +++ b/app/src/main/java/com/cleveroad/audiowidget/example/MusicFilter.kt @@ -0,0 +1,43 @@ +package com.cleveroad.audiowidget.example + +import android.text.TextUtils +import java.util.* + +/** + * Filter for list of tracks. + */ +internal class MusicFilter(highlightColor: Int) : BaseFilter(highlightColor) { + override fun performFilteringImpl(constraint: CharSequence): FilterResults { + val results = FilterResults() + if (TextUtils.isEmpty(constraint) || TextUtils.isEmpty( + constraint.toString().trim { it <= ' ' }) + ) { + results.count = -1 + return results + } + val str = constraint.toString().trim { it <= ' ' } + val result: MutableList = ArrayList() + val size = nonFilteredCount + for (i in 0 until size) { + val item = getNonFilteredItem(i) + if (check(str, item.title) + || check(str, item.album) + || check(str, item.artist) + ) { + result.add(item) + } + } + results.count = result.size + results.values = result + return results + } + + private fun check(what: String, where: String?): Boolean { + var what = what + var where = where + if (where.isNullOrBlank()) return false + where = where.toLowerCase() + what = what.toLowerCase() + return where.contains(what) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cleveroad/audiowidget/example/MusicItem.java b/app/src/main/java/com/cleveroad/audiowidget/example/MusicItem.java deleted file mode 100644 index e3dcaab..0000000 --- a/app/src/main/java/com/cleveroad/audiowidget/example/MusicItem.java +++ /dev/null @@ -1,149 +0,0 @@ -package com.cleveroad.audiowidget.example; - -import android.net.Uri; -import android.os.Parcel; -import android.os.Parcelable; - -/** - * Music track model. - */ -class MusicItem implements Parcelable { - private String title; - private String album; - private String artist; - private long duration; - private Uri albumArtUri; - private Uri fileUri; - - public MusicItem title(String title) { - this.title = title; - return this; - } - - public MusicItem album(String album) { - this.album = album; - return this; - } - - public MusicItem artist(String artist) { - this.artist = artist; - return this; - } - - public MusicItem duration(long duration) { - this.duration = duration; - return this; - } - - public MusicItem albumArtUri(Uri albumArtUri) { - this.albumArtUri = albumArtUri; - return this; - } - - public MusicItem fileUri(Uri fileUri) { - this.fileUri = fileUri; - return this; - } - - public String title() { - return title; - } - - public String album() { - return album; - } - - public String artist() { - return artist; - } - - public long duration() { - return duration; - } - - public Uri albumArtUri() { - return albumArtUri; - } - - public Uri fileUri() { - return fileUri; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - MusicItem item = (MusicItem) o; - - if (duration != item.duration) return false; - if (title != null ? !title.equals(item.title) : item.title != null) return false; - if (album != null ? !album.equals(item.album) : item.album != null) return false; - if (artist != null ? !artist.equals(item.artist) : item.artist != null) return false; - if (albumArtUri != null ? !albumArtUri.equals(item.albumArtUri) : item.albumArtUri != null) - return false; - return fileUri != null ? fileUri.equals(item.fileUri) : item.fileUri == null; - - } - - @Override - public int hashCode() { - int result = title != null ? title.hashCode() : 0; - result = 31 * result + (album != null ? album.hashCode() : 0); - result = 31 * result + (artist != null ? artist.hashCode() : 0); - result = 31 * result + (int) (duration ^ (duration >>> 32)); - result = 31 * result + (albumArtUri != null ? albumArtUri.hashCode() : 0); - result = 31 * result + (fileUri != null ? fileUri.hashCode() : 0); - return result; - } - - @Override - public String toString() { - return "MusicItem{" + - "title='" + title + '\'' + - ", album='" + album + '\'' + - ", artist='" + artist + '\'' + - ", duration=" + duration + - ", albumArtUri=" + albumArtUri + - ", fileUri=" + fileUri + - '}'; - } - - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeString(this.title); - dest.writeString(this.album); - dest.writeString(this.artist); - dest.writeLong(this.duration); - dest.writeParcelable(this.albumArtUri, 0); - dest.writeParcelable(this.fileUri, 0); - } - - public MusicItem() { - } - - protected MusicItem(Parcel in) { - this.title = in.readString(); - this.album = in.readString(); - this.artist = in.readString(); - this.duration = in.readLong(); - this.albumArtUri = in.readParcelable(Uri.class.getClassLoader()); - this.fileUri = in.readParcelable(Uri.class.getClassLoader()); - } - - public static final Creator CREATOR = new Creator() { - public MusicItem createFromParcel(Parcel source) { - return new MusicItem(source); - } - - public MusicItem[] newArray(int size) { - return new MusicItem[size]; - } - }; -} diff --git a/app/src/main/java/com/cleveroad/audiowidget/example/MusicItem.kt b/app/src/main/java/com/cleveroad/audiowidget/example/MusicItem.kt new file mode 100644 index 0000000..63d65c8 --- /dev/null +++ b/app/src/main/java/com/cleveroad/audiowidget/example/MusicItem.kt @@ -0,0 +1,77 @@ +package com.cleveroad.audiowidget.example + +import android.net.Uri +import android.os.Parcel +import android.os.Parcelable + +/** + * Music track model. + */ +open class MusicItem() : Parcelable { + var title: String? = null + var album: String? = null + var artist: String? = null + var duration: Long = 0 + var albumArtUri: Uri? = null + var fileUri: Uri? = null + + constructor(`in`: Parcel) : this() { + title = `in`.readString() + album = `in`.readString() + artist = `in`.readString() + duration = `in`.readLong() + albumArtUri = `in`.readParcelable(Uri::class.java.classLoader) + fileUri = `in`.readParcelable(Uri::class.java.classLoader) + } + + override fun toString(): String { + return "MusicItem{" + + "title='" + title + '\'' + + ", album='" + album + '\'' + + ", artist='" + artist + '\'' + + ", duration=" + duration + + ", albumArtUri=" + albumArtUri + + ", fileUri=" + fileUri + + '}' + } + + override fun describeContents(): Int { + return 0 + } + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeString(title) + dest.writeString(album) + dest.writeString(artist) + dest.writeLong(duration) + dest.writeParcelable(albumArtUri, 0) + dest.writeParcelable(fileUri, 0) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MusicItem + + if (fileUri != other.fileUri) return false + return true + } + + override fun hashCode(): Int { + return fileUri?.hashCode() ?: 0 + } + + companion object { + @JvmField + val CREATOR: Parcelable.Creator = object : Parcelable.Creator { + override fun createFromParcel(source: Parcel): MusicItem { + return MusicItem(source) + } + + override fun newArray(size: Int): Array { + return emptyArray() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cleveroad/audiowidget/example/MusicLoader.java b/app/src/main/java/com/cleveroad/audiowidget/example/MusicLoader.java deleted file mode 100644 index d02c42b..0000000 --- a/app/src/main/java/com/cleveroad/audiowidget/example/MusicLoader.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.cleveroad.audiowidget.example; - -import android.content.ContentUris; -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; -import android.provider.MediaStore; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.List; - -/** - * Loader for list of tracks. - */ -class MusicLoader extends BaseAsyncTaskLoader> { - - private final Uri albumArtUri = Uri.parse("content://media/external/audio/albumart"); - - public MusicLoader(Context context) { - super(context); - } - - @Override - public Collection loadInBackground() { - String[] projection = new String[]{ - MediaStore.Audio.Media.TITLE, - MediaStore.Audio.Media.ALBUM, - MediaStore.Audio.Media.ALBUM_ID, - MediaStore.Audio.Media.ARTIST, - MediaStore.Audio.Media.DURATION, - MediaStore.Audio.Media.DATA, - }; - Cursor cursor = getContext().getContentResolver().query( - MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, - projection, - MediaStore.Audio.Media.IS_MUSIC + "=1", - null, - "LOWER(" + MediaStore.Audio.Media.ARTIST + ") ASC, " + - "LOWER(" + MediaStore.Audio.Media.ALBUM + ") ASC, " + - "LOWER(" + MediaStore.Audio.Media.TITLE + ") ASC" - ); - if (cursor == null) { - return Collections.emptyList(); - } - List items = new ArrayList<>(); - try { - if (cursor.moveToFirst()) { - int title = cursor.getColumnIndex(MediaStore.Audio.Media.TITLE); - int album = cursor.getColumnIndex(MediaStore.Audio.Media.ALBUM); - int artist = cursor.getColumnIndex(MediaStore.Audio.Media.ARTIST); - int duration = cursor.getColumnIndex(MediaStore.Audio.Media.DURATION); - int albumId = cursor.getColumnIndex(MediaStore.Audio.Media.ALBUM_ID); - int data = cursor.getColumnIndex(MediaStore.Audio.Media.DATA); - do { - MusicItem item = new MusicItem() - .title(cursor.getString(title)) - .album(cursor.getString(album)) - .artist(cursor.getString(artist)) - .duration(cursor.getLong(duration)) - .albumArtUri(ContentUris.withAppendedId(albumArtUri, cursor.getLong(albumId))) - .fileUri(Uri.parse(cursor.getString(data))) - ; - items.add(item); - } while (cursor.moveToNext()); - } - } finally { - cursor.close(); - } - return items; - } -} diff --git a/app/src/main/java/com/cleveroad/audiowidget/example/MusicLoader.kt b/app/src/main/java/com/cleveroad/audiowidget/example/MusicLoader.kt new file mode 100644 index 0000000..9c7fb51 --- /dev/null +++ b/app/src/main/java/com/cleveroad/audiowidget/example/MusicLoader.kt @@ -0,0 +1,62 @@ +package com.cleveroad.audiowidget.example + +import android.content.ContentUris +import android.content.Context +import android.net.Uri +import android.provider.MediaStore +import java.util.* + +/** + * Loader for list of tracks. + */ +internal class MusicLoader(context: Context) : + BaseAsyncTaskLoader>(context) { + private val albumArtUriBase = Uri.parse("content://media/external/audio/albumart") + + override fun loadInBackground(): Collection { + val projection = arrayOf( + MediaStore.Audio.Media.TITLE, + MediaStore.Audio.Media.ALBUM, + MediaStore.Audio.Media.ALBUM_ID, + MediaStore.Audio.Media.ARTIST, + MediaStore.Audio.Media.DURATION, + MediaStore.Audio.Media.DATA + ) + val items: MutableList = ArrayList() + val cursor = context.contentResolver.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + projection, + MediaStore.Audio.Media.IS_MUSIC + "=1", + null, + "LOWER(" + MediaStore.Audio.Media.ARTIST + ") ASC, " + + "LOWER(" + MediaStore.Audio.Media.ALBUM + ") ASC, " + + "LOWER(" + MediaStore.Audio.Media.TITLE + ") ASC" + ) + cursor!!.use { c -> + if (c.moveToFirst()) { + val title = c.getColumnIndex(MediaStore.Audio.Media.TITLE) + val album = c.getColumnIndex(MediaStore.Audio.Media.ALBUM) + val artist = c.getColumnIndex(MediaStore.Audio.Media.ARTIST) + val duration = c.getColumnIndex(MediaStore.Audio.Media.DURATION) + val albumId = c.getColumnIndex(MediaStore.Audio.Media.ALBUM_ID) + val data = c.getColumnIndex(MediaStore.Audio.Media.DATA) + do { + val item = with(MusicItem()) { + this.title = c.getString(title) + this.album = c.getString(album) + this.artist = c.getString(artist) + this.duration = c.getLong(duration) + this.albumArtUri = ContentUris.withAppendedId( + albumArtUriBase, + c.getLong(albumId) + ) + this.fileUri = Uri.parse(c.getString(data)) + this + } + items.add(item) + } while (c.moveToNext()) + } + } + return items + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cleveroad/audiowidget/example/MusicService.java b/app/src/main/java/com/cleveroad/audiowidget/example/MusicService.java deleted file mode 100644 index 92b61af..0000000 --- a/app/src/main/java/com/cleveroad/audiowidget/example/MusicService.java +++ /dev/null @@ -1,378 +0,0 @@ -package com.cleveroad.audiowidget.example; - -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.graphics.Bitmap; -import android.graphics.drawable.Drawable; -import android.media.AudioManager; -import android.media.MediaPlayer; -import android.os.Build; -import android.os.IBinder; -import android.preference.PreferenceManager; -import android.provider.Settings; -import android.util.Log; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.bumptech.glide.Glide; -import com.bumptech.glide.request.RequestOptions; -import com.bumptech.glide.request.target.SimpleTarget; -import com.bumptech.glide.request.transition.Transition; -import com.cleveroad.audiowidget.AudioWidget; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Timer; -import java.util.TimerTask; - -/** - * Simple implementation of music service. - */ -public class MusicService extends Service implements MediaPlayer.OnPreparedListener, - MediaPlayer.OnCompletionListener, MediaPlayer.OnErrorListener, AudioWidget.OnControlsClickListener, AudioWidget.OnWidgetStateChangedListener { - - private static final String TAG = "MusicService"; - private static final String ACTION_SET_TRACKS = "ACTION_SET_TRACKS"; - private static final String ACTION_PLAY_TRACKS = "ACTION_PLAY_TRACKS"; - private static final String ACTION_CHANGE_STATE = "ACTION_CHANGE_STATE"; - private static final String EXTRA_SELECT_TRACK = "EXTRA_SELECT_TRACK"; - private static final String EXTRA_CHANGE_STATE = "EXTRA_CHANGE_STATE"; - private static final long UPDATE_INTERVAL = 1000; - private static final String KEY_POSITION_X = "position_x"; - private static final String KEY_POSITION_Y = "position_y"; - private static MusicItem[] tracks; - private final List items = new ArrayList<>(); - private AudioWidget audioWidget; - private MediaPlayer mediaPlayer; - private boolean preparing; - private int playingIndex = -1; - private boolean paused; - private Timer timer; - private SharedPreferences preferences; - - - public static void setTracks(@NonNull Context context, @NonNull MusicItem[] tracks) { - Intent intent = new Intent(ACTION_SET_TRACKS, null, context, MusicService.class); - MusicService.tracks = tracks; - context.startService(intent); - } - - public static void playTrack(@NonNull Context context, @NonNull MusicItem item) { - Intent intent = new Intent(ACTION_PLAY_TRACKS, null, context, MusicService.class); - intent.putExtra(EXTRA_SELECT_TRACK, item); - context.startService(intent); - } - - public static void setState(@NonNull Context context, boolean isShowing) { - Intent intent = new Intent(ACTION_CHANGE_STATE, null, context, MusicService.class); - intent.putExtra(EXTRA_CHANGE_STATE, isShowing); - context.startService(intent); - } - - @Nullable - @Override - public IBinder onBind(Intent intent) { - return null; - } - - @Override - public void onCreate() { - super.onCreate(); - preferences = PreferenceManager.getDefaultSharedPreferences(this); - mediaPlayer = new MediaPlayer(); - mediaPlayer.setOnPreparedListener(this); - mediaPlayer.setOnCompletionListener(this); - mediaPlayer.setOnErrorListener(this); - mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); - audioWidget = new AudioWidget.Builder(this).build(); - audioWidget.controller().onControlsClickListener(this); - audioWidget.controller().onWidgetStateChangedListener(this); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - if (intent != null && intent.getAction() != null) { - switch (intent.getAction()) { - case ACTION_SET_TRACKS: { - updateTracks(); - break; - } - case ACTION_PLAY_TRACKS: { - selectNewTrack(intent); - break; - } - case ACTION_CHANGE_STATE: { - if (!(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(this))) { - boolean show = intent.getBooleanExtra(EXTRA_CHANGE_STATE, false); - if (show) { - audioWidget.show(preferences.getInt(KEY_POSITION_X, 100), preferences.getInt(KEY_POSITION_Y, 100)); - } else { - audioWidget.hide(); - } - } else { - Log.w(TAG, "Can't change audio widget state! Device does not have drawOverlays permissions!"); - } - break; - } - } - } - return START_STICKY; - } - - private void selectNewTrack(Intent intent) { - if (preparing) { - return; - } - MusicItem item = intent.getParcelableExtra(EXTRA_SELECT_TRACK); - if (item == null && playingIndex == -1 || playingIndex != -1 && items.get(playingIndex).equals(item)) { - if (mediaPlayer.isPlaying()) { - mediaPlayer.pause(); - audioWidget.controller().pause(); - } else { - mediaPlayer.start(); - audioWidget.controller().start(); - } - return; - } - playingIndex = items.indexOf(item); - startCurrentTrack(); - } - - private void startCurrentTrack() { - if (mediaPlayer.isPlaying() || paused) { - mediaPlayer.stop(); - paused = false; - } - mediaPlayer.reset(); - if (playingIndex < 0) { - return; - } - try { - mediaPlayer.setDataSource(this, items.get(playingIndex).fileUri()); - mediaPlayer.prepareAsync(); - preparing = true; - } catch (IOException e) { - e.printStackTrace(); - } - } - - private void updateTracks() { - MusicItem playingItem = null; - if (playingIndex != -1) { - playingItem = items.get(playingIndex); - } - items.clear(); - if (MusicService.tracks != null) { - items.addAll(Arrays.asList(MusicService.tracks)); - MusicService.tracks = null; - } - if (playingItem == null) { - playingIndex = -1; - } else { - playingIndex = this.items.indexOf(playingItem); - } - if (playingIndex == -1 && mediaPlayer.isPlaying()) { - mediaPlayer.stop(); - mediaPlayer.reset(); - } - } - - @Override - public void onDestroy() { - audioWidget.controller().onControlsClickListener(null); - audioWidget.controller().onWidgetStateChangedListener(null); - audioWidget.hide(); - audioWidget = null; - if (mediaPlayer.isPlaying()) { - mediaPlayer.stop(); - } - mediaPlayer.reset(); - mediaPlayer.release(); - mediaPlayer = null; - stopTrackingPosition(); - preferences = null; - super.onDestroy(); - } - - @Override - public void onPrepared(MediaPlayer mp) { - preparing = false; - mediaPlayer.start(); - if (!audioWidget.isShown()) { - audioWidget.show(preferences.getInt(KEY_POSITION_X, 100), preferences.getInt(KEY_POSITION_Y, 100)); - } - audioWidget.controller().start(); - audioWidget.controller().position(0); - audioWidget.controller().duration(mediaPlayer.getDuration()); - stopTrackingPosition(); - startTrackingPosition(); - int size = getResources().getDimensionPixelSize(R.dimen.cover_size); - Glide.with(this) - .asBitmap() - .load(items.get(playingIndex).albumArtUri()) - .override(size, size) - .centerCrop() - .apply((new RequestOptions()).circleCrop()) - .into(new SimpleTarget() { - @Override - public void onLoadFailed(@Nullable Drawable errorDrawable) { - super.onLoadFailed(errorDrawable); - if (audioWidget != null) { - audioWidget.controller().albumCover(null); - } - } - - @Override - public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition transition) { - if (audioWidget != null) { - audioWidget.controller().albumCoverBitmap(resource); - } - } - }); - } - - @Override - public void onCompletion(MediaPlayer mp) { - if (playingIndex == -1) { - audioWidget.controller().stop(); - return; - } - playingIndex++; - if (playingIndex >= items.size()) { - playingIndex = 0; - if (items.size() == 0) { - audioWidget.controller().stop(); - return; - } - } - startCurrentTrack(); - } - - @Override - public boolean onError(MediaPlayer mp, int what, int extra) { - preparing = true; - return false; - } - - @Override - public boolean onPlaylistClicked() { - Intent intent = new Intent(this, MainActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - startActivity(intent); - return false; - } - - @Override - public void onPlaylistLongClicked() { - Log.d(TAG, "playlist long clicked"); - } - - @Override - public void onPreviousClicked() { - if (items.size() == 0) - return; - playingIndex--; - if (playingIndex < 0) { - playingIndex = items.size() - 1; - } - startCurrentTrack(); - } - - @Override - public void onPreviousLongClicked() { - Log.d(TAG, "previous long clicked"); - } - - @Override - public boolean onPlayPauseClicked() { - if (playingIndex == -1) { - Toast.makeText(this, R.string.song_not_selected, Toast.LENGTH_SHORT).show(); - return true; - } - if (mediaPlayer.isPlaying()) { - stopTrackingPosition(); - mediaPlayer.pause(); - audioWidget.controller().start(); - paused = true; - } else { - startTrackingPosition(); - audioWidget.controller().pause(); - mediaPlayer.start(); - paused = false; - } - return false; - } - - @Override - public void onPlayPauseLongClicked() { - Log.d(TAG, "play/pause long clicked"); - } - - @Override - public void onNextClicked() { - if (items.size() == 0) - return; - playingIndex++; - if (playingIndex >= items.size()) { - playingIndex = 0; - } - startCurrentTrack(); - } - - @Override - public void onNextLongClicked() { - Log.d(TAG, "next long clicked"); - } - - @Override - public void onAlbumClicked() { - Log.d(TAG, "album clicked"); - } - - @Override - public void onAlbumLongClicked() { - Log.d(TAG, "album long clicked"); - } - - private void startTrackingPosition() { - timer = new Timer(TAG); - timer.scheduleAtFixedRate(new TimerTask() { - @Override - public void run() { - AudioWidget widget = audioWidget; - MediaPlayer player = mediaPlayer; - if (widget != null) { - widget.controller().position(player.getCurrentPosition()); - } - } - }, UPDATE_INTERVAL, UPDATE_INTERVAL); - } - - private void stopTrackingPosition() { - if (timer == null) - return; - timer.cancel(); - timer.purge(); - timer = null; - } - - @Override - public void onWidgetStateChanged(@NonNull AudioWidget.State state) { - - } - - @Override - public void onWidgetPositionChanged(int cx, int cy) { - preferences.edit() - .putInt(KEY_POSITION_X, cx) - .putInt(KEY_POSITION_Y, cy) - .apply(); - } -} diff --git a/app/src/main/java/com/cleveroad/audiowidget/example/MusicService.kt b/app/src/main/java/com/cleveroad/audiowidget/example/MusicService.kt new file mode 100644 index 0000000..a4715cc --- /dev/null +++ b/app/src/main/java/com/cleveroad/audiowidget/example/MusicService.kt @@ -0,0 +1,366 @@ +package com.cleveroad.audiowidget.example + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.MediaPlayer +import android.media.MediaPlayer.OnCompletionListener +import android.media.MediaPlayer.OnPreparedListener +import android.os.Build +import android.os.IBinder +import android.preference.PreferenceManager +import android.provider.Settings +import android.util.Log +import android.widget.Toast +import com.bumptech.glide.Glide +import com.bumptech.glide.request.RequestOptions +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.cleveroad.audiowidget.AudioWidget +import com.cleveroad.audiowidget.AudioWidget.OnControlsClickListener +import com.cleveroad.audiowidget.AudioWidget.OnWidgetStateChangedListener +import java.io.IOException +import java.util.* + +/** + * Simple implementation of music service. + */ +class MusicService : Service(), OnPreparedListener, OnCompletionListener, + MediaPlayer.OnErrorListener, OnControlsClickListener, OnWidgetStateChangedListener { + private val items = arrayListOf() + private val audioWidget: AudioWidget by lazy { + AudioWidget.Builder(this).build() + } + private val mediaPlayer: MediaPlayer by lazy { + MediaPlayer() + } + private var preparing = false + private var playingIndex = -1 + private var paused = false + private var timer: Timer? = null + private val preferences: SharedPreferences by lazy { + PreferenceManager.getDefaultSharedPreferences(this) + } + + override fun onBind(intent: Intent): IBinder? { + return null + } + + override fun onCreate() { + super.onCreate() + mediaPlayer.setOnPreparedListener(this) + mediaPlayer.setOnCompletionListener(this) + mediaPlayer.setOnErrorListener(this) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + mediaPlayer.setAudioAttributes( + AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA).build() + ) + } else { + @Suppress("DEPRECATION") + mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC) + } + audioWidget.controller().onControlsClickListener(this) + audioWidget.controller().onWidgetStateChangedListener(this) + } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + if (intent.action != null) { + when (intent.action) { + ACTION_SET_TRACKS -> { + updateTracks() + } + ACTION_PLAY_TRACKS -> { + selectNewTrack(intent) + } + ACTION_CHANGE_STATE -> { + if (!(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays( + this + )) + ) { + val show = intent.getBooleanExtra(EXTRA_CHANGE_STATE, false) + if (show) { + audioWidget.show( + preferences.getInt(KEY_POSITION_X, 100), preferences.getInt( + KEY_POSITION_Y, 100 + ) + ) + } else { + audioWidget.hide() + } + } else { + Log.w( + TAG, + "Can't change audio widget state! Device does not have drawOverlays permissions!" + ) + } + } + } + } + return START_STICKY + } + + private fun selectNewTrack(intent: Intent) { + if (preparing) { + return + } + val item: MusicItem = intent.getParcelableExtra(EXTRA_SELECT_TRACK) + if (playingIndex != -1 && items[playingIndex] == item) { + if (mediaPlayer.isPlaying) { + mediaPlayer.pause() + audioWidget.controller().pause() + } else { + mediaPlayer.start() + audioWidget.controller().start() + } + return + } + playingIndex = items.indexOf(item) + startCurrentTrack() + } + + private fun startCurrentTrack() { + if (mediaPlayer.isPlaying || paused) { + mediaPlayer.stop() + paused = false + } + mediaPlayer.reset() + if (playingIndex < 0) { + return + } + try { + mediaPlayer.setDataSource(this, items[playingIndex].fileUri!!) + mediaPlayer.prepareAsync() + preparing = true + } catch (e: IOException) { + e.printStackTrace() + } + } + + private fun updateTracks() { + var playingItem: MusicItem? = null + if (playingIndex != -1) { + playingItem = items[playingIndex] + } + items.clear() + tracks?.let { + items.addAll(it) + } + tracks = null + playingIndex = if (playingItem == null) { + -1 + } else { + items.indexOf(playingItem) + } + if (playingIndex == -1 && mediaPlayer.isPlaying) { + mediaPlayer.stop() + mediaPlayer.reset() + } + } + + override fun onDestroy() { + audioWidget.controller().onControlsClickListener(null) + audioWidget.controller().onWidgetStateChangedListener(null) + audioWidget.hide() + if (mediaPlayer.isPlaying) { + mediaPlayer.stop() + } + mediaPlayer.reset() + mediaPlayer.release() + stopTrackingPosition() + super.onDestroy() + } + + override fun onPrepared(mp: MediaPlayer) { + preparing = false + mediaPlayer.start() + if (!audioWidget.isShown) { + audioWidget.show( + preferences.getInt(KEY_POSITION_X, 100), preferences.getInt( + KEY_POSITION_Y, 100 + ) + ) + } + audioWidget.controller().start() + audioWidget.controller().position(0) + audioWidget.controller().duration(mediaPlayer.duration) + stopTrackingPosition() + startTrackingPosition() + val size = resources.getDimensionPixelSize(R.dimen.cover_size) + Glide.with(this) + .asBitmap() + .load(items[playingIndex].albumArtUri) + .override(size, size) + .centerCrop() + .apply(RequestOptions().circleCrop()) + .into(object : CustomTarget() { + override fun onLoadFailed(errorDrawable: Drawable?) { + audioWidget.controller().albumCover(null) + } + + override fun onResourceReady( + resource: Bitmap, + transition: Transition? + ) { + audioWidget.controller().albumCoverBitmap(resource) + } + + override fun onLoadCleared(placeholder: Drawable?) { + } + }) + } + + override fun onCompletion(mp: MediaPlayer) { + if (playingIndex == -1) { + audioWidget.controller().stop() + return + } + playingIndex++ + if (playingIndex >= items.size) { + playingIndex = 0 + if (items.size == 0) { + audioWidget.controller().stop() + return + } + } + startCurrentTrack() + } + + override fun onError(mp: MediaPlayer, what: Int, extra: Int): Boolean { + preparing = true + return false + } + + override fun onPlaylistClicked(): Boolean { + val intent = Intent(this, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + return false + } + + override fun onPlaylistLongClicked() { + Log.d(TAG, "playlist long clicked") + } + + override fun onPreviousClicked() { + if (items.size == 0) return + playingIndex-- + if (playingIndex < 0) { + playingIndex = items.size - 1 + } + startCurrentTrack() + } + + override fun onPreviousLongClicked() { + Log.d(TAG, "previous long clicked") + } + + override fun onPlayPauseClicked(): Boolean { + if (playingIndex == -1) { + Toast.makeText(this, R.string.song_not_selected, Toast.LENGTH_SHORT).show() + return true + } + paused = if (mediaPlayer.isPlaying) { + stopTrackingPosition() + mediaPlayer.pause() + audioWidget.controller().start() + true + } else { + startTrackingPosition() + audioWidget.controller().pause() + mediaPlayer.start() + false + } + return false + } + + override fun onPlayPauseLongClicked() { + Log.d(TAG, "play/pause long clicked") + } + + override fun onNextClicked() { + if (items.size == 0) return + playingIndex++ + if (playingIndex >= items.size) { + playingIndex = 0 + } + startCurrentTrack() + } + + override fun onNextLongClicked() { + Log.d(TAG, "next long clicked") + } + + override fun onAlbumClicked() { + Log.d(TAG, "album clicked") + } + + override fun onAlbumLongClicked() { + Log.d(TAG, "album long clicked") + } + + private fun startTrackingPosition() { + timer = Timer(TAG).also { + it.scheduleAtFixedRate(object : TimerTask() { + override fun run() { + val widget = audioWidget + val player = mediaPlayer + widget.controller().position(player.currentPosition) + } + }, UPDATE_INTERVAL, UPDATE_INTERVAL) + } + } + + private fun stopTrackingPosition() { + timer?.let { + it.cancel() + it.purge() + } + timer = null + } + + override fun onWidgetStateChanged(state: AudioWidget.State) {} + + override fun onWidgetPositionChanged(cx: Int, cy: Int) { + preferences.edit() + .putInt(KEY_POSITION_X, cx) + .putInt(KEY_POSITION_Y, cy) + .apply() + } + + companion object { + private const val TAG = "MusicService" + private const val ACTION_SET_TRACKS = "ACTION_SET_TRACKS" + private const val ACTION_PLAY_TRACKS = "ACTION_PLAY_TRACKS" + private const val ACTION_CHANGE_STATE = "ACTION_CHANGE_STATE" + private const val EXTRA_SELECT_TRACK = "EXTRA_SELECT_TRACK" + private const val EXTRA_CHANGE_STATE = "EXTRA_CHANGE_STATE" + private const val UPDATE_INTERVAL: Long = 1000 + private const val KEY_POSITION_X = "position_x" + private const val KEY_POSITION_Y = "position_y" + private var tracks: Array? = null + + fun setTracks(context: Context, tracks: Array) { + val intent = Intent(ACTION_SET_TRACKS, null, context, MusicService::class.java) + Companion.tracks = tracks + context.startService(intent) + } + + fun playTrack(context: Context, item: MusicItem) { + val intent = Intent(ACTION_PLAY_TRACKS, null, context, MusicService::class.java) + intent.putExtra(EXTRA_SELECT_TRACK, item) + context.startService(intent) + } + + fun setState(context: Context, isShowing: Boolean) { + val intent = Intent(ACTION_CHANGE_STATE, null, context, MusicService::class.java) + intent.putExtra(EXTRA_CHANGE_STATE, isShowing) + context.startService(intent) + } + } +} \ No newline at end of file diff --git a/audiowidget/build.gradle b/audiowidget/build.gradle index c6258e1..d7d213f 100644 --- a/audiowidget/build.gradle +++ b/audiowidget/build.gradle @@ -1,4 +1,5 @@ apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' android { compileSdkVersion rootProject.ext.compileSdkVersion as Integer @@ -25,9 +26,14 @@ android { dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) - testImplementation 'junit:junit:4.12' + testImplementation 'junit:junit:4.13.1' // Support implementation rootProject.ext.supportDependencies.palette implementation rootProject.ext.supportDependencies.appCompat + implementation "androidx.core:core-ktx:1.3.1" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} +repositories { + mavenCentral() } \ No newline at end of file diff --git a/audiowidget/src/main/java/com/cleveroad/audiowidget/AudioWidget.java b/audiowidget/src/main/java/com/cleveroad/audiowidget/AudioWidget.java deleted file mode 100644 index c134804..0000000 --- a/audiowidget/src/main/java/com/cleveroad/audiowidget/AudioWidget.java +++ /dev/null @@ -1,1391 +0,0 @@ -package com.cleveroad.audiowidget; - -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.animation.ValueAnimator; -import android.annotation.TargetApi; -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.Paint; -import android.graphics.PixelFormat; -import android.graphics.Point; -import android.graphics.RectF; -import android.graphics.drawable.BitmapDrawable; -import android.graphics.drawable.Drawable; -import android.os.Build; -import android.os.Handler; -import android.os.Vibrator; -import android.view.Gravity; -import android.view.KeyCharacterMap; -import android.view.KeyEvent; -import android.view.View; -import android.view.WindowManager; -import android.view.animation.AccelerateDecelerateInterpolator; - -import androidx.annotation.ColorInt; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; - -import java.lang.ref.WeakReference; -import java.util.Map; -import java.util.Random; -import java.util.WeakHashMap; - -/** - * Audio widget implementation. - */ -public class AudioWidget { - - private static final long VIBRATION_DURATION = 100; - /** - * Play/pause button view. - */ - private final PlayPauseButton playPauseButton; - - /** - * Expanded widget style view. - */ - private final ExpandCollapseWidget expandCollapseWidget; - - /** - * Remove widget view. - */ - private final RemoveWidgetView removeWidgetView; - - /** - * Playback state. - */ - private PlaybackState playbackState; - - /** - * Widget controller. - */ - private final Controller controller; - - private final WindowManager windowManager; - private final Vibrator vibrator; - private final Handler handler; - private final Point screenSize; - private final Context context; - private final TouchManager playPauseButtonManager; - private final TouchManager expandedWidgetManager; - private final TouchManager.BoundsChecker ppbToExpBoundsChecker; - private final TouchManager.BoundsChecker expToPpbBoundsChecker; - - private final Map> albumCoverCache = new WeakHashMap<>(); - - /** - * Bounds of remove widget view. Used for checking if play/pause button is inside this bounds - * and ready for removing from screen. - */ - private final RectF removeBounds; - - /** - * Remove widget view X, Y position (hidden). - */ - private final Point hiddenRemWidPos; - - /** - * Remove widget view X, Y position (visible). - */ - private final Point visibleRemWidPos; - private int animatedRemBtnYPos = -1; - private float widgetWidth, widgetHeight, radius; - private final OnControlsClickListenerWrapper onControlsClickListener; - private boolean shown; - private boolean released; - private boolean removeWidgetShown; - private OnWidgetStateChangedListener onWidgetStateChangedListener; - - @SuppressWarnings("deprecation") - private AudioWidget(@NonNull Builder builder) { - this.context = builder.context.getApplicationContext(); - this.vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); - this.handler = new Handler(); - this.screenSize = new Point(); - this.removeBounds = new RectF(); - this.hiddenRemWidPos = new Point(); - this.visibleRemWidPos = new Point(); - this.controller = newController(); - this.windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR2) { - windowManager.getDefaultDisplay().getSize(screenSize); - } else { - screenSize.x = windowManager.getDefaultDisplay().getWidth(); - screenSize.y = windowManager.getDefaultDisplay().getHeight(); - } - screenSize.y -= statusBarHeight() + navigationBarHeight(); - - Configuration configuration = prepareConfiguration(builder); - playPauseButton = new PlayPauseButton(configuration); - expandCollapseWidget = new ExpandCollapseWidget(configuration); - removeWidgetView = new RemoveWidgetView(configuration); - int offsetCollapsed = context.getResources().getDimensionPixelOffset(R.dimen.aw_edge_offset_collapsed); - int offsetExpanded = context.getResources().getDimensionPixelOffset(R.dimen.aw_edge_offset_expanded); - playPauseButtonManager = new TouchManager(playPauseButton, playPauseButton.newBoundsChecker( - builder.edgeOffsetXCollapsedSet ? builder.edgeOffsetXCollapsed : offsetCollapsed, - builder.edgeOffsetYCollapsedSet ? builder.edgeOffsetYCollapsed : offsetCollapsed - )) - .screenWidth(screenSize.x) - .screenHeight(screenSize.y); - expandedWidgetManager = new TouchManager(expandCollapseWidget, expandCollapseWidget.newBoundsChecker( - builder.edgeOffsetXExpandedSet ? builder.edgeOffsetXExpanded : offsetExpanded, - builder.edgeOffsetYExpandedSet ? builder.edgeOffsetYExpanded : offsetExpanded - )) - .screenWidth(screenSize.x) - .screenHeight(screenSize.y); - - playPauseButtonManager.callback(new PlayPauseButtonCallback()); - expandedWidgetManager.callback(new ExpandCollapseWidgetCallback()); - expandCollapseWidget.onWidgetStateChangedListener(new OnWidgetStateChangedListener() { - @Override - public void onWidgetStateChanged(@NonNull State state) { - if (state == State.COLLAPSED) { - playPauseButton.setLayerType(View.LAYER_TYPE_SOFTWARE, null); - try { - windowManager.removeView(expandCollapseWidget); - } catch (IllegalArgumentException e) { - // view not attached to window - } - playPauseButton.enableProgressChanges(true); - } - if (onWidgetStateChangedListener != null) { - onWidgetStateChangedListener.onWidgetStateChanged(state); - } - } - - @Override - public void onWidgetPositionChanged(int cx, int cy) { - - } - }); - onControlsClickListener = new OnControlsClickListenerWrapper(); - expandCollapseWidget.onControlsClickListener(onControlsClickListener); - ppbToExpBoundsChecker = playPauseButton.newBoundsChecker( - builder.edgeOffsetXExpandedSet ? builder.edgeOffsetXExpanded : offsetExpanded, - builder.edgeOffsetYExpandedSet ? builder.edgeOffsetYExpanded : offsetExpanded - ); - expToPpbBoundsChecker = expandCollapseWidget.newBoundsChecker( - builder.edgeOffsetXCollapsedSet ? builder.edgeOffsetXCollapsed : offsetCollapsed, - builder.edgeOffsetYCollapsedSet ? builder.edgeOffsetYCollapsed : offsetCollapsed - ); - } - - /** - * Prepare configuration for widget. - * @param builder user defined settings - * @return new configuration for widget - */ - private Configuration prepareConfiguration(@NonNull Builder builder) { - int darkColor = builder.darkColorSet ? builder.darkColor : ContextCompat.getColor(context, R.color.aw_dark); - int lightColor = builder.lightColorSet ? builder.lightColor : ContextCompat.getColor(context, R.color.aw_light); - int progressColor = builder.progressColorSet ? builder.progressColor : ContextCompat.getColor(context, R.color.aw_progress); - int expandColor = builder.expandWidgetColorSet ? builder.expandWidgetColor : ContextCompat.getColor(context, R.color.aw_expanded); - int crossColor = builder.crossColorSet ? builder.crossColor : ContextCompat.getColor(context, R.color.aw_cross_default); - int crossOverlappedColor = builder.crossOverlappedColorSet ? builder.crossOverlappedColor : ContextCompat.getColor(context, R.color.aw_cross_overlapped); - int shadowColor = builder.shadowColorSet ? builder.shadowColor : ContextCompat.getColor(context, R.color.aw_shadow); - - Drawable playDrawable = builder.playDrawable != null ? builder.playDrawable : ContextCompat.getDrawable(context, R.drawable.aw_ic_play); - Drawable pauseDrawable = builder.pauseDrawable != null ? builder.pauseDrawable : ContextCompat.getDrawable(context, R.drawable.aw_ic_pause); - Drawable prevDrawable = builder.prevDrawable != null ? builder.prevDrawable : ContextCompat.getDrawable(context, R.drawable.aw_ic_prev); - Drawable nextDrawable = builder.nextDrawable != null ? builder.nextDrawable : ContextCompat.getDrawable(context, R.drawable.aw_ic_next); - Drawable playlistDrawable = builder.playlistDrawable != null ? builder.playlistDrawable : ContextCompat.getDrawable(context, R.drawable.aw_ic_playlist); - Drawable albumDrawable = builder.defaultAlbumDrawable != null ? builder.defaultAlbumDrawable : ContextCompat.getDrawable(context, R.drawable.aw_ic_default_album); - - int buttonPadding = builder.buttonPaddingSet ? builder.buttonPadding : context.getResources().getDimensionPixelSize(R.dimen.aw_button_padding); - float crossStrokeWidth = builder.crossStrokeWidthSet ? builder.crossStrokeWidth : context.getResources().getDimension(R.dimen.aw_cross_stroke_width); - float progressStrokeWidth = builder.progressStrokeWidthSet ? builder.progressStrokeWidth : context.getResources().getDimension(R.dimen.aw_progress_stroke_width); - float shadowRadius = builder.shadowRadiusSet ? builder.shadowRadius : context.getResources().getDimension(R.dimen.aw_shadow_radius); - float shadowDx = builder.shadowDxSet ? builder.shadowDx : context.getResources().getDimension(R.dimen.aw_shadow_dx); - float shadowDy = builder.shadowDySet ? builder.shadowDy : context.getResources().getDimension(R.dimen.aw_shadow_dy); - float bubblesMinSize = builder.bubblesMinSizeSet ? builder.bubblesMinSize : context.getResources().getDimension(R.dimen.aw_bubbles_min_size); - float bubblesMaxSize = builder.bubblesMaxSizeSet ? builder.bubblesMaxSize : context.getResources().getDimension(R.dimen.aw_bubbles_max_size); - int prevNextExtraPadding = context.getResources().getDimensionPixelSize(R.dimen.aw_prev_next_button_extra_padding); - - widgetHeight = context.getResources().getDimensionPixelSize(R.dimen.aw_player_height); - widgetWidth = context.getResources().getDimensionPixelSize(R.dimen.aw_player_width); - radius = widgetHeight / 2f; - playbackState = new PlaybackState(); - return new Configuration.Builder() - .context(context) - .playbackState(playbackState) - .random(new Random()) - .accDecInterpolator(new AccelerateDecelerateInterpolator()) - .darkColor(darkColor) - .playColor(lightColor) - .progressColor(progressColor) - .expandedColor(expandColor) - .widgetWidth(widgetWidth) - .radius(radius) - .playlistDrawable(playlistDrawable) - .playDrawable(playDrawable) - .prevDrawable(prevDrawable) - .nextDrawable(nextDrawable) - .pauseDrawable(pauseDrawable) - .albumDrawable(albumDrawable) - .buttonPadding(buttonPadding) - .prevNextExtraPadding(prevNextExtraPadding) - .crossStrokeWidth(crossStrokeWidth) - .progressStrokeWidth(progressStrokeWidth) - .shadowRadius(shadowRadius) - .shadowDx(shadowDx) - .shadowDy(shadowDy) - .shadowColor(shadowColor) - .bubblesMinSize(bubblesMinSize) - .bubblesMaxSize(bubblesMaxSize) - .crossColor(crossColor) - .crossOverlappedColor(crossOverlappedColor) - .build(); - } - - /** - * Get status bar height. - * @return status bar height. - */ - private int statusBarHeight() { - int resourceId = context.getResources().getIdentifier("status_bar_height", "dimen", "android"); - if (resourceId > 0) { - return context.getResources().getDimensionPixelSize(resourceId); - } - return context.getResources().getDimensionPixelSize(R.dimen.aw_status_bar_height); - } - - /** - * Get navigation bar height. - * @return navigation bar height - */ - private int navigationBarHeight() { - if (hasNavigationBar()) { - int resourceId = context.getResources().getIdentifier("navigation_bar_height", "dimen", "android"); - if (resourceId > 0) { - return context.getResources().getDimensionPixelSize(resourceId); - } - return context.getResources().getDimensionPixelSize(R.dimen.aw_navigation_bar_height); - } - return 0; - } - - /** - * Check if device has navigation bar. - * @return true if device has navigation bar, false otherwise. - */ - private boolean hasNavigationBar() { - boolean hasBackKey = KeyCharacterMap.deviceHasKey(KeyEvent.KEYCODE_BACK); - boolean hasHomeKey = KeyCharacterMap.deviceHasKey(KeyEvent.KEYCODE_HOME); - int id = context.getResources().getIdentifier("config_showNavigationBar", "bool", "android"); - return !hasBackKey && !hasHomeKey || id > 0 && context.getResources().getBoolean(id); - } - - /** - * Create new controller. - * - * @return new controller - */ - @NonNull - private Controller newController() { - return new Controller() { - - @Override - public void start() { - playbackState.start(this); - } - - @Override - public void pause() { - playbackState.pause(this); - } - - @Override - public void stop() { - playbackState.stop(this); - } - - @Override - public int duration() { - return playbackState.duration(); - } - - @Override - public void duration(int duration) { - playbackState.duration(duration); - } - - @Override - public int position() { - return playbackState.position(); - } - - @Override - public void position(int position) { - playbackState.position(position); - } - - @Override - public void onControlsClickListener(@Nullable OnControlsClickListener onControlsClickListener) { - AudioWidget.this.onControlsClickListener.onControlsClickListener(onControlsClickListener); - } - - @Override - public void onWidgetStateChangedListener(@Nullable OnWidgetStateChangedListener onWidgetStateChangedListener) { - AudioWidget.this.onWidgetStateChangedListener = onWidgetStateChangedListener; - } - - @Override - public void albumCover(@Nullable Drawable albumCover) { - expandCollapseWidget.albumCover(albumCover); - playPauseButton.albumCover(albumCover); - } - - @Override - public void albumCoverBitmap(@Nullable Bitmap bitmap) { - if (bitmap == null) { - expandCollapseWidget.albumCover(null); - playPauseButton.albumCover(null); - } else { - WeakReference wrDrawable = albumCoverCache.get(bitmap.hashCode()); - if(wrDrawable != null) { - Drawable drawable = wrDrawable.get(); - if(drawable != null) { - expandCollapseWidget.albumCover(drawable); - playPauseButton.albumCover(drawable); - return; - } - } - - Drawable albumCover = new BitmapDrawable(context.getResources(), bitmap); - expandCollapseWidget.albumCover(albumCover); - playPauseButton.albumCover(albumCover); - albumCoverCache.put(bitmap.hashCode(), new WeakReference<>(albumCover)); - } - } - }; - } - - /** - * Show widget at specified position. - * - * @param cx center x - * @param cy center y - */ - public void show(int cx, int cy) { - if (shown) { - return; - } - shown = true; - float remWidX = screenSize.x / 2f - radius * RemoveWidgetView.SCALE_LARGE; - hiddenRemWidPos.set((int)remWidX, (int) (screenSize.y + widgetHeight + navigationBarHeight())); - visibleRemWidPos.set((int)remWidX, (int) (screenSize.y - radius - (hasNavigationBar() ? 0 : widgetHeight))); - try { - show(removeWidgetView, hiddenRemWidPos.x, hiddenRemWidPos.y); - } catch (IllegalArgumentException e) { - // widget not removed yet, animation in progress - } - show(playPauseButton, (int) (cx - widgetHeight), (int) (cy - widgetHeight)); - playPauseButtonManager.animateToBounds(); - } - - /** - * Hide widget. - */ - public void hide() { - hideInternal(true); - } - - private void hideInternal(boolean byPublic) { - if (!shown) { - return; - } - shown = false; - released = true; - try { - windowManager.removeView(playPauseButton); - } catch (IllegalArgumentException e) { - // view not attached to window - } - if (byPublic) { - try { - windowManager.removeView(removeWidgetView); - } catch (IllegalArgumentException e) { - // view not attached to window - } - } - try { - windowManager.removeView(expandCollapseWidget); - } catch (IllegalArgumentException e) { - // widget not added to window yet - } - if (onWidgetStateChangedListener != null) { - onWidgetStateChangedListener.onWidgetStateChanged(State.REMOVED); - } - } - - /** - * Get current visibility state. - * - * @return true if widget shown on screen, false otherwise. - */ - public boolean isShown() { - return shown; - } - - public void expand() { - removeWidgetShown = false; - playPauseButton.enableProgressChanges(false); - playPauseButton.postDelayed(this::checkSpaceAndShowExpanded, PlayPauseButton.PROGRESS_CHANGES_DURATION); - } - - public void collapse() { - expandCollapseWidget.setCollapseListener(playPauseButton::setAlpha); - - WindowManager.LayoutParams params = (WindowManager.LayoutParams) expandCollapseWidget.getLayoutParams(); - int cx = params.x + expandCollapseWidget.getWidth() / 2; - if(cx > screenSize.x / 2) { - expandCollapseWidget.expandDirection(ExpandCollapseWidget.DIRECTION_LEFT); - } else { - expandCollapseWidget.expandDirection(ExpandCollapseWidget.DIRECTION_RIGHT); - } - updatePlayPauseButtonPosition(); - if (expandCollapseWidget.collapse()) { - playPauseButtonManager.animateToBounds(); - expandedWidgetManager.animateToBounds(expToPpbBoundsChecker, null); - } - } - - private void updatePlayPauseButtonPosition() { - WindowManager.LayoutParams widgetParams = (WindowManager.LayoutParams) expandCollapseWidget.getLayoutParams(); - WindowManager.LayoutParams params = (WindowManager.LayoutParams) playPauseButton.getLayoutParams(); - if (expandCollapseWidget.expandDirection() == ExpandCollapseWidget.DIRECTION_RIGHT) { - params.x = (int) (widgetParams.x - radius); - } else { - params.x = (int) (widgetParams.x + widgetWidth - widgetHeight - radius); - } - params.y = widgetParams.y; - try { - windowManager.updateViewLayout(playPauseButton, params); - } catch (IllegalArgumentException e) { - // view not attached to window - } - if (onWidgetStateChangedListener != null) { - onWidgetStateChangedListener.onWidgetPositionChanged((int) (params.x + widgetHeight), (int) (params.y + widgetHeight)); - } - } - - @SuppressWarnings("deprecation") - private void checkSpaceAndShowExpanded() { - WindowManager.LayoutParams params = (WindowManager.LayoutParams) playPauseButton.getLayoutParams(); - int x = params.x; - int y = params.y; - int expandDirection; - if (x + widgetHeight > screenSize.x / 2) { - expandDirection = ExpandCollapseWidget.DIRECTION_LEFT; - } else { - expandDirection = ExpandCollapseWidget.DIRECTION_RIGHT; - } - - playPauseButtonManager.animateToBounds(ppbToExpBoundsChecker, () -> { - WindowManager.LayoutParams params1 = (WindowManager.LayoutParams) playPauseButton.getLayoutParams(); - int x1 = params1.x; - int y1 = params1.y; - if (expandDirection == ExpandCollapseWidget.DIRECTION_LEFT) { - x1 -= widgetWidth - widgetHeight * 1.5f; - } else { - x1 += widgetHeight / 2f; - } - show(expandCollapseWidget, x1, y1); - playPauseButton.setLayerType(View.LAYER_TYPE_NONE, null); - - expandCollapseWidget.setExpandListener(percent -> playPauseButton.setAlpha(1f - percent)); - expandCollapseWidget.expand(expandDirection); - }); - } - - /** - * Get widget controller. - * - * @return widget controller - */ - @NonNull - public Controller controller() { - return controller; - } - - private void show(View view, int left, int top) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - preOreoShow(view, left, top); - } else { - oreoShow(view, left, top); - } - } - - private void preOreoShow(View view, int left, int top) { - WindowManager.LayoutParams params = new WindowManager.LayoutParams( - WindowManager.LayoutParams.WRAP_CONTENT, - WindowManager.LayoutParams.WRAP_CONTENT, - WindowManager.LayoutParams.TYPE_PHONE, - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE - | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL - | WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH - | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, - PixelFormat.TRANSLUCENT); - params.gravity = Gravity.START | Gravity.TOP; - params.x = left; - params.y = top; - windowManager.addView(view, params); - } - - @TargetApi(Build.VERSION_CODES.O) - private void oreoShow(View view, int left, int top) { - WindowManager.LayoutParams params = new WindowManager.LayoutParams( - WindowManager.LayoutParams.WRAP_CONTENT, - WindowManager.LayoutParams.WRAP_CONTENT, - WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE - | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL - | WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH - | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, - PixelFormat.TRANSLUCENT); - - - params.gravity = Gravity.START | Gravity.TOP; - params.x = left; - params.y = top; - windowManager.addView(view, params); - } - - abstract static class BoundsCheckerWithOffset implements TouchManager.BoundsChecker { - - private int offsetX, offsetY; - - public BoundsCheckerWithOffset(int offsetX, int offsetY) { - this.offsetX = offsetX; - this.offsetY = offsetY; - } - - @Override - public final float stickyLeftSide(float screenWidth) { - return stickyLeftSideImpl(screenWidth) + offsetX; - } - - @Override - public final float stickyRightSide(float screenWidth) { - return stickyRightSideImpl(screenWidth) - offsetX; - } - - @Override - public final float stickyTopSide(float screenHeight) { - return stickyTopSideImpl(screenHeight) + offsetY; - } - - @Override - public final float stickyBottomSide(float screenHeight) { - return stickyBottomSideImpl(screenHeight) - offsetY; - } - - protected abstract float stickyLeftSideImpl(float screenWidth); - protected abstract float stickyRightSideImpl(float screenWidth); - protected abstract float stickyTopSideImpl(float screenHeight); - protected abstract float stickyBottomSideImpl(float screenHeight); - } - - /** - * Helper class for dealing with collapsed widget touch events. - */ - private class PlayPauseButtonCallback extends TouchManager.SimpleCallback { - - private static final long REMOVE_BTN_ANIM_DURATION = 200; - private final ValueAnimator.AnimatorUpdateListener animatorUpdateListener; - private boolean readyToRemove; - - PlayPauseButtonCallback() { - animatorUpdateListener = animation -> { - if (!removeWidgetShown) { - return; - } - animatedRemBtnYPos = (int)((float) animation.getAnimatedValue()); - updateRemoveBtnPosition(); - }; - } - - @Override - public void onClick(float x, float y) { - playPauseButton.onClick(); - if (onControlsClickListener != null) { - onControlsClickListener.onPlayPauseClicked(); - } - } - - @Override - public void onLongClick(float x, float y) { - released = true; - expand(); - } - - @Override - public void onTouched(float x, float y) { - super.onTouched(x, y); - released = false; - handler.postDelayed(() -> { - if (!released) { - removeWidgetShown = true; - ValueAnimator animator = ValueAnimator.ofFloat(hiddenRemWidPos.y, visibleRemWidPos.y); - animator.setDuration(REMOVE_BTN_ANIM_DURATION); - animator.addUpdateListener(animatorUpdateListener); - animator.start(); - } - }, Configuration.LONG_CLICK_THRESHOLD); - playPauseButton.onTouchDown(); - } - - @Override - public void onMoved(float diffX, float diffY) { - super.onMoved(diffX, diffY); - boolean curReadyToRemove = isReadyToRemove(); - if (curReadyToRemove != readyToRemove) { - readyToRemove = curReadyToRemove; - removeWidgetView.setOverlapped(readyToRemove); - if (readyToRemove && vibrator.hasVibrator()) { - vibrator.vibrate(VIBRATION_DURATION); - } - } - updateRemoveBtnPosition(); - } - - private void updateRemoveBtnPosition() { - if(removeWidgetShown) { - WindowManager.LayoutParams playPauseBtnParams = (WindowManager.LayoutParams) playPauseButton.getLayoutParams(); - WindowManager.LayoutParams removeBtnParams = (WindowManager.LayoutParams) removeWidgetView.getLayoutParams(); - - double tgAlpha = (screenSize.x / 2. - playPauseBtnParams.x) / (visibleRemWidPos.y - playPauseBtnParams.y); - double rotationDegrees = 360 - Math.toDegrees(Math.atan(tgAlpha)); - - float distance = (float) Math.sqrt(Math.pow(animatedRemBtnYPos - playPauseBtnParams.y, 2) + - Math.pow(visibleRemWidPos.x - hiddenRemWidPos.x, 2)); - float maxDistance = (float) Math.sqrt(Math.pow(screenSize.x, 2) + Math.pow(screenSize.y, 2)); - distance /= maxDistance; - - if (animatedRemBtnYPos == -1) { - animatedRemBtnYPos = visibleRemWidPos.y; - } - - removeBtnParams.x = (int) DrawableUtils.rotateX( - visibleRemWidPos.x, animatedRemBtnYPos - radius * distance, - hiddenRemWidPos.x, animatedRemBtnYPos, (float) rotationDegrees); - removeBtnParams.y = (int) DrawableUtils.rotateY( - visibleRemWidPos.x, animatedRemBtnYPos - radius * distance, - hiddenRemWidPos.x, animatedRemBtnYPos, (float) rotationDegrees); - - try { - windowManager.updateViewLayout(removeWidgetView, removeBtnParams); - } catch (IllegalArgumentException e) { - // view not attached to window - } - } - } - - @Override - public void onReleased(float x, float y) { - super.onReleased(x, y); - playPauseButton.onTouchUp(); - released = true; - if (removeWidgetShown) { - ValueAnimator animator = ValueAnimator.ofFloat(visibleRemWidPos.y, hiddenRemWidPos.y); - animator.setDuration(REMOVE_BTN_ANIM_DURATION); - animator.addUpdateListener(animatorUpdateListener); - animator.addListener(new AnimatorListenerAdapter() { - - @Override - public void onAnimationEnd(Animator animation) { - removeWidgetShown = false; - if (!shown) { - try { - windowManager.removeView(removeWidgetView); - } catch (IllegalArgumentException e) { - // view not attached to window - } - } - } - }); - animator.start(); - } - if (isReadyToRemove()) { - hideInternal(false); - } else { - if (onWidgetStateChangedListener != null) { - WindowManager.LayoutParams params = (WindowManager.LayoutParams) playPauseButton.getLayoutParams(); - onWidgetStateChangedListener.onWidgetPositionChanged((int) (params.x + widgetHeight), (int) (params.y + widgetHeight)); - } - } - } - - @Override - public void onAnimationCompleted() { - super.onAnimationCompleted(); - if (onWidgetStateChangedListener != null) { - WindowManager.LayoutParams params = (WindowManager.LayoutParams) playPauseButton.getLayoutParams(); - onWidgetStateChangedListener.onWidgetPositionChanged((int) (params.x + widgetHeight), (int) (params.y + widgetHeight)); - } - } - - private boolean isReadyToRemove() { - WindowManager.LayoutParams removeParams = (WindowManager.LayoutParams) removeWidgetView.getLayoutParams(); - removeBounds.set(removeParams.x, removeParams.y, removeParams.x + widgetHeight, removeParams.y + widgetHeight); - WindowManager.LayoutParams params = (WindowManager.LayoutParams) playPauseButton.getLayoutParams(); - float cx = params.x + widgetHeight; - float cy = params.y + widgetHeight; - return removeBounds.contains(cx, cy); - } - } - - /** - * Helper class for dealing with expanded widget touch events. - */ - private class ExpandCollapseWidgetCallback extends TouchManager.SimpleCallback { - - @Override - public void onTouched(float x, float y) { - super.onTouched(x, y); - expandCollapseWidget.onTouched(x, y); - } - - @Override - public void onReleased(float x, float y) { - super.onReleased(x, y); - expandCollapseWidget.onReleased(x, y); - } - - @Override - public void onClick(float x, float y) { - super.onClick(x, y); - expandCollapseWidget.onClick(x, y); - } - - @Override - public void onLongClick(float x, float y) { - super.onLongClick(x, y); - expandCollapseWidget.onLongClick(x, y); - } - - @Override - public void onTouchOutside() { - if(!expandCollapseWidget.isAnimationInProgress()) { - collapse(); - } - } - - @Override - public void onMoved(float diffX, float diffY) { - super.onMoved(diffX, diffY); - updatePlayPauseButtonPosition(); - } - - @Override - public void onAnimationCompleted() { - super.onAnimationCompleted(); - updatePlayPauseButtonPosition(); - } - } - - private class OnControlsClickListenerWrapper implements OnControlsClickListener { - - private OnControlsClickListener onControlsClickListener; - - public OnControlsClickListenerWrapper onControlsClickListener(OnControlsClickListener inner) { - this.onControlsClickListener = inner; - return this; - } - - @Override - public boolean onPlaylistClicked() { - if (onControlsClickListener == null || !onControlsClickListener.onPlaylistClicked()) { - collapse(); - return true; - } - return false; - } - - @Override - public void onPlaylistLongClicked() { - if (onControlsClickListener != null) { - onControlsClickListener.onPlaylistLongClicked(); - } - } - - @Override - public void onPreviousClicked() { - if (onControlsClickListener != null) { - onControlsClickListener.onPreviousClicked(); - } - } - - @Override - public void onPreviousLongClicked() { - if (onControlsClickListener != null) { - onControlsClickListener.onPreviousLongClicked(); - } - } - - @Override - public boolean onPlayPauseClicked() { - if (onControlsClickListener == null || !onControlsClickListener.onPlayPauseClicked()) { - if (playbackState.state() != Configuration.STATE_PLAYING) { - playbackState.start(AudioWidget.this); - } else { - playbackState.pause(AudioWidget.this); - } - return true; - } - return false; - } - - @Override - public void onPlayPauseLongClicked() { - if (onControlsClickListener != null) { - onControlsClickListener.onPlayPauseLongClicked(); - } - } - - @Override - public void onNextClicked() { - if (onControlsClickListener != null) { - onControlsClickListener.onNextClicked(); - } - } - - @Override - public void onNextLongClicked() { - if (onControlsClickListener != null) { - onControlsClickListener.onNextLongClicked(); - } - } - - @Override - public void onAlbumClicked() { - if (onControlsClickListener != null) { - onControlsClickListener.onAlbumClicked(); - } - } - - @Override - public void onAlbumLongClicked() { - if (onControlsClickListener != null) { - onControlsClickListener.onAlbumLongClicked(); - } - } - } - - /** - * Builder class for {@link AudioWidget}. - */ - public static class Builder { - - private final Context context; - - @ColorInt - private int darkColor; - @ColorInt - private int lightColor; - @ColorInt - private int progressColor; - @ColorInt - private int crossColor; - @ColorInt - private int crossOverlappedColor; - @ColorInt - private int shadowColor; - @ColorInt - private int expandWidgetColor; - private int buttonPadding; - private float crossStrokeWidth; - private float progressStrokeWidth; - private float shadowRadius; - private float shadowDx; - private float shadowDy; - private float bubblesMinSize; - private float bubblesMaxSize; - private Drawable playDrawable; - private Drawable prevDrawable; - private Drawable nextDrawable; - private Drawable playlistDrawable; - private Drawable defaultAlbumDrawable; - private Drawable pauseDrawable; - private boolean darkColorSet; - private boolean lightColorSet; - private boolean progressColorSet; - private boolean crossColorSet; - private boolean crossOverlappedColorSet; - private boolean shadowColorSet; - private boolean expandWidgetColorSet; - private boolean buttonPaddingSet; - private boolean crossStrokeWidthSet; - private boolean progressStrokeWidthSet; - private boolean shadowRadiusSet; - private boolean shadowDxSet; - private boolean shadowDySet; - private boolean bubblesMinSizeSet; - private boolean bubblesMaxSizeSet; - private int edgeOffsetXCollapsed; - private int edgeOffsetYCollapsed; - private int edgeOffsetXExpanded; - private int edgeOffsetYExpanded; - private boolean edgeOffsetXCollapsedSet; - private boolean edgeOffsetYCollapsedSet; - private boolean edgeOffsetXExpandedSet; - private boolean edgeOffsetYExpandedSet; - - public Builder(@NonNull Context context) { - this.context = context; - } - - /** - * Set dark color (playing state). - * @param darkColor dark color - */ - public Builder darkColor(@ColorInt int darkColor) { - this.darkColor = darkColor; - darkColorSet = true; - return this; - } - - /** - * Set light color (paused state). - * @param lightColor light color - */ - public Builder lightColor(@ColorInt int lightColor) { - this.lightColor = lightColor; - lightColorSet = true; - return this; - } - - /** - * Set progress bar color. - * @param progressColor progress bar color - */ - public Builder progressColor(@ColorInt int progressColor) { - this.progressColor = progressColor; - progressColorSet = true; - return this; - } - - /** - * Set remove widget cross color. - * @param crossColor cross color - */ - public Builder crossColor(@ColorInt int crossColor) { - this.crossColor = crossColor; - crossColorSet = true; - return this; - } - - /** - * Set remove widget cross color in overlapped state (audio widget overlapped remove widget). - * @param crossOverlappedColor cross color in overlapped state - */ - public Builder crossOverlappedColor(@ColorInt int crossOverlappedColor) { - this.crossOverlappedColor = crossOverlappedColor; - crossOverlappedColorSet = true; - return this; - } - - /** - * Set shadow color. - * @param shadowColor shadow color - */ - public Builder shadowColor(@ColorInt int shadowColor) { - this.shadowColor = shadowColor; - shadowColorSet = true; - return this; - } - - /** - * Set widget color in expanded state. - * @param expandWidgetColor widget color in expanded state - */ - public Builder expandWidgetColor(@ColorInt int expandWidgetColor) { - this.expandWidgetColor = expandWidgetColor; - expandWidgetColorSet = true; - return this; - } - - /** - * Set button padding in pixels. Default value: 10dp. - * @param buttonPadding button padding - */ - public Builder buttonPadding(int buttonPadding) { - this.buttonPadding = buttonPadding; - buttonPaddingSet = true; - return this; - } - - /** - * Set stroke width of remove widget. Default value: 4dp. - * @param crossStrokeWidth stroke width of remove widget - */ - public Builder crossStrokeWidth(float crossStrokeWidth) { - this.crossStrokeWidth = crossStrokeWidth; - crossStrokeWidthSet = true; - return this; - } - - /** - * Set stroke width of progress bar. Default value: 4dp. - * @param progressStrokeWidth stroke width of progress bar - */ - public Builder progressStrokeWidth(float progressStrokeWidth) { - this.progressStrokeWidth = progressStrokeWidth; - progressStrokeWidthSet = true; - return this; - } - - /** - * Set shadow radius. Default value: 5dp. - * @param shadowRadius shadow radius. - * @see Paint#setShadowLayer(float, float, float, int) - */ - public Builder shadowRadius(float shadowRadius) { - this.shadowRadius = shadowRadius; - shadowRadiusSet = true; - return this; - } - - /** - * Set shadow dx. Default value: 1dp. - * @param shadowDx shadow dx - * @see Paint#setShadowLayer(float, float, float, int) - */ - public Builder shadowDx(float shadowDx) { - this.shadowDx = shadowDx; - shadowDxSet = true; - return this; - } - - /** - * Set shadow dx. Default value: 1dp. - * @param shadowDy shadow dy - * @see Paint#setShadowLayer(float, float, float, int) - */ - public Builder shadowDy(float shadowDy) { - this.shadowDy = shadowDy; - shadowDySet = true; - return this; - } - - /** - * Set bubbles minimum size in pixels. Default value: 5dp. - * @param bubblesMinSize bubbles minimum size - */ - public Builder bubblesMinSize(float bubblesMinSize) { - this.bubblesMinSize = bubblesMinSize; - bubblesMinSizeSet = true; - return this; - } - - /** - * Set bubbles maximum size in pixels. Default value: 10dp. - * @param bubblesMaxSize bubbles maximum size - */ - public Builder bubblesMaxSize(float bubblesMaxSize) { - this.bubblesMaxSize = bubblesMaxSize; - bubblesMaxSizeSet = true; - return this; - } - - /** - * Set drawable for play button. - * @param playDrawable drawable for play button - */ - public Builder playDrawable(@NonNull Drawable playDrawable) { - this.playDrawable = playDrawable; - return this; - } - - /** - * Set drawable for previous track button. - * @param prevDrawable drawable for previous track button - */ - public Builder prevTrackDrawale(@NonNull Drawable prevDrawable) { - this.prevDrawable = prevDrawable; - return this; - } - - /** - * Set drawable for next track button. - * @param nextDrawable drawable for next track button. - */ - public Builder nextTrackDrawable(@NonNull Drawable nextDrawable) { - this.nextDrawable = nextDrawable; - return this; - } - - /** - * Set drawable for playlist button. - * @param playlistDrawable drawable for playlist button - */ - public Builder playlistDrawable(@NonNull Drawable playlistDrawable) { - this.playlistDrawable = playlistDrawable; - return this; - } - - /** - * Set drawable for default album icon. - * @param defaultAlbumCover drawable for default album icon - */ - public Builder defaultAlbumDrawable(@NonNull Drawable defaultAlbumCover) { - this.defaultAlbumDrawable = defaultAlbumCover; - return this; - } - - /** - * Set drawable for pause button. - * @param pauseDrawable drawable for pause button - */ - public Builder pauseDrawable(@NonNull Drawable pauseDrawable) { - this.pauseDrawable = pauseDrawable; - return this; - } - - /** - * Set widget edge offset on X axis - * @param edgeOffsetX widget edge offset on X axis - */ - public Builder edgeOffsetXCollapsed(int edgeOffsetX) { - this.edgeOffsetXCollapsed = edgeOffsetX; - edgeOffsetXCollapsedSet = true; - return this; - } - - /** - * Set widget edge offset on Y axis - * @param edgeOffsetY widget edge offset on Y axis - */ - public Builder edgeOffsetYCollapsed(int edgeOffsetY) { - this.edgeOffsetYCollapsed = edgeOffsetY; - edgeOffsetYCollapsedSet = true; - return this; - } - - public Builder edgeOffsetYExpanded(int edgeOffsetY) { - this.edgeOffsetYExpanded = edgeOffsetY; - edgeOffsetYExpandedSet = true; - return this; - } - - public Builder edgeOffsetXExpanded(int edgeOffsetX) { - this.edgeOffsetXExpanded = edgeOffsetX; - edgeOffsetXExpandedSet = true; - return this; - } - - /** - * Create new audio widget. - * @return new audio widget - * @throws IllegalStateException if size parameters have wrong values (less than zero). - */ - public AudioWidget build() { - if (buttonPaddingSet) { - checkOrThrow(buttonPadding, "Button padding"); - } - if (shadowRadiusSet) { - checkOrThrow(shadowRadius, "Shadow radius"); - } - if (shadowDxSet) { - checkOrThrow(shadowDx, "Shadow dx"); - } - if (shadowDySet) { - checkOrThrow(shadowDy, "Shadow dy"); - } - if (bubblesMinSizeSet) { - checkOrThrow(bubblesMinSize, "Bubbles min size"); - } - if (bubblesMaxSizeSet) { - checkOrThrow(bubblesMaxSize, "Bubbles max size"); - } - if (bubblesMinSizeSet && bubblesMaxSizeSet && bubblesMaxSize < bubblesMinSize) { - throw new IllegalArgumentException("Bubbles max size must be greater than bubbles min size"); - } - if (crossStrokeWidthSet) { - checkOrThrow(crossStrokeWidth, "Cross stroke width"); - } - if (progressStrokeWidthSet) { - checkOrThrow(progressStrokeWidth, "Progress stroke width"); - } - return new AudioWidget(this); - } - - private void checkOrThrow(int number, String name) { - if (number < 0) - throw new IllegalArgumentException(name + " must be equals or greater zero."); - } - - private void checkOrThrow(float number, String name) { - if (number < 0) - throw new IllegalArgumentException(name + " must be equals or greater zero."); - } - - } - - /** - * Audio widget controller. - */ - public interface Controller { - - /** - * Start playback. - */ - void start(); - - /** - * Pause playback. - */ - void pause(); - - /** - * Stop playback. - */ - void stop(); - - /** - * Get track duration. - * - * @return track duration - */ - int duration(); - - /** - * Set track duration. - * - * @param duration track duration - */ - void duration(int duration); - - /** - * Get track position. - * - * @return track position - */ - int position(); - - /** - * Set track position. - * - * @param position track position - */ - void position(int position); - - /** - * Set controls click listener. - * - * @param onControlsClickListener controls click listener - */ - void onControlsClickListener(@Nullable OnControlsClickListener onControlsClickListener); - - /** - * Set widget state change listener. - * - * @param onWidgetStateChangedListener widget state change listener - */ - void onWidgetStateChangedListener(@Nullable OnWidgetStateChangedListener onWidgetStateChangedListener); - - /** - * Set album cover. - * - * @param albumCover album cover or null to set default one - */ - void albumCover(@Nullable Drawable albumCover); - - /** - * Set album cover. - * - * @param albumCover album cover or null to set default one - */ - void albumCoverBitmap(@Nullable Bitmap albumCover); - } - - /** - * Listener for control clicks. - */ - public interface OnControlsClickListener { - - /** - * Called when playlist button clicked. - * @return true if you consume the action, false to use default behavior (collapse widget) - */ - boolean onPlaylistClicked(); - - /** - * Called when playlist button long clicked. - */ - void onPlaylistLongClicked(); - - /** - * Called when previous track button clicked. - */ - void onPreviousClicked(); - - /** - * Called when previous track button long clicked. - */ - void onPreviousLongClicked(); - - /** - * Called when play/pause button clicked. - * @return true if you consume the action, false to use default behavior (change play/pause state) - */ - boolean onPlayPauseClicked(); - - /** - * Called when play/pause button long clicked. - */ - void onPlayPauseLongClicked(); - - /** - * Called when next track button clicked. - */ - void onNextClicked(); - - /** - * Called when next track button long clicked. - */ - void onNextLongClicked(); - - /** - * Called when album icon clicked. - */ - void onAlbumClicked(); - - /** - * Called when album icon long clicked. - */ - void onAlbumLongClicked(); - } - - /** - * Listener for widget state changes. - */ - public interface OnWidgetStateChangedListener { - - /** - * Called when widget state changed. - * - * @param state new widget state - */ - void onWidgetStateChanged(@NonNull State state); - - /** - * Called when position of widget is changed. - * - * @param cx center x - * @param cy center y - */ - void onWidgetPositionChanged(int cx, int cy); - } - - /** - * Widget state. - */ - public enum State { - COLLAPSED, - EXPANDED, - REMOVED - } -} diff --git a/audiowidget/src/main/java/com/cleveroad/audiowidget/AudioWidget.kt b/audiowidget/src/main/java/com/cleveroad/audiowidget/AudioWidget.kt new file mode 100644 index 0000000..44d76ef --- /dev/null +++ b/audiowidget/src/main/java/com/cleveroad/audiowidget/AudioWidget.kt @@ -0,0 +1,1380 @@ +package com.cleveroad.audiowidget + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator +import android.animation.ValueAnimator.AnimatorUpdateListener +import android.annotation.TargetApi +import android.content.Context +import android.graphics.Bitmap +import android.graphics.PixelFormat +import android.graphics.Point +import android.graphics.RectF +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.os.Build +import android.os.Handler +import android.os.Vibrator +import android.view.* +import android.view.animation.AccelerateDecelerateInterpolator +import androidx.annotation.ColorInt +import androidx.core.content.ContextCompat +import com.cleveroad.audiowidget.DrawableUtils.rotateX +import com.cleveroad.audiowidget.DrawableUtils.rotateY +import com.cleveroad.audiowidget.ExpandCollapseWidget.AnimationProgressListener +import com.cleveroad.audiowidget.TouchManager.BoundsChecker +import java.lang.ref.WeakReference +import java.util.* +import kotlin.math.atan +import kotlin.math.pow +import kotlin.math.sqrt + +/** + * Audio widget implementation. + */ +class AudioWidget private constructor(builder: Builder) { + private val albumCoverCache: MutableMap> = WeakHashMap() + private val context: Context = builder.context.applicationContext + private val controller: Controller = newController() + private val expToPpbBoundsChecker: BoundsChecker + private val expandCollapseWidget: ExpandCollapseWidget + private val expandedWidgetManager: TouchManager + private val handler: Handler = Handler() + private val hiddenRemWidPos: Point = Point() + private val onControlsClickListener: OnControlsClickListenerWrapper? + private val playPauseButton: PlayPauseButton + private val playPauseButtonManager: TouchManager + private val ppbToExpBoundsChecker: BoundsChecker + private val removeBounds: RectF = RectF() + private val removeWidgetView: RemoveWidgetView + private val screenSize: Point = Point() + private val vibrator: Vibrator = + builder.context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + private val visibleRemWidPos: Point = Point() + private val windowManager: WindowManager = + builder.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + private var animatedRemBtnYPos = -1 + private var onWidgetStateChangedListener: OnWidgetStateChangedListener? = null + private var playbackState: PlaybackState? = null + private var released = false + private var removeWidgetShown = false + + /** + * Get current visibility state. + * + * @return true if widget shown on screen, false otherwise. + */ + var isShown = false + private set + private var widgetWidth = 0f + private var widgetHeight = 0f + private var radius = 0f + fun collapse() { + expandCollapseWidget.setCollapseListener(object : AnimationProgressListener { + override fun onValueChanged(alpha: Float) { + playPauseButton.alpha = alpha + } + }) + val params = expandCollapseWidget.layoutParams as WindowManager.LayoutParams + val cx = params.x + expandCollapseWidget.width / 2 + if (cx > screenSize.x / 2) { + expandCollapseWidget.expandDirection(ExpandCollapseWidget.DIRECTION_LEFT) + } else { + expandCollapseWidget.expandDirection(ExpandCollapseWidget.DIRECTION_RIGHT) + } + updatePlayPauseButtonPosition() + if (expandCollapseWidget.collapse()) { + playPauseButtonManager.animateToBounds() + expandedWidgetManager.animateToBounds(expToPpbBoundsChecker, null) + } + } + + /** + * Get widget controller. + * + * @return widget controller + */ + fun controller(): Controller { + return controller + } + + fun expand() { + removeWidgetShown = false + playPauseButton.enableProgressChanges(false) + playPauseButton.postDelayed( + { checkSpaceAndShowExpanded() }, + PlayPauseButton.PROGRESS_CHANGES_DURATION + ) + } + + /** + * Hide widget. + */ + fun hide() { + hideInternal(true) + } + + /** + * Show widget at specified position. + * + * @param cx center x + * @param cy center y + */ + fun show(cx: Int, cy: Int) { + if (isShown) { + return + } + isShown = true + val remWidX = screenSize.x / 2f - radius * RemoveWidgetView.SCALE_LARGE + hiddenRemWidPos[remWidX.toInt()] = + (screenSize.y + widgetHeight + navigationBarHeight()).toInt() + visibleRemWidPos[remWidX.toInt()] = + (screenSize.y - radius - if (hasNavigationBar()) 0F else widgetHeight).toInt() + try { + show(removeWidgetView, hiddenRemWidPos.x, hiddenRemWidPos.y) + } catch (e: IllegalArgumentException) { + // widget not removed yet, animation in progress + } + show(playPauseButton, (cx - widgetHeight).toInt(), (cy - widgetHeight).toInt()) + playPauseButtonManager.animateToBounds() + } + + private fun checkSpaceAndShowExpanded() { + val params = playPauseButton.layoutParams as WindowManager.LayoutParams + val x = params.x + val y = params.y + val expandDirection: Int + expandDirection = if (x + widgetHeight > screenSize.x / 2) { + ExpandCollapseWidget.DIRECTION_LEFT + } else { + ExpandCollapseWidget.DIRECTION_RIGHT + } + playPauseButtonManager.animateToBounds(ppbToExpBoundsChecker) { + val params1 = playPauseButton.layoutParams as WindowManager.LayoutParams + var x1 = params1.x + val y1 = params1.y + if (expandDirection == ExpandCollapseWidget.DIRECTION_LEFT) { + x1 -= (widgetWidth - widgetHeight * 1.5f).toInt() + } else { + x1 += (widgetHeight / 2f.toInt()).toInt() + } + show(expandCollapseWidget, x1, y1) + playPauseButton.setLayerType(View.LAYER_TYPE_NONE, null) + expandCollapseWidget.expandListener = object : AnimationProgressListener { + override fun onValueChanged(percent: Float) { + playPauseButton.alpha = 1f - percent + } + } + expandCollapseWidget.expand(expandDirection) + } + } + + /** + * Check if device has navigation bar. + * + * @return true if device has navigation bar, false otherwise. + */ + private fun hasNavigationBar(): Boolean { + val hasBackKey = KeyCharacterMap.deviceHasKey(KeyEvent.KEYCODE_BACK) + val hasHomeKey = KeyCharacterMap.deviceHasKey(KeyEvent.KEYCODE_HOME) + val id = context.resources.getIdentifier("config_showNavigationBar", "bool", "android") + return !hasBackKey && !hasHomeKey || id > 0 && context.resources.getBoolean(id) + } + + private fun hideInternal(byPublic: Boolean) { + if (!isShown) { + return + } + isShown = false + released = true + try { + windowManager.removeView(playPauseButton) + } catch (e: IllegalArgumentException) { + // view not attached to window + } + if (byPublic) { + try { + windowManager.removeView(removeWidgetView) + } catch (e: IllegalArgumentException) { + // view not attached to window + } + } + try { + windowManager.removeView(expandCollapseWidget) + } catch (e: IllegalArgumentException) { + // widget not added to window yet + } + if (onWidgetStateChangedListener != null) { + onWidgetStateChangedListener!!.onWidgetStateChanged(State.REMOVED) + } + } + + /** + * Get navigation bar height. + * + * @return navigation bar height + */ + private fun navigationBarHeight(): Int { + if (hasNavigationBar()) { + val resourceId = + context.resources.getIdentifier("navigation_bar_height", "dimen", "android") + return if (resourceId > 0) { + context.resources.getDimensionPixelSize(resourceId) + } else context.resources.getDimensionPixelSize(R.dimen.aw_navigation_bar_height) + } + return 0 + } + + /** + * Create new controller. + * + * @return new controller + */ + private fun newController(): Controller { + return object : Controller { + override fun start() { + playbackState!!.start(this) + } + + override fun pause() { + playbackState!!.pause(this) + } + + override fun stop() { + playbackState!!.stop(this) + } + + override fun duration(): Int { + return playbackState!!.duration() + } + + override fun duration(duration: Int) { + playbackState!!.duration(duration) + } + + override fun position(): Int { + return playbackState!!.position() + } + + override fun position(position: Int) { + playbackState!!.position(position) + } + + override fun onControlsClickListener(onControlsClickListener: OnControlsClickListener?) { + this@AudioWidget.onControlsClickListener!!.onControlsClickListener( + onControlsClickListener + ) + } + + override fun onWidgetStateChangedListener( + onWidgetStateChangedListener: OnWidgetStateChangedListener? + ) { + this@AudioWidget.onWidgetStateChangedListener = onWidgetStateChangedListener + } + + override fun albumCover(albumCover: Drawable?) { + expandCollapseWidget.albumCover(albumCover) + playPauseButton.albumCover(albumCover) + } + + override fun albumCoverBitmap(bitmap: Bitmap?) { + if (bitmap == null) { + expandCollapseWidget.albumCover(null) + playPauseButton.albumCover(null) + } else { + val wrDrawable = albumCoverCache[bitmap.hashCode()] + if (wrDrawable != null) { + val drawable = wrDrawable.get() + if (drawable != null) { + expandCollapseWidget.albumCover(drawable) + playPauseButton.albumCover(drawable) + return + } + } + val albumCover: Drawable = BitmapDrawable(context.resources, bitmap) + expandCollapseWidget.albumCover(albumCover) + playPauseButton.albumCover(albumCover) + albumCoverCache[bitmap.hashCode()] = WeakReference(albumCover) + } + } + } + } + + @TargetApi(Build.VERSION_CODES.O) + private fun oreoShow(view: View, left: Int, top: Int) { + val params = WindowManager.LayoutParams( + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL + or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH + or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, + PixelFormat.TRANSLUCENT + ) + params.gravity = Gravity.START or Gravity.TOP + params.x = left + params.y = top + windowManager.addView(view, params) + } + + private fun preOreoShow(view: View, left: Int, top: Int) { + val params = WindowManager.LayoutParams( + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.TYPE_PHONE, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL + or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH + or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, + PixelFormat.TRANSLUCENT + ) + params.gravity = Gravity.START or Gravity.TOP + params.x = left + params.y = top + windowManager.addView(view, params) + } + + /** + * Prepare configuration for widget. + * + * @param builder user defined settings + * @return new configuration for widget + */ + private fun prepareConfiguration(builder: Builder): Configuration { + val darkColor = if (builder.darkColorSet) builder.darkColor else ContextCompat.getColor( + context, + R.color.aw_dark + ) + val lightColor = if (builder.lightColorSet) builder.lightColor else ContextCompat.getColor( + context, + R.color.aw_light + ) + val progressColor = + if (builder.progressColorSet) builder.progressColor else ContextCompat.getColor( + context, + R.color.aw_progress + ) + val expandColor = + if (builder.expandWidgetColorSet) builder.expandWidgetColor else ContextCompat.getColor( + context, + R.color.aw_expanded + ) + val crossColor = if (builder.crossColorSet) builder.crossColor else ContextCompat.getColor( + context, + R.color.aw_cross_default + ) + val crossOverlappedColor = + if (builder.crossOverlappedColorSet) builder.crossOverlappedColor else ContextCompat.getColor( + context, + R.color.aw_cross_overlapped + ) + val shadowColor = + if (builder.shadowColorSet) builder.shadowColor else ContextCompat.getColor( + context, + R.color.aw_shadow + ) + val playDrawable = + if (builder.playDrawable != null) builder.playDrawable else ContextCompat.getDrawable( + context, + R.drawable.aw_ic_play + ) + val pauseDrawable = + if (builder.pauseDrawable != null) builder.pauseDrawable else ContextCompat.getDrawable( + context, + R.drawable.aw_ic_pause + ) + val prevDrawable = + if (builder.prevDrawable != null) builder.prevDrawable else ContextCompat.getDrawable( + context, + R.drawable.aw_ic_prev + ) + val nextDrawable = + if (builder.nextDrawable != null) builder.nextDrawable else ContextCompat.getDrawable( + context, + R.drawable.aw_ic_next + ) + val playlistDrawable = + if (builder.playlistDrawable != null) builder.playlistDrawable else ContextCompat.getDrawable( + context, + R.drawable.aw_ic_playlist + ) + val albumDrawable = + if (builder.defaultAlbumDrawable != null) builder.defaultAlbumDrawable else ContextCompat.getDrawable( + context, + R.drawable.aw_ic_default_album + ) + val buttonPadding = + if (builder.buttonPaddingSet) builder.buttonPadding else context.resources.getDimensionPixelSize( + R.dimen.aw_button_padding + ) + val crossStrokeWidth = + if (builder.crossStrokeWidthSet) builder.crossStrokeWidth else context.resources.getDimension( + R.dimen.aw_cross_stroke_width + ) + val progressStrokeWidth = + if (builder.progressStrokeWidthSet) builder.progressStrokeWidth else context.resources.getDimension( + R.dimen.aw_progress_stroke_width + ) + val shadowRadius = + if (builder.shadowRadiusSet) builder.shadowRadius else context.resources.getDimension(R.dimen.aw_shadow_radius) + val shadowDx = + if (builder.shadowDxSet) builder.shadowDx else context.resources.getDimension(R.dimen.aw_shadow_dx) + val shadowDy = + if (builder.shadowDySet) builder.shadowDy else context.resources.getDimension(R.dimen.aw_shadow_dy) + val bubblesMinSize = + if (builder.bubblesMinSizeSet) builder.bubblesMinSize else context.resources.getDimension( + R.dimen.aw_bubbles_min_size + ) + val bubblesMaxSize = + if (builder.bubblesMaxSizeSet) builder.bubblesMaxSize else context.resources.getDimension( + R.dimen.aw_bubbles_max_size + ) + val prevNextExtraPadding = + context.resources.getDimensionPixelSize(R.dimen.aw_prev_next_button_extra_padding) + widgetHeight = context.resources.getDimensionPixelSize(R.dimen.aw_player_height).toFloat() + widgetWidth = context.resources.getDimensionPixelSize(R.dimen.aw_player_width).toFloat() + radius = widgetHeight / 2f + playbackState = PlaybackState() + return Configuration.Builder() + .context(context) + .playbackState(playbackState!!) + .random(Random()) + .accDecInterpolator(AccelerateDecelerateInterpolator()) + .darkColor(darkColor) + .playColor(lightColor) + .progressColor(progressColor) + .expandedColor(expandColor) + .widgetWidth(widgetWidth) + .radius(radius) + .playlistDrawable(playlistDrawable!!) + .playDrawable(playDrawable!!) + .prevDrawable(prevDrawable!!) + .nextDrawable(nextDrawable!!) + .pauseDrawable(pauseDrawable!!) + .albumDrawable(albumDrawable!!) + .buttonPadding(buttonPadding) + .prevNextExtraPadding(prevNextExtraPadding) + .crossStrokeWidth(crossStrokeWidth) + .progressStrokeWidth(progressStrokeWidth) + .shadowRadius(shadowRadius) + .shadowDx(shadowDx) + .shadowDy(shadowDy) + .shadowColor(shadowColor) + .bubblesMinSize(bubblesMinSize) + .bubblesMaxSize(bubblesMaxSize) + .crossColor(crossColor) + .crossOverlappedColor(crossOverlappedColor) + .build() + } + + private fun show(view: View, left: Int, top: Int) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + preOreoShow(view, left, top) + } else { + oreoShow(view, left, top) + } + } + + /** + * Get status bar height. + * + * @return status bar height. + */ + private fun statusBarHeight(): Int { + val resourceId = context.resources.getIdentifier("status_bar_height", "dimen", "android") + return if (resourceId > 0) { + context.resources.getDimensionPixelSize(resourceId) + } else context.resources.getDimensionPixelSize(R.dimen.aw_status_bar_height) + } + + private fun updatePlayPauseButtonPosition() { + val widgetParams = expandCollapseWidget.layoutParams as WindowManager.LayoutParams + val params = playPauseButton.layoutParams as WindowManager.LayoutParams + if (expandCollapseWidget.expandDirection() == ExpandCollapseWidget.DIRECTION_RIGHT) { + params.x = (widgetParams.x - radius).toInt() + } else { + params.x = (widgetParams.x + widgetWidth - widgetHeight - radius).toInt() + } + params.y = widgetParams.y + try { + windowManager.updateViewLayout(playPauseButton, params) + } catch (e: IllegalArgumentException) { + // view not attached to window + } + onWidgetStateChangedListener?.onWidgetPositionChanged( + (params.x + widgetHeight).toInt(), + (params.y + widgetHeight).toInt() + ) + } + + /** + * Widget state. + */ + enum class State { + COLLAPSED, EXPANDED, REMOVED + } + + /** + * Audio widget controller. + */ + interface Controller { + /** + * Set album cover. + * + * @param albumCover album cover or null to set default one + */ + fun albumCover(albumCover: Drawable?) + + /** + * Set album cover. + * + * @param albumCover album cover or null to set default one + */ + fun albumCoverBitmap(albumCover: Bitmap?) + + /** + * Get track duration. + * + * @return track duration + */ + fun duration(): Int + + /** + * Set track duration. + * + * @param duration track duration + */ + fun duration(duration: Int) + + /** + * Set controls click listener. + * + * @param onControlsClickListener controls click listener + */ + fun onControlsClickListener(onControlsClickListener: OnControlsClickListener?) + + /** + * Set widget state change listener. + * + * @param onWidgetStateChangedListener widget state change listener + */ + fun onWidgetStateChangedListener(onWidgetStateChangedListener: OnWidgetStateChangedListener?) + + /** + * Pause playback. + */ + fun pause() + + /** + * Get track position. + * + * @return track position + */ + fun position(): Int + + /** + * Set track position. + * + * @param position track position + */ + fun position(position: Int) + + /** + * Start playback. + */ + fun start() + + /** + * Stop playback. + */ + fun stop() + } + + /** + * Listener for control clicks. + */ + interface OnControlsClickListener { + /** + * Called when album icon clicked. + */ + fun onAlbumClicked() + + /** + * Called when album icon long clicked. + */ + fun onAlbumLongClicked() + + /** + * Called when next track button clicked. + */ + fun onNextClicked() + + /** + * Called when next track button long clicked. + */ + fun onNextLongClicked() + + /** + * Called when play/pause button clicked. + * + * @return true if you consume the action, false to use default behavior (change play/pause state) + */ + fun onPlayPauseClicked(): Boolean + + /** + * Called when play/pause button long clicked. + */ + fun onPlayPauseLongClicked() + + /** + * Called when playlist button clicked. + * + * @return true if you consume the action, false to use default behavior (collapse widget) + */ + fun onPlaylistClicked(): Boolean + + /** + * Called when playlist button long clicked. + */ + fun onPlaylistLongClicked() + + /** + * Called when previous track button clicked. + */ + fun onPreviousClicked() + + /** + * Called when previous track button long clicked. + */ + fun onPreviousLongClicked() + } + + /** + * Listener for widget state changes. + */ + interface OnWidgetStateChangedListener { + /** + * Called when position of widget is changed. + * + * @param cx center x + * @param cy center y + */ + fun onWidgetPositionChanged(cx: Int, cy: Int) + + /** + * Called when widget state changed. + * + * @param state new widget state + */ + fun onWidgetStateChanged(state: State) + } + + internal abstract class BoundsCheckerWithOffset( + private val offsetX: Int, + private val offsetY: Int + ) : BoundsChecker { + override fun stickyLeftSide(screenWidth: Float): Float { + return stickyLeftSideImpl(screenWidth) + offsetX + } + + override fun stickyRightSide(screenWidth: Float): Float { + return stickyRightSideImpl(screenWidth) - offsetX + } + + override fun stickyTopSide(screenHeight: Float): Float { + return stickyTopSideImpl(screenHeight) + offsetY + } + + override fun stickyBottomSide(screenHeight: Float): Float { + return stickyBottomSideImpl(screenHeight) - offsetY + } + + protected abstract fun stickyBottomSideImpl(screenHeight: Float): Float + protected abstract fun stickyLeftSideImpl(screenWidth: Float): Float + protected abstract fun stickyRightSideImpl(screenWidth: Float): Float + protected abstract fun stickyTopSideImpl(screenHeight: Float): Float + } + + /** + * Builder class for [AudioWidget]. + */ + class Builder(val context: Context) { + var bubblesMaxSize = 0f + var bubblesMaxSizeSet = false + var bubblesMinSize = 0f + var bubblesMinSizeSet = false + var buttonPadding = 0 + var buttonPaddingSet = false + + @ColorInt + var crossColor = 0 + var crossColorSet = false + + @ColorInt + var crossOverlappedColor = 0 + var crossOverlappedColorSet = false + var crossStrokeWidth = 0f + var crossStrokeWidthSet = false + + @ColorInt + var darkColor = 0 + var darkColorSet = false + var defaultAlbumDrawable: Drawable? = null + var edgeOffsetXCollapsed = 0 + var edgeOffsetXCollapsedSet = false + var edgeOffsetXExpanded = 0 + var edgeOffsetXExpandedSet = false + var edgeOffsetYCollapsed = 0 + var edgeOffsetYCollapsedSet = false + var edgeOffsetYExpanded = 0 + var edgeOffsetYExpandedSet = false + + @ColorInt + var expandWidgetColor = 0 + var expandWidgetColorSet = false + + @ColorInt + var lightColor = 0 + var lightColorSet = false + var nextDrawable: Drawable? = null + var pauseDrawable: Drawable? = null + var playDrawable: Drawable? = null + var playlistDrawable: Drawable? = null + var prevDrawable: Drawable? = null + + @ColorInt + var progressColor = 0 + var progressColorSet = false + var progressStrokeWidth = 0f + var progressStrokeWidthSet = false + + @ColorInt + var shadowColor = 0 + var shadowColorSet = false + var shadowDx = 0f + var shadowDxSet = false + var shadowDy = 0f + var shadowDySet = false + var shadowRadius = 0f + var shadowRadiusSet = false + + /** + * Set bubbles maximum size in pixels. Default value: 10dp. + * + * @param bubblesMaxSize bubbles maximum size + */ + fun bubblesMaxSize(bubblesMaxSize: Float): Builder { + this.bubblesMaxSize = bubblesMaxSize + bubblesMaxSizeSet = true + return this + } + + /** + * Set bubbles minimum size in pixels. Default value: 5dp. + * + * @param bubblesMinSize bubbles minimum size + */ + fun bubblesMinSize(bubblesMinSize: Float): Builder { + this.bubblesMinSize = bubblesMinSize + bubblesMinSizeSet = true + return this + } + + /** + * Create new audio widget. + * + * @return new audio widget + * @throws IllegalStateException if size parameters have wrong values (less than zero). + */ + fun build(): AudioWidget { + if (buttonPaddingSet) { + checkOrThrow(buttonPadding, "Button padding") + } + if (shadowRadiusSet) { + checkOrThrow(shadowRadius, "Shadow radius") + } + if (shadowDxSet) { + checkOrThrow(shadowDx, "Shadow dx") + } + if (shadowDySet) { + checkOrThrow(shadowDy, "Shadow dy") + } + if (bubblesMinSizeSet) { + checkOrThrow(bubblesMinSize, "Bubbles min size") + } + if (bubblesMaxSizeSet) { + checkOrThrow(bubblesMaxSize, "Bubbles max size") + } + require(!(bubblesMinSizeSet && bubblesMaxSizeSet && bubblesMaxSize < bubblesMinSize)) { + "Bubbles max size must be greater than bubbles min size" + } + if (crossStrokeWidthSet) { + checkOrThrow(crossStrokeWidth, "Cross stroke width") + } + if (progressStrokeWidthSet) { + checkOrThrow(progressStrokeWidth, "Progress stroke width") + } + return AudioWidget(this) + } + + /** + * Set button padding in pixels. Default value: 10dp. + * + * @param buttonPadding button padding + */ + fun buttonPadding(buttonPadding: Int): Builder { + this.buttonPadding = buttonPadding + buttonPaddingSet = true + return this + } + + /** + * Set remove widget cross color. + * + * @param crossColor cross color + */ + fun crossColor(@ColorInt crossColor: Int): Builder { + this.crossColor = crossColor + crossColorSet = true + return this + } + + /** + * Set remove widget cross color in overlapped state (audio widget overlapped remove widget). + * + * @param crossOverlappedColor cross color in overlapped state + */ + fun crossOverlappedColor(@ColorInt crossOverlappedColor: Int): Builder { + this.crossOverlappedColor = crossOverlappedColor + crossOverlappedColorSet = true + return this + } + + /** + * Set stroke width of remove widget. Default value: 4dp. + * + * @param crossStrokeWidth stroke width of remove widget + */ + fun crossStrokeWidth(crossStrokeWidth: Float): Builder { + this.crossStrokeWidth = crossStrokeWidth + crossStrokeWidthSet = true + return this + } + + /** + * Set dark color (playing state). + * + * @param darkColor dark color + */ + fun darkColor(@ColorInt darkColor: Int): Builder { + this.darkColor = darkColor + darkColorSet = true + return this + } + + /** + * Set drawable for default album icon. + * + * @param defaultAlbumCover drawable for default album icon + */ + fun defaultAlbumDrawable(defaultAlbumCover: Drawable): Builder { + defaultAlbumDrawable = defaultAlbumCover + return this + } + + /** + * Set widget edge offset on X axis + * + * @param edgeOffsetX widget edge offset on X axis + */ + fun edgeOffsetXCollapsed(edgeOffsetX: Int): Builder { + edgeOffsetXCollapsed = edgeOffsetX + edgeOffsetXCollapsedSet = true + return this + } + + fun edgeOffsetXExpanded(edgeOffsetX: Int): Builder { + edgeOffsetXExpanded = edgeOffsetX + edgeOffsetXExpandedSet = true + return this + } + + /** + * Set widget edge offset on Y axis + * + * @param edgeOffsetY widget edge offset on Y axis + */ + fun edgeOffsetYCollapsed(edgeOffsetY: Int): Builder { + edgeOffsetYCollapsed = edgeOffsetY + edgeOffsetYCollapsedSet = true + return this + } + + fun edgeOffsetYExpanded(edgeOffsetY: Int): Builder { + edgeOffsetYExpanded = edgeOffsetY + edgeOffsetYExpandedSet = true + return this + } + + /** + * Set widget color in expanded state. + * + * @param expandWidgetColor widget color in expanded state + */ + fun expandWidgetColor(@ColorInt expandWidgetColor: Int): Builder { + this.expandWidgetColor = expandWidgetColor + expandWidgetColorSet = true + return this + } + + /** + * Set light color (paused state). + * + * @param lightColor light color + */ + fun lightColor(@ColorInt lightColor: Int): Builder { + this.lightColor = lightColor + lightColorSet = true + return this + } + + /** + * Set drawable for next track button. + * + * @param nextDrawable drawable for next track button. + */ + fun nextTrackDrawable(nextDrawable: Drawable): Builder { + this.nextDrawable = nextDrawable + return this + } + + /** + * Set drawable for pause button. + * + * @param pauseDrawable drawable for pause button + */ + fun pauseDrawable(pauseDrawable: Drawable): Builder { + this.pauseDrawable = pauseDrawable + return this + } + + /** + * Set drawable for play button. + * + * @param playDrawable drawable for play button + */ + fun playDrawable(playDrawable: Drawable): Builder { + this.playDrawable = playDrawable + return this + } + + /** + * Set drawable for playlist button. + * + * @param playlistDrawable drawable for playlist button + */ + fun playlistDrawable(playlistDrawable: Drawable): Builder { + this.playlistDrawable = playlistDrawable + return this + } + + /** + * Set drawable for previous track button. + * + * @param prevDrawable drawable for previous track button + */ + fun prevTrackDrawale(prevDrawable: Drawable): Builder { + this.prevDrawable = prevDrawable + return this + } + + /** + * Set progress bar color. + * + * @param progressColor progress bar color + */ + fun progressColor(@ColorInt progressColor: Int): Builder { + this.progressColor = progressColor + progressColorSet = true + return this + } + + /** + * Set stroke width of progress bar. Default value: 4dp. + * + * @param progressStrokeWidth stroke width of progress bar + */ + fun progressStrokeWidth(progressStrokeWidth: Float): Builder { + this.progressStrokeWidth = progressStrokeWidth + progressStrokeWidthSet = true + return this + } + + /** + * Set shadow color. + * + * @param shadowColor shadow color + */ + fun shadowColor(@ColorInt shadowColor: Int): Builder { + this.shadowColor = shadowColor + shadowColorSet = true + return this + } + + /** + * Set shadow dx. Default value: 1dp. + * + * @param shadowDx shadow dx + * @see Paint.setShadowLayer + */ + fun shadowDx(shadowDx: Float): Builder { + this.shadowDx = shadowDx + shadowDxSet = true + return this + } + + /** + * Set shadow dx. Default value: 1dp. + * + * @param shadowDy shadow dy + * @see Paint.setShadowLayer + */ + fun shadowDy(shadowDy: Float): Builder { + this.shadowDy = shadowDy + shadowDySet = true + return this + } + + /** + * Set shadow radius. Default value: 5dp. + * + * @param shadowRadius shadow radius. + * @see Paint.setShadowLayer + */ + fun shadowRadius(shadowRadius: Float): Builder { + this.shadowRadius = shadowRadius + shadowRadiusSet = true + return this + } + + private fun checkOrThrow(number: Int, name: String) { + require(number >= 0) { "$name must be equals or greater zero." } + } + + private fun checkOrThrow(number: Float, name: String) { + require(number >= 0) { "$name must be equals or greater zero." } + } + } + + /** + * Helper class for dealing with expanded widget touch events. + */ + private inner class ExpandCollapseWidgetCallback : TouchManager.Callback { + override fun onClick(x: Float, y: Float) { + expandCollapseWidget.onClick(x, y) + } + + override fun onLongClick(x: Float, y: Float) { + expandCollapseWidget.onLongClick(x, y) + } + + override fun onTouchOutside() { + if (!expandCollapseWidget.isAnimationInProgress) { + collapse() + } + } + + override fun onTouched(x: Float, y: Float) { + expandCollapseWidget.onTouched(x, y) + } + + override fun onMoved(diffX: Float, diffY: Float) { + updatePlayPauseButtonPosition() + } + + override fun onReleased(x: Float, y: Float) { + expandCollapseWidget.onReleased(x, y) + } + + override fun onAnimationCompleted() { + updatePlayPauseButtonPosition() + } + } + + private inner class OnControlsClickListenerWrapper : OnControlsClickListener { + private var onControlsClickListener: OnControlsClickListener? = null + + fun onControlsClickListener(inner: OnControlsClickListener?): OnControlsClickListenerWrapper { + this.onControlsClickListener = inner + return this + } + + override fun onPlaylistClicked(): Boolean { + if (onControlsClickListener?.let { !it.onPlaylistClicked() } == true) { + collapse() + return true + } + return false + } + + override fun onPlaylistLongClicked() { + onControlsClickListener?.onPlaylistLongClicked() + } + + override fun onPreviousClicked() { + onControlsClickListener?.onPreviousClicked() + } + + override fun onPreviousLongClicked() { + onControlsClickListener?.onPreviousLongClicked() + } + + override fun onPlayPauseClicked(): Boolean { + if (onControlsClickListener == null || !onControlsClickListener!!.onPlayPauseClicked()) { + val pbState = playbackState!! + if (pbState.state() != Configuration.STATE_PLAYING) { + pbState.start(this@AudioWidget) + } else { + pbState.pause(this@AudioWidget) + } + return true + } + return false + } + + override fun onPlayPauseLongClicked() { + onControlsClickListener?.onPlayPauseLongClicked() + } + + override fun onNextClicked() { + onControlsClickListener?.onNextClicked() + } + + override fun onNextLongClicked() { + onControlsClickListener?.onNextLongClicked() + } + + override fun onAlbumClicked() { + onControlsClickListener?.onAlbumClicked() + } + + override fun onAlbumLongClicked() { + onControlsClickListener?.onAlbumLongClicked() + } + } + + /** + * Helper class for dealing with collapsed widget touch events. + */ + private inner class PlayPauseButtonCallback : TouchManager.Callback { + private val REMOVE_BTN_ANIM_DURATION: Long = 200 + private val animatorUpdateListener: AnimatorUpdateListener + private var readyToRemove = false + override fun onClick(x: Float, y: Float) { + playPauseButton.onClick() + onControlsClickListener?.onPlayPauseClicked() + } + + override fun onLongClick(x: Float, y: Float) { + released = true + expand() + } + + override fun onTouched(x: Float, y: Float) { + released = false + handler.postDelayed({ + if (!released) { + removeWidgetShown = true + val animator = ValueAnimator.ofFloat( + hiddenRemWidPos.y.toFloat(), + visibleRemWidPos.y.toFloat() + ) + animator.duration = REMOVE_BTN_ANIM_DURATION + animator.addUpdateListener(animatorUpdateListener) + animator.start() + } + }, Configuration.LONG_CLICK_THRESHOLD) + playPauseButton.onTouchDown() + } + + override fun onMoved(diffX: Float, diffY: Float) { + val curReadyToRemove = isReadyToRemove() + if (curReadyToRemove != readyToRemove) { + readyToRemove = curReadyToRemove + removeWidgetView.setOverlapped(readyToRemove) + if (readyToRemove && vibrator.hasVibrator()) { + vibrator.vibrate(VIBRATION_DURATION) + } + } + updateRemoveBtnPosition() + } + + override fun onReleased(x: Float, y: Float) { + playPauseButton.onTouchUp() + released = true + if (removeWidgetShown) { + val animator = + ValueAnimator.ofFloat(visibleRemWidPos.y.toFloat(), hiddenRemWidPos.y.toFloat()) + animator.duration = REMOVE_BTN_ANIM_DURATION + animator.addUpdateListener(animatorUpdateListener) + animator.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + removeWidgetShown = false + if (!isShown) { + try { + windowManager.removeView(removeWidgetView) + } catch (e: IllegalArgumentException) { + // view not attached to window + } + } + } + }) + animator.start() + } + if (isReadyToRemove()) { + hideInternal(false) + } else { + if (onWidgetStateChangedListener != null) { + val params = playPauseButton.layoutParams as WindowManager.LayoutParams + onWidgetStateChangedListener!!.onWidgetPositionChanged( + (params.x + widgetHeight).toInt(), + (params.y + widgetHeight).toInt() + ) + } + } + } + + override fun onAnimationCompleted() { + onWidgetStateChangedListener?.let { + val params = playPauseButton.layoutParams as WindowManager.LayoutParams + it.onWidgetPositionChanged( + (params.x + widgetHeight).toInt(), + (params.y + widgetHeight).toInt() + ) + } + } + + private fun isReadyToRemove(): Boolean { + val removeParams = removeWidgetView.layoutParams as WindowManager.LayoutParams + removeBounds[removeParams.x.toFloat(), removeParams.y.toFloat(), removeParams.x + widgetHeight] = + removeParams.y + widgetHeight + val params = playPauseButton.layoutParams as WindowManager.LayoutParams + val cx = params.x + widgetHeight + val cy = params.y + widgetHeight + return removeBounds.contains(cx, cy) + } + + private fun updateRemoveBtnPosition() { + if (removeWidgetShown) { + val playPauseBtnParams = playPauseButton.layoutParams as WindowManager.LayoutParams + val removeBtnParams = removeWidgetView.layoutParams as WindowManager.LayoutParams + val tgAlpha = + (screenSize.x / 2.0 - playPauseBtnParams.x) / (visibleRemWidPos.y - playPauseBtnParams.y) + val rotationDegrees = 360 - Math.toDegrees(atan(tgAlpha)) + var distance = sqrt( + (animatedRemBtnYPos - playPauseBtnParams.y.toDouble()).pow(2.0) + + (visibleRemWidPos.x - hiddenRemWidPos.x.toDouble()).pow(2.0) + ).toFloat() + val maxDistance = sqrt( + screenSize.x.toDouble().pow(2.0) + screenSize.y.toDouble().pow(2.0) + ).toFloat() + distance /= maxDistance + if (animatedRemBtnYPos == -1) { + animatedRemBtnYPos = visibleRemWidPos.y + } + removeBtnParams.x = rotateX( + visibleRemWidPos.x.toFloat(), + animatedRemBtnYPos - radius * distance, + hiddenRemWidPos.x.toFloat(), + animatedRemBtnYPos.toFloat(), + rotationDegrees.toFloat() + ).toInt() + removeBtnParams.y = rotateY( + visibleRemWidPos.x.toFloat(), + animatedRemBtnYPos - radius * distance, + hiddenRemWidPos.x.toFloat(), + animatedRemBtnYPos.toFloat(), + rotationDegrees.toFloat() + ).toInt() + try { + windowManager.updateViewLayout(removeWidgetView, removeBtnParams) + } catch (e: IllegalArgumentException) { + // view not attached to window + } + } + } + + + init { + animatorUpdateListener = AnimatorUpdateListener { animation: ValueAnimator -> + if (!removeWidgetShown) { + return@AnimatorUpdateListener + } + animatedRemBtnYPos = (animation.animatedValue as Float).toInt() + updateRemoveBtnPosition() + } + } + } + + companion object { + private const val VIBRATION_DURATION: Long = 100 + } + + init { + windowManager.defaultDisplay.getSize(screenSize) + screenSize.y -= statusBarHeight() + navigationBarHeight() + val configuration = prepareConfiguration(builder) + playPauseButton = PlayPauseButton(configuration) + expandCollapseWidget = ExpandCollapseWidget(configuration) + removeWidgetView = RemoveWidgetView(configuration) + val offsetCollapsed = + context.resources.getDimensionPixelOffset(R.dimen.aw_edge_offset_collapsed) + val offsetExpanded = + context.resources.getDimensionPixelOffset(R.dimen.aw_edge_offset_expanded) + playPauseButtonManager = TouchManager( + playPauseButton, playPauseButton.newBoundsChecker( + if (builder.edgeOffsetXCollapsedSet) builder.edgeOffsetXCollapsed else offsetCollapsed, + if (builder.edgeOffsetYCollapsedSet) builder.edgeOffsetYCollapsed else offsetCollapsed + ) + ) + .screenWidth(screenSize.x) + .screenHeight(screenSize.y) + expandedWidgetManager = TouchManager( + expandCollapseWidget, expandCollapseWidget.newBoundsChecker( + if (builder.edgeOffsetXExpandedSet) builder.edgeOffsetXExpanded else offsetExpanded, + if (builder.edgeOffsetYExpandedSet) builder.edgeOffsetYExpanded else offsetExpanded + ) + ) + .screenWidth(screenSize.x) + .screenHeight(screenSize.y) + playPauseButtonManager.callback(PlayPauseButtonCallback()) + expandedWidgetManager.callback(ExpandCollapseWidgetCallback()) + expandCollapseWidget.onWidgetStateChangedListener(object : OnWidgetStateChangedListener { + override fun onWidgetStateChanged(state: State) { + if (state == State.COLLAPSED) { + playPauseButton.setLayerType(View.LAYER_TYPE_SOFTWARE, null) + try { + windowManager.removeView(expandCollapseWidget) + } catch (e: IllegalArgumentException) { + // view not attached to window + } + playPauseButton.enableProgressChanges(true) + } + onWidgetStateChangedListener?.onWidgetStateChanged(state) + } + + override fun onWidgetPositionChanged(cx: Int, cy: Int) {} + }) + onControlsClickListener = OnControlsClickListenerWrapper() + expandCollapseWidget.onControlsClickListener(onControlsClickListener) + ppbToExpBoundsChecker = playPauseButton.newBoundsChecker( + if (builder.edgeOffsetXExpandedSet) builder.edgeOffsetXExpanded else offsetExpanded, + if (builder.edgeOffsetYExpandedSet) builder.edgeOffsetYExpanded else offsetExpanded + ) + expToPpbBoundsChecker = expandCollapseWidget.newBoundsChecker( + if (builder.edgeOffsetXCollapsedSet) builder.edgeOffsetXCollapsed else offsetCollapsed, + if (builder.edgeOffsetYCollapsedSet) builder.edgeOffsetYCollapsed else offsetCollapsed + ) + } +} \ No newline at end of file diff --git a/audiowidget/src/main/java/com/cleveroad/audiowidget/ColorChanger.java b/audiowidget/src/main/java/com/cleveroad/audiowidget/ColorChanger.java deleted file mode 100644 index e296493..0000000 --- a/audiowidget/src/main/java/com/cleveroad/audiowidget/ColorChanger.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.cleveroad.audiowidget; - -import android.graphics.Color; - -/** - * Helper class for changing color. - */ -class ColorChanger { - - private final float[] fromColorHsv; - private final float[] toColorHsv; - private final float[] resultColorHsv; - - ColorChanger() { - fromColorHsv = new float[3]; - toColorHsv = new float[3]; - resultColorHsv = new float[3]; - } - - ColorChanger fromColor(int fromColor) { - Color.colorToHSV(fromColor, fromColorHsv); - return this; - } - - ColorChanger toColor(int toColor) { - Color.colorToHSV(toColor, toColorHsv); - return this; - } - - int nextColor(float dt) { - for (int k = 0; k < 3; k++) { - resultColorHsv[k] = fromColorHsv[k] + (toColorHsv[k] - fromColorHsv[k]) * dt; - } - return Color.HSVToColor(resultColorHsv); - } -} diff --git a/audiowidget/src/main/java/com/cleveroad/audiowidget/ColorChanger.kt b/audiowidget/src/main/java/com/cleveroad/audiowidget/ColorChanger.kt new file mode 100644 index 0000000..b95d6cf --- /dev/null +++ b/audiowidget/src/main/java/com/cleveroad/audiowidget/ColorChanger.kt @@ -0,0 +1,30 @@ +package com.cleveroad.audiowidget + +import android.graphics.Color + +/** + * Helper class for changing color. + */ +internal class ColorChanger { + private val fromColorHsv: FloatArray = FloatArray(3) + private val toColorHsv: FloatArray = FloatArray(3) + private val resultColorHsv: FloatArray = FloatArray(3) + + fun fromColor(fromColor: Int): ColorChanger { + Color.colorToHSV(fromColor, fromColorHsv) + return this + } + + fun toColor(toColor: Int): ColorChanger { + Color.colorToHSV(toColor, toColorHsv) + return this + } + + fun nextColor(dt: Float): Int { + for (k in 0..2) { + resultColorHsv[k] = fromColorHsv[k] + (toColorHsv[k] - fromColorHsv[k]) * dt + } + return Color.HSVToColor(resultColorHsv) + } + +} \ No newline at end of file diff --git a/audiowidget/src/main/java/com/cleveroad/audiowidget/Configuration.java b/audiowidget/src/main/java/com/cleveroad/audiowidget/Configuration.java deleted file mode 100644 index 40b319a..0000000 --- a/audiowidget/src/main/java/com/cleveroad/audiowidget/Configuration.java +++ /dev/null @@ -1,377 +0,0 @@ -package com.cleveroad.audiowidget; - -import android.content.Context; -import android.graphics.drawable.Drawable; -import android.view.ViewConfiguration; -import android.view.animation.Interpolator; - -import androidx.annotation.ColorInt; -import androidx.annotation.Nullable; - -import java.util.Random; - -/** - * Audio widget configuration class. - */ -class Configuration { - - static final float FRAME_SPEED = 70.0f; - - static final long LONG_CLICK_THRESHOLD = ViewConfiguration.getLongPressTimeout() + 128; - static final int STATE_STOPPED = 0; - static final int STATE_PLAYING = 1; - static final int STATE_PAUSED = 2; - static final long TOUCH_ANIMATION_DURATION = 100; - - private final int lightColor; - private final int darkColor; - private final int progressColor; - private final int expandedColor; - private final Random random; - private final float width; - private final float height; - private final Drawable playDrawable; - private final Drawable pauseDrawable; - private final Drawable prevDrawable; - private final Drawable nextDrawable; - private final Drawable playlistDrawable; - private final Drawable albumDrawable; - private final Context context; - private final PlaybackState playbackState; - private final int buttonPadding; - private final float crossStrokeWidth; - private final float progressStrokeWidth; - private final float shadowRadius; - private final float shadowDx; - private final float shadowDy; - private final int shadowColor; - private final float bubblesMinSize; - private final float bubblesMaxSize; - private final int crossColor; - private final int crossOverlappedColor; - private final Interpolator accDecInterpolator; - private final int prevNextExtraPadding; - - private Configuration(Builder builder) { - this.context = builder.context; - this.random = builder.random; - this.width = builder.width; - this.height = builder.radius; - this.lightColor = builder.lightColor; - this.darkColor = builder.darkColor; - this.progressColor = builder.progressColor; - this.expandedColor = builder.expandedColor; - this.playlistDrawable = builder.playlistDrawable; - this.playDrawable = builder.playDrawable; - this.pauseDrawable = builder.pauseDrawable; - this.prevDrawable = builder.prevDrawable; - this.nextDrawable = builder.nextDrawable; - this.albumDrawable = builder.albumDrawable; - this.playbackState = builder.playbackState; - this.buttonPadding = builder.buttonPadding; - this.crossStrokeWidth = builder.crossStrokeWidth; - this.progressStrokeWidth = builder.progressStrokeWidth; - this.shadowRadius = builder.shadowRadius; - this.shadowDx = builder.shadowDx; - this.shadowDy = builder.shadowDy; - this.shadowColor = builder.shadowColor; - this.bubblesMinSize = builder.bubblesMinSize; - this.bubblesMaxSize = builder.bubblesMaxSize; - this.crossColor = builder.crossColor; - this.crossOverlappedColor = builder.crossOverlappedColor; - this.accDecInterpolator = builder.accDecInterpolator; - this.prevNextExtraPadding = builder.prevNextExtraPadding; - } - - Context context() { - return context; - } - - Random random() { - return random; - } - - @ColorInt - int lightColor() { - return lightColor; - } - - @ColorInt - int darkColor() { - return darkColor; - } - - @ColorInt - int progressColor() { - return progressColor; - } - - @ColorInt - int expandedColor() { - return expandedColor; - } - - float widgetWidth() { - return width; - } - - float radius() { - return height; - } - - Drawable playDrawable() { - return playDrawable; - } - - Drawable pauseDrawable() { - return pauseDrawable; - } - - Drawable prevDrawable() { - return prevDrawable; - } - - Drawable nextDrawable() { - return nextDrawable; - } - - Drawable playlistDrawable() { - return playlistDrawable; - } - - Drawable albumDrawable() { - return albumDrawable; - } - - PlaybackState playbackState() { - return playbackState; - } - - float crossStrokeWidth() { - return crossStrokeWidth; - } - - float progressStrokeWidth() { - return progressStrokeWidth; - } - - int buttonPadding() { - return buttonPadding; - } - - float shadowRadius() { - return shadowRadius; - } - - float shadowDx() { - return shadowDx; - } - - float shadowDy() { - return shadowDy; - } - - int shadowColor() { - return shadowColor; - } - - float bubblesMinSize() { - return bubblesMinSize; - } - - float bubblesMaxSize() { - return bubblesMaxSize; - } - - int crossColor() { - return crossColor; - } - - int crossOverlappedColor() { - return crossOverlappedColor; - } - - Interpolator accDecInterpolator() { - return accDecInterpolator; - } - - int prevNextExtraPadding() { - return prevNextExtraPadding; - } - - static final class Builder { - - private int lightColor; - private int darkColor; - private int progressColor; - private int expandedColor; - private float width; - private float radius; - private Context context; - private Random random; - private Drawable playDrawable; - private Drawable pauseDrawable; - private Drawable prevDrawable; - private Drawable nextDrawable; - private Drawable playlistDrawable; - private Drawable albumDrawable; - private PlaybackState playbackState; - private int buttonPadding; - private float crossStrokeWidth; - private float progressStrokeWidth; - private float shadowRadius; - private float shadowDx; - private float shadowDy; - private int shadowColor; - private float bubblesMinSize; - private float bubblesMaxSize; - private int crossColor; - private int crossOverlappedColor; - private Interpolator accDecInterpolator; - private int prevNextExtraPadding; - - Builder context(Context context) { - this.context = context; - return this; - } - - Builder playColor(@ColorInt int pauseColor) { - this.lightColor = pauseColor; - return this; - } - - Builder darkColor(@ColorInt int playColor) { - this.darkColor = playColor; - return this; - } - - Builder progressColor(@ColorInt int progressColor) { - this.progressColor = progressColor; - return this; - } - - Builder expandedColor(@ColorInt int expandedColor) { - this.expandedColor = expandedColor; - return this; - } - - Builder random(Random random) { - this.random = random; - return this; - } - - Builder widgetWidth(float width) { - this.width = width; - return this; - } - - Builder radius(float radius) { - this.radius = radius; - return this; - } - - Builder playDrawable(@Nullable Drawable playDrawable) { - this.playDrawable = playDrawable; - return this; - } - - Builder pauseDrawable(@Nullable Drawable pauseDrawable) { - this.pauseDrawable = pauseDrawable; - return this; - } - - Builder prevDrawable(@Nullable Drawable prevDrawable) { - this.prevDrawable = prevDrawable; - return this; - } - - Builder nextDrawable(@Nullable Drawable nextDrawable) { - this.nextDrawable = nextDrawable; - return this; - } - - Builder playlistDrawable(@Nullable Drawable plateDrawable) { - this.playlistDrawable = plateDrawable; - return this; - } - - Builder albumDrawable(@Nullable Drawable albumDrawable) { - this.albumDrawable = albumDrawable; - return this; - } - - Builder playbackState(PlaybackState playbackState) { - this.playbackState = playbackState; - return this; - } - - Builder buttonPadding(int buttonPadding) { - this.buttonPadding = buttonPadding; - return this; - } - - Builder crossStrokeWidth(float crossStrokeWidth) { - this.crossStrokeWidth = crossStrokeWidth; - return this; - } - - Builder progressStrokeWidth(float progressStrokeWidth) { - this.progressStrokeWidth = progressStrokeWidth; - return this; - } - - Builder shadowRadius(float shadowRadius) { - this.shadowRadius = shadowRadius; - return this; - } - - Builder shadowDx(float shadowDx) { - this.shadowDx = shadowDx; - return this; - } - - Builder shadowDy(float shadowDy) { - this.shadowDy = shadowDy; - return this; - } - - Builder shadowColor(@ColorInt int shadowColor) { - this.shadowColor = shadowColor; - return this; - } - - Builder bubblesMinSize(float bubblesMinSize) { - this.bubblesMinSize = bubblesMinSize; - return this; - } - - Builder bubblesMaxSize(float bubblesMaxSize) { - this.bubblesMaxSize = bubblesMaxSize; - return this; - } - - Builder crossColor(@ColorInt int crossColor) { - this.crossColor = crossColor; - return this; - } - - Builder crossOverlappedColor(@ColorInt int crossOverlappedColor) { - this.crossOverlappedColor = crossOverlappedColor; - return this; - } - - Builder accDecInterpolator(Interpolator accDecInterpolator) { - this.accDecInterpolator = accDecInterpolator; - return this; - } - - Builder prevNextExtraPadding(int prevNextExtraPadding) { - this.prevNextExtraPadding = prevNextExtraPadding; - return this; - } - - Configuration build() { - return new Configuration(this); - } - } -} diff --git a/audiowidget/src/main/java/com/cleveroad/audiowidget/Configuration.kt b/audiowidget/src/main/java/com/cleveroad/audiowidget/Configuration.kt new file mode 100644 index 0000000..1112841 --- /dev/null +++ b/audiowidget/src/main/java/com/cleveroad/audiowidget/Configuration.kt @@ -0,0 +1,232 @@ +package com.cleveroad.audiowidget + +import android.content.Context +import android.graphics.drawable.Drawable +import android.view.ViewConfiguration +import android.view.animation.Interpolator +import androidx.annotation.ColorInt +import java.util.* + +/** + * Audio widget configuration class. + */ +internal class Configuration private constructor(private val builder: Builder) { + val lightColor: Int = builder.lightColor + val darkColor: Int = builder.darkColor + val progressColor: Int = builder.progressColor + val expandedColor: Int = builder.expandedColor + val random: Random = builder.random!! + val playDrawable: Drawable = builder.playDrawable!! + val pauseDrawable: Drawable = builder.pauseDrawable!! + val prevDrawable: Drawable = builder.prevDrawable!! + val nextDrawable: Drawable = builder.nextDrawable!! + val playlistDrawable: Drawable = builder.playlistDrawable!! + val albumDrawable: Drawable = builder.albumDrawable!! + val context: Context? = builder.context + val playbackState: PlaybackState = builder.playbackState!! + val buttonPadding: Int = builder.buttonPadding + val crossStrokeWidth: Float = builder.crossStrokeWidth + val progressStrokeWidth: Float = builder.progressStrokeWidth + val shadowRadius: Float = builder.shadowRadius + val shadowDx: Float = builder.shadowDx + val shadowDy: Float = builder.shadowDy + val shadowColor: Int = builder.shadowColor + val bubblesMinSize: Float = builder.bubblesMinSize + val bubblesMaxSize: Float = builder.bubblesMaxSize + val crossColor: Int = builder.crossColor + val crossOverlappedColor: Int = builder.crossOverlappedColor + val accDecInterpolator: Interpolator = builder.accDecInterpolator!! + val prevNextExtraPadding: Int = builder.prevNextExtraPadding + + val radius: Float + get() = builder.radius + + val widgetWidth: Float + get() = builder.width + + internal class Builder { + var lightColor = 0 + var darkColor = 0 + var progressColor = 0 + var expandedColor = 0 + var width = 0f + var radius = 0f + var context: Context? = null + var random: Random? = null + var playDrawable: Drawable? = null + var pauseDrawable: Drawable? = null + var prevDrawable: Drawable? = null + var nextDrawable: Drawable? = null + var playlistDrawable: Drawable? = null + var albumDrawable: Drawable? = null + var playbackState: PlaybackState? = null + var buttonPadding = 0 + var crossStrokeWidth = 0f + var progressStrokeWidth = 0f + var shadowRadius = 0f + var shadowDx = 0f + var shadowDy = 0f + var shadowColor = 0 + var bubblesMinSize = 0f + var bubblesMaxSize = 0f + var crossColor = 0 + var crossOverlappedColor = 0 + var accDecInterpolator: Interpolator? = null + var prevNextExtraPadding = 0 + + fun context(context: Context): Builder { + this.context = context + return this + } + + fun playColor(@ColorInt pauseColor: Int): Builder { + lightColor = pauseColor + return this + } + + fun darkColor(@ColorInt playColor: Int): Builder { + darkColor = playColor + return this + } + + fun progressColor(@ColorInt progressColor: Int): Builder { + this.progressColor = progressColor + return this + } + + fun expandedColor(@ColorInt expandedColor: Int): Builder { + this.expandedColor = expandedColor + return this + } + + fun random(random: Random): Builder { + this.random = random + return this + } + + fun widgetWidth(width: Float): Builder { + this.width = width + return this + } + + fun radius(radius: Float): Builder { + this.radius = radius + return this + } + + fun playDrawable(playDrawable: Drawable): Builder { + this.playDrawable = playDrawable + return this + } + + fun pauseDrawable(pauseDrawable: Drawable): Builder { + this.pauseDrawable = pauseDrawable + return this + } + + fun prevDrawable(prevDrawable: Drawable): Builder { + this.prevDrawable = prevDrawable + return this + } + + fun nextDrawable(nextDrawable: Drawable): Builder { + this.nextDrawable = nextDrawable + return this + } + + fun playlistDrawable(plateDrawable: Drawable): Builder { + playlistDrawable = plateDrawable + return this + } + + fun albumDrawable(albumDrawable: Drawable): Builder { + this.albumDrawable = albumDrawable + return this + } + + fun playbackState(playbackState: PlaybackState): Builder { + this.playbackState = playbackState + return this + } + + fun buttonPadding(buttonPadding: Int): Builder { + this.buttonPadding = buttonPadding + return this + } + + fun crossStrokeWidth(crossStrokeWidth: Float): Builder { + this.crossStrokeWidth = crossStrokeWidth + return this + } + + fun progressStrokeWidth(progressStrokeWidth: Float): Builder { + this.progressStrokeWidth = progressStrokeWidth + return this + } + + fun shadowRadius(shadowRadius: Float): Builder { + this.shadowRadius = shadowRadius + return this + } + + fun shadowDx(shadowDx: Float): Builder { + this.shadowDx = shadowDx + return this + } + + fun shadowDy(shadowDy: Float): Builder { + this.shadowDy = shadowDy + return this + } + + fun shadowColor(@ColorInt shadowColor: Int): Builder { + this.shadowColor = shadowColor + return this + } + + fun bubblesMinSize(bubblesMinSize: Float): Builder { + this.bubblesMinSize = bubblesMinSize + return this + } + + fun bubblesMaxSize(bubblesMaxSize: Float): Builder { + this.bubblesMaxSize = bubblesMaxSize + return this + } + + fun crossColor(@ColorInt crossColor: Int): Builder { + this.crossColor = crossColor + return this + } + + fun crossOverlappedColor(@ColorInt crossOverlappedColor: Int): Builder { + this.crossOverlappedColor = crossOverlappedColor + return this + } + + fun accDecInterpolator(accDecInterpolator: Interpolator): Builder { + this.accDecInterpolator = accDecInterpolator + return this + } + + fun prevNextExtraPadding(prevNextExtraPadding: Int): Builder { + this.prevNextExtraPadding = prevNextExtraPadding + return this + } + + fun build(): Configuration { + return Configuration(this) + } + } + + companion object { + const val FRAME_SPEED = 70.0f + + @JvmField + val LONG_CLICK_THRESHOLD = ViewConfiguration.getLongPressTimeout() + 128.toLong() + const val STATE_STOPPED = 0 + const val STATE_PLAYING = 1 + const val STATE_PAUSED = 2 + const val TOUCH_ANIMATION_DURATION: Long = 100 + } +} \ No newline at end of file diff --git a/audiowidget/src/main/java/com/cleveroad/audiowidget/DrawableUtils.java b/audiowidget/src/main/java/com/cleveroad/audiowidget/DrawableUtils.java deleted file mode 100644 index b130bf0..0000000 --- a/audiowidget/src/main/java/com/cleveroad/audiowidget/DrawableUtils.java +++ /dev/null @@ -1,136 +0,0 @@ -package com.cleveroad.audiowidget; - -/** - * Helpful utils class. - */ -class DrawableUtils { - - private DrawableUtils() {} - - static float customFunction(float t, float ... pairs) { - if (pairs.length == 0 || pairs.length % 2 != 0) { - throw new IllegalArgumentException("Length of pairs must be multiple by 2 and greater than zero."); - } - if (t < pairs[1]) { - return pairs[0]; - } - int size = pairs.length / 2; - for (int i=0; i= aT && t <= bT) { - float norm = normalize(t, aT, bT); - return a + norm * (b - a); - } - } - return pairs[pairs.length - 2]; - } - - /** - * Normalize value between minimum and maximum. - * @param val value - * @param minVal minimum value - * @param maxVal maximum value - * @return normalized value in range 0..1 - * @throws IllegalArgumentException if value is out of range [minVal, maxVal] - */ - static float normalize(float val, float minVal, float maxVal) { - if (val < minVal) - return 0; - if (val > maxVal) - return 1; - return (val - minVal) / (maxVal - minVal); - } - - /** - * Rotate point P around center point C. - * @param pX x coordinate of point P - * @param pY y coordinate of point P - * @param cX x coordinate of point C - * @param cY y coordinate of point C - * @param angleInDegrees rotation angle in degrees - * @return new x coordinate - */ - static float rotateX(float pX, float pY, float cX, float cY, float angleInDegrees) { - double angle = Math.toRadians(angleInDegrees); - return (float) (Math.cos(angle) * (pX - cX) - Math.sin(angle) * (pY - cY) + cX); - } - - /** - * Rotate point P around center point C. - * @param pX x coordinate of point P - * @param pY y coordinate of point P - * @param cX x coordinate of point C - * @param cY y coordinate of point C - * @param angleInDegrees rotation angle in degrees - * @return new y coordinate - */ - static float rotateY(float pX, float pY, float cX, float cY, float angleInDegrees) { - double angle = Math.toRadians(angleInDegrees); - return (float) (Math.sin(angle) * (pX - cX) + Math.cos(angle) * (pY - cY) + cY); - } - - /** - * Checks if value belongs to range [start, end] - * @param value value - * @param start start of range - * @param end end of range - * @return true if value belongs to range, false otherwise - */ - static boolean isBetween(float value, float start, float end) { - if (start > end) { - float tmp = start; - start = end; - end = tmp; - } - return value >= start && value <= end; - } - - static float between(float val, float min, float max) { - return Math.min(Math.max(val, min), max); - } - - static int between(int val, int min, int max) { - return Math.min(Math.max(val, min), max); - } - - /** - * Enlarge value from startValue to endValue - * @param startValue start size - * @param endValue end size - * @param time time of animation - * @return new size value - */ - static float enlarge(float startValue, float endValue, float time) { - if (startValue > endValue) - throw new IllegalArgumentException("Start size can't be larger than end size."); - return startValue + (endValue - startValue) * time; - } - - /** - * Reduce value from startValue to endValue - * @param startValue start size - * @param endValue end size - * @param time time of animation - * @return new size value - */ - static float reduce(float startValue, float endValue, float time) { - if (startValue < endValue) - throw new IllegalArgumentException("End size can't be larger than start size."); - return endValue + (startValue - endValue) * (1 - time); - } - - /** - * Exponential smoothing (Holt - Winters). - * @param prevValue previous values in series X[i-1] - * @param newValue new value in series X[i] - * @param a smooth coefficient - * @return smoothed value - */ - static float smooth(float prevValue, float newValue, float a) { - return a * newValue + (1 - a) * prevValue; - } - -} diff --git a/audiowidget/src/main/java/com/cleveroad/audiowidget/DrawableUtils.kt b/audiowidget/src/main/java/com/cleveroad/audiowidget/DrawableUtils.kt new file mode 100644 index 0000000..b503271 --- /dev/null +++ b/audiowidget/src/main/java/com/cleveroad/audiowidget/DrawableUtils.kt @@ -0,0 +1,138 @@ +package com.cleveroad.audiowidget + +import kotlin.math.cos +import kotlin.math.sin + +/** + * Helpful utils class. + */ +internal object DrawableUtils { + @JvmStatic + fun customFunction(t: Float, vararg pairs: Float): Float { + require(!(pairs.isEmpty() || pairs.size % 2 != 0)) { "Length of pairs must be multiple by 2 and greater than zero." } + if (t < pairs[1]) { + return pairs[0] + } + val size = pairs.size / 2 + for (i in 0 until size - 1) { + val a = pairs[2 * i] + val b = pairs[2 * (i + 1)] + val aT = pairs[2 * i + 1] + val bT = pairs[2 * (i + 1) + 1] + if (t in aT..bT) { + val norm = normalize(t, aT, bT) + return a + norm * (b - a) + } + } + return pairs[pairs.size - 2] + } + + /** + * Normalize value between minimum and maximum. + * @param val value + * @param minVal minimum value + * @param maxVal maximum value + * @return normalized value in range `0..1` + * @throws IllegalArgumentException if value is out of range `[minVal, maxVal]` + */ + @JvmStatic + fun normalize(`val`: Float, minVal: Float, maxVal: Float): Float { + if (`val` < minVal) return 0F + return if (`val` > maxVal) 1F else (`val` - minVal) / (maxVal - minVal) + } + + /** + * Rotate point P around center point C. + * @param pX x coordinate of point P + * @param pY y coordinate of point P + * @param cX x coordinate of point C + * @param cY y coordinate of point C + * @param angleInDegrees rotation angle in degrees + * @return new x coordinate + */ + @JvmStatic + fun rotateX(pX: Float, pY: Float, cX: Float, cY: Float, angleInDegrees: Float): Float { + val angle = Math.toRadians(angleInDegrees.toDouble()) + return (cos(angle) * (pX - cX) - sin(angle) * (pY - cY) + cX).toFloat() + } + + /** + * Rotate point P around center point C. + * @param pX x coordinate of point P + * @param pY y coordinate of point P + * @param cX x coordinate of point C + * @param cY y coordinate of point C + * @param angleInDegrees rotation angle in degrees + * @return new y coordinate + */ + @JvmStatic + fun rotateY(pX: Float, pY: Float, cX: Float, cY: Float, angleInDegrees: Float): Float { + val angle = Math.toRadians(angleInDegrees.toDouble()) + return (sin(angle) * (pX - cX) + cos(angle) * (pY - cY) + cY).toFloat() + } + + /** + * Checks if value belongs to range `[start, end]` + * @param value value + * @param start start of range + * @param end end of range + * @return true if value belongs to range, false otherwise + */ + @JvmStatic + fun isBetween(value: Float, start: Float, end: Float): Boolean { + var start = start + var end = end + if (start > end) { + val tmp = start + start = end + end = tmp + } + return value in start..end + } + + fun between(`val`: Float, min: Float, max: Float): Float { + return `val`.coerceAtLeast(min).coerceAtMost(max) + } + + fun between(`val`: Int, min: Int, max: Int): Int { + return `val`.coerceAtLeast(min).coerceAtMost(max) + } + + /** + * Enlarge value from startValue to endValue + * @param startValue start size + * @param endValue end size + * @param time time of animation + * @return new size value + */ + @JvmStatic + fun enlarge(startValue: Float, endValue: Float, time: Float): Float { + require(startValue <= endValue) { "Start size can't be larger than end size." } + return startValue + (endValue - startValue) * time + } + + /** + * Reduce value from startValue to endValue + * @param startValue start size + * @param endValue end size + * @param time time of animation + * @return new size value + */ + @JvmStatic + fun reduce(startValue: Float, endValue: Float, time: Float): Float { + require(startValue >= endValue) { "End size can't be larger than start size." } + return endValue + (startValue - endValue) * (1 - time) + } + + /** + * Exponential smoothing (Holt - Winters). + * @param prevValue previous values in series `X[i-1]` + * @param newValue new value in series `X[i]` + * @param a smooth coefficient + * @return smoothed value + */ + @JvmStatic + fun smooth(prevValue: Float, newValue: Float, a: Float): Float { + return a * newValue + (1 - a) * prevValue + } +} \ No newline at end of file diff --git a/audiowidget/src/main/java/com/cleveroad/audiowidget/ExpandCollapseWidget.java b/audiowidget/src/main/java/com/cleveroad/audiowidget/ExpandCollapseWidget.java deleted file mode 100644 index b1f7703..0000000 --- a/audiowidget/src/main/java/com/cleveroad/audiowidget/ExpandCollapseWidget.java +++ /dev/null @@ -1,780 +0,0 @@ -package com.cleveroad.audiowidget; - -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.animation.PropertyValuesHolder; -import android.animation.ValueAnimator; -import android.annotation.SuppressLint; -import android.graphics.Canvas; -import android.graphics.Paint; -import android.graphics.Rect; -import android.graphics.RectF; -import android.graphics.drawable.Drawable; -import android.os.Build; -import android.util.Log; -import android.view.animation.Interpolator; -import android.view.animation.LinearInterpolator; -import android.widget.ImageView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.util.Random; - -/** - * Expanded state view. - */ -@SuppressLint({"ViewConstructor", "AppCompatCustomView"}) -class ExpandCollapseWidget extends ImageView implements PlaybackState.PlaybackStateListener { - - static final int DIRECTION_LEFT = 1; - static final int DIRECTION_RIGHT = 2; - - private static final float EXPAND_DURATION_F = (34 * Configuration.FRAME_SPEED); - private static final long EXPAND_DURATION_L = (long) EXPAND_DURATION_F; - private static final float EXPAND_COLOR_END_F = 9 * Configuration.FRAME_SPEED; - private static final float EXPAND_SIZE_END_F = 12 * Configuration.FRAME_SPEED; - private static final float EXPAND_POSITION_START_F = 10 * Configuration.FRAME_SPEED; - private static final float EXPAND_POSITION_END_F = 18 * Configuration.FRAME_SPEED; - private static final float EXPAND_BUBBLES_START_F = 18 * Configuration.FRAME_SPEED; - private static final float EXPAND_BUBBLES_END_F = 32 * Configuration.FRAME_SPEED; - private static final float EXPAND_ELEMENTS_START_F = 20 * Configuration.FRAME_SPEED; - private static final float EXPAND_ELEMENTS_END_F = 27 * Configuration.FRAME_SPEED; - - private static final float COLLAPSE_DURATION_F = 12 * Configuration.FRAME_SPEED; - private static final long COLLAPSE_DURATION_L = (long) COLLAPSE_DURATION_F; - private static final float COLLAPSE_ELEMENTS_END_F = 3 * Configuration.FRAME_SPEED; - private static final float COLLAPSE_SIZE_START_F = 2 * Configuration.FRAME_SPEED; - private static final float COLLAPSE_SIZE_END_F = 12 * Configuration.FRAME_SPEED; - private static final float COLLAPSE_POSITION_START_F = 3 * Configuration.FRAME_SPEED; - private static final float COLLAPSE_POSITION_END_F = 12 * Configuration.FRAME_SPEED; - - - private static final int INDEX_PLAYLIST = 0; - private static final int INDEX_PREV = 1; - private static final int INDEX_PLAY = 2; - private static final int INDEX_NEXT = 3; - private static final int INDEX_ALBUM = 4; - private static final int INDEX_PAUSE = 5; - - private static final int TOTAL_BUBBLES_COUNT = 30; - - - private final Paint paint; - private final float radius; - private final float widgetWidth; - private final float widgetHeight; - private final ColorChanger colorChanger; - private final int playColor; - private final int pauseColor; - private final int widgetColor; - private final Drawable[] drawables; - private final Rect[] buttonBounds; - private final float sizeStep; - private final float[] bubbleSizes; - private final float[] bubbleSpeeds; - private final float[] bubblePositions; - private final float bubblesMinSize; - private final float bubblesMaxSize; - private final Random random; - private final Paint bubblesPaint; - private final RectF bounds; - private final Rect tmpRect; - private final PlaybackState playbackState; - private final ValueAnimator expandAnimator; - private final ValueAnimator collapseAnimator; - private final Drawable defaultAlbumCover; - private final int buttonPadding; - private final int prevNextExtraPadding; - private final Interpolator accDecInterpolator; - private final ValueAnimator touchDownAnimator; - private final ValueAnimator touchUpAnimator; - private final ValueAnimator bubblesTouchAnimator; - - private float bubblesTime; - private boolean expanded; - private boolean animatingExpand, animatingCollapse; - private int expandDirection; - private AudioWidget.OnWidgetStateChangedListener onWidgetStateChangedListener; - private int padding; - private AudioWidget.OnControlsClickListener onControlsClickListener; - private int touchedButtonIndex; - - @Nullable - protected AnimationProgressListener expandListener; - @Nullable - private AnimationProgressListener collapseListener; - - public ExpandCollapseWidget(@NonNull Configuration configuration) { - super(configuration.context()); - setLayerType(LAYER_TYPE_SOFTWARE, null); - this.playbackState = configuration.playbackState(); - this.accDecInterpolator = configuration.accDecInterpolator(); - this.random = configuration.random(); - this.bubblesPaint = new Paint(); - this.bubblesPaint.setStyle(Paint.Style.FILL); - this.bubblesPaint.setAntiAlias(true); - this.bubblesPaint.setColor(configuration.expandedColor()); - this.bubblesPaint.setAlpha(0); - this.paint = new Paint(); - this.paint.setColor(configuration.expandedColor()); - this.paint.setAntiAlias(true); - this.paint.setShadowLayer( - configuration.shadowRadius(), - configuration.shadowDx(), - configuration.shadowDy(), - configuration.shadowColor() - ); - this.radius = configuration.radius(); - this.widgetWidth = configuration.widgetWidth(); - this.colorChanger = new ColorChanger(); - this.playColor = configuration.darkColor(); - this.pauseColor = configuration.lightColor(); - this.widgetColor = configuration.expandedColor(); - this.buttonPadding = configuration.buttonPadding(); - this.prevNextExtraPadding = configuration.prevNextExtraPadding(); - this.bubblesMinSize = configuration.bubblesMinSize(); - this.bubblesMaxSize = configuration.bubblesMaxSize(); - this.tmpRect = new Rect(); - this.buttonBounds = new Rect[5]; - this.drawables = new Drawable[6]; - this.bounds = new RectF(); - this.drawables[INDEX_PLAYLIST] = configuration.playlistDrawable().getConstantState().newDrawable().mutate(); - this.drawables[INDEX_PREV] = configuration.prevDrawable().getConstantState().newDrawable().mutate(); - this.drawables[INDEX_PLAY] = configuration.playDrawable().getConstantState().newDrawable().mutate(); - this.drawables[INDEX_PAUSE] = configuration.pauseDrawable().getConstantState().newDrawable().mutate(); - this.drawables[INDEX_NEXT] = configuration.nextDrawable().getConstantState().newDrawable().mutate(); - this.drawables[INDEX_ALBUM] = defaultAlbumCover = configuration.albumDrawable().getConstantState().newDrawable().mutate(); - this.sizeStep = widgetWidth / 5f; - this.widgetHeight = radius * 2; - for (int i = 0; i < buttonBounds.length; i++) { - buttonBounds[i] = new Rect(); - } - this.bubbleSizes = new float[TOTAL_BUBBLES_COUNT]; - this.bubbleSpeeds = new float[TOTAL_BUBBLES_COUNT]; - this.bubblePositions = new float[TOTAL_BUBBLES_COUNT * 2]; - this.playbackState.addPlaybackStateListener(this); - - this.expandAnimator = ValueAnimator.ofPropertyValuesHolder( - PropertyValuesHolder.ofFloat("percent", 0f, 1f), - PropertyValuesHolder.ofInt("expandPosition", 0, (int) EXPAND_DURATION_L), - PropertyValuesHolder.ofFloat("alpha", 0f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f) - ).setDuration(EXPAND_DURATION_L); - - LinearInterpolator interpolator = new LinearInterpolator(); - this.expandAnimator.setInterpolator(interpolator); - this.expandAnimator.addUpdateListener(animation -> { - updateExpandAnimation((int) animation.getAnimatedValue("expandPosition")); - setAlpha((float) animation.getAnimatedValue("alpha")); - invalidate(); - - if(expandListener != null) { - expandListener.onValueChanged((float) animation.getAnimatedValue("percent")); - } - }); - this.expandAnimator.addListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationStart(Animator animation) { - super.onAnimationStart(animation); - animatingExpand = true; - } - - @Override - public void onAnimationEnd(Animator animation) { - super.onAnimationEnd(animation); - animatingExpand = false; - expanded = true; - if (onWidgetStateChangedListener != null) { - onWidgetStateChangedListener.onWidgetStateChanged(AudioWidget.State.EXPANDED); - } - } - - @Override - public void onAnimationCancel(Animator animation) { - super.onAnimationCancel(animation); - animatingExpand = false; - } - }); - this.collapseAnimator = ValueAnimator.ofPropertyValuesHolder( - PropertyValuesHolder.ofFloat("percent", 0f, 1f), - PropertyValuesHolder.ofInt("expandPosition", 0, (int) COLLAPSE_DURATION_L), - PropertyValuesHolder.ofFloat("alpha", 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 0f) - ).setDuration(COLLAPSE_DURATION_L); - this.collapseAnimator.setInterpolator(interpolator); - this.collapseAnimator.addUpdateListener(animation -> { - updateCollapseAnimation((int) animation.getAnimatedValue("expandPosition")); - setAlpha((float) animation.getAnimatedValue("alpha")); - invalidate(); - - if(collapseListener != null) { - collapseListener.onValueChanged((float) animation.getAnimatedValue("percent")); - } - }); - this.collapseAnimator.addListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationStart(Animator animation) { - super.onAnimationStart(animation); - animatingCollapse = true; - } - - @Override - public void onAnimationEnd(Animator animation) { - super.onAnimationEnd(animation); - animatingCollapse = false; - expanded = false; - if (onWidgetStateChangedListener != null) { - onWidgetStateChangedListener.onWidgetStateChanged(AudioWidget.State.COLLAPSED); - } - } - - @Override - public void onAnimationCancel(Animator animation) { - super.onAnimationCancel(animation); - animatingCollapse = false; - } - }); - this.padding = configuration.context().getResources().getDimensionPixelSize(R.dimen.aw_expand_collapse_widget_padding); - ValueAnimator.AnimatorUpdateListener listener = animation -> { - if (touchedButtonIndex == -1 || touchedButtonIndex >= buttonBounds.length) { - return; - } - calculateBounds(touchedButtonIndex, tmpRect); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - invalidate(); - return; - } - Rect rect = buttonBounds[touchedButtonIndex]; - float width = tmpRect.width() * (float) animation.getAnimatedValue() / 2; - float height = tmpRect.height() * (float) animation.getAnimatedValue() / 2; - int l = (int) (tmpRect.centerX() - width); - int r = (int) (tmpRect.centerX() + width); - int t = (int) (tmpRect.centerY() - height); - int b = (int) (tmpRect.centerY() + height); - rect.set(l, t, r, b); - invalidate(rect); - }; - touchDownAnimator = ValueAnimator.ofFloat(1, 0.9f).setDuration(Configuration.TOUCH_ANIMATION_DURATION); - touchDownAnimator.addUpdateListener(listener); - touchUpAnimator = ValueAnimator.ofFloat(0.9f, 1f).setDuration(Configuration.TOUCH_ANIMATION_DURATION); - touchUpAnimator.addUpdateListener(listener); - bubblesTouchAnimator = ValueAnimator.ofFloat(0, EXPAND_BUBBLES_END_F - EXPAND_BUBBLES_START_F) - .setDuration((long) (EXPAND_BUBBLES_END_F - EXPAND_BUBBLES_START_F)); - bubblesTouchAnimator.setInterpolator(interpolator); - bubblesTouchAnimator.addUpdateListener(animation -> { - bubblesTime = animation.getAnimatedFraction(); - bubblesPaint.setAlpha((int) DrawableUtils.customFunction(bubblesTime, 0, 0, 255, 0.33f, 255, 0.66f, 0, 1f)); - invalidate(); - }); - bubblesTouchAnimator.addListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationStart(Animator animation) { - super.onAnimationStart(animation); - } - - @Override - public void onAnimationEnd(Animator animation) { - super.onAnimationEnd(animation); - bubblesTime = 0; - } - - @Override - public void onAnimationCancel(Animator animation) { - super.onAnimationCancel(animation); - bubblesTime = 0; - } - }); - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - int w = MeasureSpec.makeMeasureSpec((int) widgetWidth + padding * 2, MeasureSpec.EXACTLY); - int h = MeasureSpec.makeMeasureSpec((int) (widgetHeight * 2) + padding * 2, MeasureSpec.EXACTLY); - super.onMeasure(w, h); - } - - @Override - protected void onDraw(@NonNull Canvas canvas) { - if (bubblesTime >= 0) { - int half = TOTAL_BUBBLES_COUNT / 2; - for (int i = 0; i < TOTAL_BUBBLES_COUNT; i++) { - float radius = bubbleSizes[i]; - float speed = bubbleSpeeds[i] * bubblesTime; - float cx = bubblePositions[2 * i]; - float cy = bubblePositions[2 * i + 1]; - if (i < half) - cy *= (1 - speed); - else - cy *= (1 + speed); - canvas.drawCircle(cx, cy, radius, bubblesPaint); - } - } - canvas.drawRoundRect(bounds, radius, radius, paint); - drawMediaButtons(canvas); - } - - private void drawMediaButtons(@NonNull Canvas canvas) { - for (int i = 0; i < buttonBounds.length; i++) { - Drawable drawable; - if (i == INDEX_PLAY) { - if (playbackState.state() == Configuration.STATE_PLAYING) { - drawable = drawables[INDEX_PAUSE]; - } else { - drawable = drawables[INDEX_PLAY]; - } - } else { - drawable = drawables[i]; - } - drawable.setBounds(buttonBounds[i]); - drawable.draw(canvas); - } - } - - private void updateExpandAnimation(long position) { - if (DrawableUtils.isBetween(position, 0, EXPAND_COLOR_END_F)) { - float t = DrawableUtils.normalize(position, 0, EXPAND_COLOR_END_F); - paint.setColor(colorChanger.nextColor(t)); - } - if (DrawableUtils.isBetween(position, 0, EXPAND_SIZE_END_F)) { - float time = DrawableUtils.normalize(position, 0, EXPAND_SIZE_END_F); - time = accDecInterpolator.getInterpolation(time); - float l, r, t, b; - float height = radius * 2; - t = radius; - b = t + height; - if (expandDirection == DIRECTION_LEFT) { - r = widgetWidth; - l = r - height - (widgetWidth - height) * time; - } else { - l = 0; - r = l + height + (widgetWidth - height) * time; - } - bounds.set(l, t, r, b); - } else if (position > EXPAND_SIZE_END_F) { - if (expandDirection == DIRECTION_LEFT) { - bounds.left = 0; - } else { - bounds.right = widgetWidth; - } - - } - if (DrawableUtils.isBetween(position, 0, EXPAND_POSITION_START_F)) { - if (expandDirection == DIRECTION_LEFT) { - calculateBounds(INDEX_ALBUM, buttonBounds[INDEX_PLAY]); - } else { - calculateBounds(INDEX_PLAYLIST, buttonBounds[INDEX_PLAY]); - } - } - if (DrawableUtils.isBetween(position, 0, EXPAND_ELEMENTS_START_F)) { - for (int i = 0; i < buttonBounds.length; i++) { - if (i != INDEX_PLAY) { - drawables[i].setAlpha(0); - } - } - } - if (DrawableUtils.isBetween(position, EXPAND_ELEMENTS_START_F, EXPAND_ELEMENTS_END_F)) { - float time = DrawableUtils.normalize(position, EXPAND_ELEMENTS_START_F, EXPAND_ELEMENTS_END_F); - expandCollapseElements(time); - } - if (DrawableUtils.isBetween(position, EXPAND_POSITION_START_F, EXPAND_POSITION_END_F)) { - float time = DrawableUtils.normalize(position, EXPAND_POSITION_START_F, EXPAND_POSITION_END_F); - time = accDecInterpolator.getInterpolation(time); - Rect playBounds = buttonBounds[INDEX_PLAY]; - calculateBounds(INDEX_PLAY, playBounds); - int l, t, r, b; - t = playBounds.top; - b = playBounds.bottom; - if (expandDirection == DIRECTION_LEFT) { - calculateBounds(INDEX_ALBUM, tmpRect); - l = (int) DrawableUtils.reduce(tmpRect.left, playBounds.left, time); - r = l + playBounds.width(); - } else { - calculateBounds(INDEX_PLAYLIST, tmpRect); - l = (int) DrawableUtils.enlarge(tmpRect.left, playBounds.left, time); - r = l + playBounds.width(); - } - playBounds.set(l, t, r, b); - } else if (position >= EXPAND_POSITION_END_F) { - calculateBounds(INDEX_PLAY, buttonBounds[INDEX_PLAY]); - } - if (DrawableUtils.isBetween(position, EXPAND_BUBBLES_START_F, EXPAND_BUBBLES_END_F)) { - float time = DrawableUtils.normalize(position, EXPAND_BUBBLES_START_F, EXPAND_BUBBLES_END_F); - bubblesPaint.setAlpha((int) DrawableUtils.customFunction(time, 0, 0, 255, 0.33f, 255, 0.66f, 0, 1f)); - } else { - bubblesPaint.setAlpha(0); - } - if (DrawableUtils.isBetween(position, EXPAND_BUBBLES_START_F, EXPAND_BUBBLES_END_F)) { - bubblesTime = DrawableUtils.normalize(position, EXPAND_BUBBLES_START_F, EXPAND_BUBBLES_END_F); - } - } - - private void calculateBounds(int index, Rect bounds) { - int padding = buttonPadding; - if (index == INDEX_PREV || index == INDEX_NEXT) { - padding += prevNextExtraPadding; - } - calculateBounds(index, bounds, padding); - } - - private void calculateBounds(int index, Rect bounds, int padding) { - int l = (int) (index * sizeStep + padding); - int t = (int) (radius + padding); - int r = (int) ((index + 1) * sizeStep - padding); - int b = (int) (radius * 3 - padding); - bounds.set(l, t, r, b); - } - - private void updateCollapseAnimation(long position) { - if (DrawableUtils.isBetween(position, 0, COLLAPSE_ELEMENTS_END_F)) { - float time = 1 - DrawableUtils.normalize(position, 0, COLLAPSE_ELEMENTS_END_F); - expandCollapseElements(time); - } - if (position > COLLAPSE_ELEMENTS_END_F) { - for (int i = 0; i < buttonBounds.length; i++) { - if (i != INDEX_PLAY) { - drawables[i].setAlpha(0); - } - } - } - if (DrawableUtils.isBetween(position, COLLAPSE_POSITION_START_F, COLLAPSE_POSITION_END_F)) { - float time = DrawableUtils.normalize(position, COLLAPSE_POSITION_START_F, COLLAPSE_POSITION_END_F); - time = accDecInterpolator.getInterpolation(time); - Rect playBounds = buttonBounds[INDEX_PLAY]; - calculateBounds(INDEX_PLAY, playBounds); - int l, t, r, b; - t = playBounds.top; - b = playBounds.bottom; - if (expandDirection == DIRECTION_LEFT) { - calculateBounds(INDEX_ALBUM, tmpRect); - l = (int) DrawableUtils.enlarge(playBounds.left, tmpRect.left, time); - r = l + playBounds.width(); - } else { - calculateBounds(INDEX_PLAYLIST, tmpRect); - l = (int) DrawableUtils.reduce(playBounds.left, tmpRect.left, time); - r = l + playBounds.width(); - } - buttonBounds[INDEX_PLAY].set(l, t, r, b); - } - if (DrawableUtils.isBetween(position, COLLAPSE_SIZE_START_F, COLLAPSE_SIZE_END_F)) { - float time = DrawableUtils.normalize(position, COLLAPSE_SIZE_START_F, COLLAPSE_SIZE_END_F); - time = accDecInterpolator.getInterpolation(time); - paint.setColor(colorChanger.nextColor(time)); - float l, r, t, b; - float height = radius * 2; - t = radius; - b = t + height; - if (expandDirection == DIRECTION_LEFT) { - r = widgetWidth; - l = r - height - (widgetWidth - height) * (1 - time); - } else { - l = 0; - r = l + height + (widgetWidth - height) * (1 - time); - } - bounds.set(l, t, r, b); - } - } - - private void expandCollapseElements(float time) { - int alpha = (int) DrawableUtils.between(time * 255, 0, 255); - for (int i = 0; i < buttonBounds.length; i++) { - if (i != INDEX_PLAY) { - int padding = buttonPadding; - if (i == INDEX_PREV || i == INDEX_NEXT) { - padding += prevNextExtraPadding; - } - calculateBounds(i, buttonBounds[i]); - float size = time * (sizeStep / 2f - padding); - int cx = buttonBounds[i].centerX(); - int cy = buttonBounds[i].centerY(); - buttonBounds[i].set((int) (cx - size), (int) (cy - size), (int) (cx + size), (int) (cy + size)); - drawables[i].setAlpha(alpha); - } - } - } - - public void onClick(float x, float y) { - if (isAnimationInProgress()) - return; - int index = getTouchedAreaIndex((int) x, (int) y); - if (index == INDEX_PLAY || index == INDEX_PREV || index == INDEX_NEXT) { - if (!bubblesTouchAnimator.isRunning()) { - randomizeBubblesPosition(); - bubblesTouchAnimator.start(); - } - } - switch (index) { - case INDEX_PLAYLIST: { - if (onControlsClickListener != null) { - onControlsClickListener.onPlaylistClicked(); - } - break; - } - case INDEX_PREV: { - if (onControlsClickListener != null) { - onControlsClickListener.onPreviousClicked(); - } - break; - } - case INDEX_PLAY: { - if (onControlsClickListener != null) { - onControlsClickListener.onPlayPauseClicked(); - } - break; - } - case INDEX_NEXT: { - if (onControlsClickListener != null) { - onControlsClickListener.onNextClicked(); - } - break; - } - case INDEX_ALBUM: { - if (onControlsClickListener != null) { - onControlsClickListener.onAlbumClicked(); - } - break; - } - default: { - Log.w(ExpandCollapseWidget.class.getSimpleName(), "Unknown index: " + index); - break; - } - } - } - - public void onLongClick(float x, float y) { - if (isAnimationInProgress()) - return; - int index = getTouchedAreaIndex((int) x, (int) y); - switch (index) { - case INDEX_PLAYLIST: { - if (onControlsClickListener != null) { - onControlsClickListener.onPlaylistLongClicked(); - } - break; - } - case INDEX_PREV: { - if (onControlsClickListener != null) { - onControlsClickListener.onPreviousLongClicked(); - } - break; - } - case INDEX_PLAY: { - if (onControlsClickListener != null) { - onControlsClickListener.onPlayPauseLongClicked(); - } - break; - } - case INDEX_NEXT: { - if (onControlsClickListener != null) { - onControlsClickListener.onNextLongClicked(); - } - break; - } - case INDEX_ALBUM: { - if (onControlsClickListener != null) { - onControlsClickListener.onAlbumLongClicked(); - } - break; - } - default: { - Log.w(ExpandCollapseWidget.class.getSimpleName(), "Unknown index: " + index); - break; - } - } - } - - private int getTouchedAreaIndex(int x, int y) { - int index = -1; - for (int i = 0; i < buttonBounds.length; i++) { - calculateBounds(i, tmpRect, 0); - if (tmpRect.contains(x, y)) { - index = i; - break; - } - } - return index; - } - - public void expand(int expandDirection) { - if (expanded) { - return; - } - this.expandDirection = expandDirection; - startExpandAnimation(); - } - - private void startExpandAnimation() { - if (isAnimationInProgress()) - return; - animatingExpand = true; - if (playbackState.state() == Configuration.STATE_PLAYING) { - colorChanger - .fromColor(playColor) - .toColor(widgetColor); - - } else { - colorChanger - .fromColor(pauseColor) - .toColor(widgetColor); - } - randomizeBubblesPosition(); - expandAnimator.start(); - } - - private void randomizeBubblesPosition() { - int half = TOTAL_BUBBLES_COUNT / 2; - float step = widgetWidth / half; - for (int i = 0; i < TOTAL_BUBBLES_COUNT; i++) { - int index = i % half; - float speed = 0.3f + 0.7f * random.nextFloat(); - float size = bubblesMinSize + (bubblesMaxSize - bubblesMinSize) * random.nextFloat(); - float radius = size / 2f; - float cx = padding + index * step + step * random.nextFloat() * (random.nextBoolean() ? 1 : -1); - float cy = widgetHeight + padding; - bubbleSpeeds[i] = speed; - bubbleSizes[i] = radius; - bubblePositions[2 * i] = cx; - bubblePositions[2 * i + 1] = cy; - } - } - - private void startCollapseAnimation() { - if (isAnimationInProgress()) { - return; - } - collapseAnimator.start(); - } - - public boolean isAnimationInProgress() { - return animatingCollapse || animatingExpand; - } - - public boolean collapse() { - if (!expanded) { - return false; - } - if (playbackState.state() == Configuration.STATE_PLAYING) { - colorChanger - .fromColor(widgetColor) - .toColor(playColor); - } else { - colorChanger - .fromColor(widgetColor) - .toColor(pauseColor); - } - startCollapseAnimation(); - return true; - } - - @Override - public void onStateChanged(int oldState, int newState, Object initiator) { - invalidate(); - } - - @Override - public void onProgressChanged(int position, int duration, float percentage) { - - } - - public ExpandCollapseWidget onWidgetStateChangedListener(AudioWidget.OnWidgetStateChangedListener onWidgetStateChangedListener) { - this.onWidgetStateChangedListener = onWidgetStateChangedListener; - return this; - } - - public int expandDirection() { - return expandDirection; - } - - public void expandDirection(int expandDirection) { - this.expandDirection = expandDirection; - } - - public void onControlsClickListener(AudioWidget.OnControlsClickListener onControlsClickListener) { - this.onControlsClickListener = onControlsClickListener; - } - - public void albumCover(@Nullable Drawable albumCover) { - if (drawables[INDEX_ALBUM] == albumCover) - return; - if (albumCover == null) { - drawables[INDEX_ALBUM] = defaultAlbumCover; - } else { - if (albumCover.getConstantState() != null) - drawables[INDEX_ALBUM] = albumCover.getConstantState().newDrawable().mutate(); - else - drawables[INDEX_ALBUM] = albumCover; - } - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { - Rect bounds = buttonBounds[INDEX_ALBUM]; - invalidate(bounds.left, bounds.top, bounds.right, bounds.bottom); - } else { - invalidate(); - } - } - - public void onTouched(float x, float y) { - int index = getTouchedAreaIndex((int) x, (int) y); - if (index == INDEX_PLAY || index == INDEX_NEXT || index == INDEX_PREV) { - touchedButtonIndex = index; - touchDownAnimator.start(); - } - } - - public void onReleased(float x, float y) { - int index = getTouchedAreaIndex((int) x, (int) y); - if (index == INDEX_PLAY || index == INDEX_NEXT || index == INDEX_PREV) { - touchedButtonIndex = index; - touchUpAnimator.start(); - } - } - - public TouchManager.BoundsChecker newBoundsChecker(int offsetX, int offsetY) { - return new BoundsCheckerImpl(radius, padding, widgetWidth, widgetHeight, offsetX, offsetY); - } - - public void setCollapseListener(@Nullable AnimationProgressListener collapseListener) { - this.collapseListener = collapseListener; - } - - public void setExpandListener(@Nullable AnimationProgressListener expandListener) { - this.expandListener = expandListener; - } - - interface AnimationProgressListener { - void onValueChanged(float percent); - } - - private static final class BoundsCheckerImpl extends AudioWidget.BoundsCheckerWithOffset { - - private float radius; - private float padding; - private float widgetWidth; - private float widgetHeight; - - BoundsCheckerImpl(float radius, float padding, float widgetWidth, float widgetHeight, int offsetX, int offsetY) { - super(offsetX, offsetY); - this.radius = radius; - this.padding = padding; - this.widgetWidth = widgetWidth; - this.widgetHeight = widgetHeight; - } - - @Override - public float stickyLeftSideImpl(float screenWidth) { - return 0; - } - - @Override - public float stickyRightSideImpl(float screenWidth) { - return screenWidth - widgetWidth; - } - - @Override - public float stickyBottomSideImpl(float screenHeight) { - return screenHeight - 3 * radius; - } - - @Override - public float stickyTopSideImpl(float screenHeight) { - return -radius; - } - } -} diff --git a/audiowidget/src/main/java/com/cleveroad/audiowidget/ExpandCollapseWidget.kt b/audiowidget/src/main/java/com/cleveroad/audiowidget/ExpandCollapseWidget.kt new file mode 100644 index 0000000..25f44b6 --- /dev/null +++ b/audiowidget/src/main/java/com/cleveroad/audiowidget/ExpandCollapseWidget.kt @@ -0,0 +1,718 @@ +package com.cleveroad.audiowidget + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.PropertyValuesHolder +import android.animation.ValueAnimator +import android.animation.ValueAnimator.AnimatorUpdateListener +import android.annotation.SuppressLint +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.drawable.Drawable +import android.os.Build +import android.util.Log +import android.view.animation.Interpolator +import android.view.animation.LinearInterpolator +import android.widget.ImageView +import com.cleveroad.audiowidget.AudioWidget.* +import com.cleveroad.audiowidget.DrawableUtils.between +import com.cleveroad.audiowidget.DrawableUtils.customFunction +import com.cleveroad.audiowidget.DrawableUtils.enlarge +import com.cleveroad.audiowidget.DrawableUtils.isBetween +import com.cleveroad.audiowidget.DrawableUtils.normalize +import com.cleveroad.audiowidget.DrawableUtils.reduce +import com.cleveroad.audiowidget.PlaybackState.PlaybackStateListener +import com.cleveroad.audiowidget.TouchManager.BoundsChecker +import java.util.* + +/** + * Expanded state view. + */ +@SuppressLint("ViewConstructor", "AppCompatCustomView") +internal class ExpandCollapseWidget(configuration: Configuration) : + ImageView(configuration.context), PlaybackStateListener { + private val paint: Paint + private val radius: Float + private val widgetWidth: Float + private val widgetHeight: Float + private val colorChanger: ColorChanger + private val playColor: Int + private val pauseColor: Int + private val widgetColor: Int + private val drawables: Array + private val buttonBounds: Array + private val sizeStep: Float + private val bubbleSizes: FloatArray + private val bubbleSpeeds: FloatArray + private val bubblePositions: FloatArray + private val bubblesMinSize: Float + private val bubblesMaxSize: Float + private val random: Random + private val bubblesPaint: Paint + private val bounds: RectF + private val tmpRect: Rect + private val playbackState: PlaybackState + private val expandAnimator: ValueAnimator + private val collapseAnimator: ValueAnimator + private val defaultAlbumCover: Drawable + private val buttonPadding: Int + private val prevNextExtraPadding: Int + private val accDecInterpolator: Interpolator + private val touchDownAnimator: ValueAnimator + private val touchUpAnimator: ValueAnimator + private val bubblesTouchAnimator: ValueAnimator + private var bubblesTime = 0f + private var expanded = false + private var animatingExpand = false + private var animatingCollapse = false + private var expandDirection = 0 + private var onWidgetStateChangedListener: OnWidgetStateChangedListener? = null + private val padding: Int + private var onControlsClickListener: OnControlsClickListener? = null + private var touchedButtonIndex = 0 + + var expandListener: AnimationProgressListener? = null + private var collapseListener: AnimationProgressListener? = null + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val w = MeasureSpec.makeMeasureSpec(widgetWidth.toInt() + padding * 2, MeasureSpec.EXACTLY) + val h = MeasureSpec.makeMeasureSpec( + (widgetHeight * 2).toInt() + padding * 2, + MeasureSpec.EXACTLY + ) + super.onMeasure(w, h) + } + + override fun onDraw(canvas: Canvas) { + if (bubblesTime >= 0) { + val half = TOTAL_BUBBLES_COUNT / 2 + for (i in 0 until TOTAL_BUBBLES_COUNT) { + val radius = bubbleSizes[i] + val speed = bubbleSpeeds[i] * bubblesTime + val cx = bubblePositions[2 * i] + var cy = bubblePositions[2 * i + 1] + cy *= if (i < half) 1 - speed else 1 + speed + canvas.drawCircle(cx, cy, radius, bubblesPaint) + } + } + canvas.drawRoundRect(bounds, radius, radius, paint) + drawMediaButtons(canvas) + } + + private fun drawMediaButtons(canvas: Canvas) { + for (i in buttonBounds.indices) { + val drawable: Drawable? = if (i == INDEX_PLAY) { + if (playbackState.state() == Configuration.STATE_PLAYING) { + drawables[INDEX_PAUSE] + } else { + drawables[INDEX_PLAY] + } + } else { + drawables[i] + } + drawable!!.bounds = buttonBounds[i]!! + drawable.draw(canvas) + } + } + + private fun updateExpandAnimation(position: Long) { + if (isBetween(position.toFloat(), 0f, EXPAND_COLOR_END_F)) { + val t = normalize(position.toFloat(), 0f, EXPAND_COLOR_END_F) + paint.color = colorChanger.nextColor(t) + } + if (isBetween(position.toFloat(), 0f, EXPAND_SIZE_END_F)) { + var time = normalize(position.toFloat(), 0f, EXPAND_SIZE_END_F) + time = accDecInterpolator.getInterpolation(time) + val l: Float + val r: Float + val b: Float + val height = radius * 2 + val t: Float = radius + b = t + height + if (expandDirection == DIRECTION_LEFT) { + r = widgetWidth + l = r - height - (widgetWidth - height) * time + } else { + l = 0f + r = l + height + (widgetWidth - height) * time + } + bounds[l, t, r] = b + } else if (position > EXPAND_SIZE_END_F) { + if (expandDirection == DIRECTION_LEFT) { + bounds.left = 0f + } else { + bounds.right = widgetWidth + } + } + if (isBetween(position.toFloat(), 0f, EXPAND_POSITION_START_F)) { + if (expandDirection == DIRECTION_LEFT) { + calculateBounds(INDEX_ALBUM, buttonBounds[INDEX_PLAY]) + } else { + calculateBounds(INDEX_PLAYLIST, buttonBounds[INDEX_PLAY]) + } + } + if (isBetween(position.toFloat(), 0f, EXPAND_ELEMENTS_START_F)) { + for (i in buttonBounds.indices) { + if (i != INDEX_PLAY) { + drawables[i]!!.alpha = 0 + } + } + } + if (isBetween(position.toFloat(), EXPAND_ELEMENTS_START_F, EXPAND_ELEMENTS_END_F)) { + val time = normalize(position.toFloat(), EXPAND_ELEMENTS_START_F, EXPAND_ELEMENTS_END_F) + expandCollapseElements(time) + } + if (isBetween(position.toFloat(), EXPAND_POSITION_START_F, EXPAND_POSITION_END_F)) { + var time = normalize(position.toFloat(), EXPAND_POSITION_START_F, EXPAND_POSITION_END_F) + time = accDecInterpolator.getInterpolation(time) + val playBounds = buttonBounds[INDEX_PLAY] + calculateBounds(INDEX_PLAY, playBounds) + val l: Int + val t: Int + val r: Int + val b: Int + t = playBounds!!.top + b = playBounds.bottom + if (expandDirection == DIRECTION_LEFT) { + calculateBounds(INDEX_ALBUM, tmpRect) + l = reduce(tmpRect.left.toFloat(), playBounds.left.toFloat(), time).toInt() + r = l + playBounds.width() + } else { + calculateBounds(INDEX_PLAYLIST, tmpRect) + l = enlarge(tmpRect.left.toFloat(), playBounds.left.toFloat(), time).toInt() + r = l + playBounds.width() + } + playBounds[l, t, r] = b + } else if (position >= EXPAND_POSITION_END_F) { + calculateBounds(INDEX_PLAY, buttonBounds[INDEX_PLAY]) + } + if (isBetween(position.toFloat(), EXPAND_BUBBLES_START_F, EXPAND_BUBBLES_END_F)) { + val time = normalize(position.toFloat(), EXPAND_BUBBLES_START_F, EXPAND_BUBBLES_END_F) + bubblesPaint.alpha = + customFunction(time, 0f, 0f, 255f, 0.33f, 255f, 0.66f, 0f, 1f).toInt() + } else { + bubblesPaint.alpha = 0 + } + if (isBetween(position.toFloat(), EXPAND_BUBBLES_START_F, EXPAND_BUBBLES_END_F)) { + bubblesTime = + normalize(position.toFloat(), EXPAND_BUBBLES_START_F, EXPAND_BUBBLES_END_F) + } + } + + private fun calculateBounds(index: Int, bounds: Rect?) { + var padding = buttonPadding + if (index == INDEX_PREV || index == INDEX_NEXT) { + padding += prevNextExtraPadding + } + calculateBounds(index, bounds, padding) + } + + private fun calculateBounds(index: Int, bounds: Rect?, padding: Int) { + val l = (index * sizeStep + padding).toInt() + val t = (radius + padding).toInt() + val r = ((index + 1) * sizeStep - padding).toInt() + val b = (radius * 3 - padding).toInt() + bounds!![l, t, r] = b + } + + private fun updateCollapseAnimation(position: Long) { + if (isBetween(position.toFloat(), 0f, COLLAPSE_ELEMENTS_END_F)) { + val time = 1 - normalize(position.toFloat(), 0f, COLLAPSE_ELEMENTS_END_F) + expandCollapseElements(time) + } + if (position > COLLAPSE_ELEMENTS_END_F) { + for (i in buttonBounds.indices) { + if (i != INDEX_PLAY) { + drawables[i]!!.alpha = 0 + } + } + } + if (isBetween(position.toFloat(), COLLAPSE_POSITION_START_F, COLLAPSE_POSITION_END_F)) { + var time = + normalize(position.toFloat(), COLLAPSE_POSITION_START_F, COLLAPSE_POSITION_END_F) + time = accDecInterpolator.getInterpolation(time) + val playBounds = buttonBounds[INDEX_PLAY] + calculateBounds(INDEX_PLAY, playBounds) + val l: Int + val t: Int + val r: Int + val b: Int + t = playBounds!!.top + b = playBounds.bottom + if (expandDirection == DIRECTION_LEFT) { + calculateBounds(INDEX_ALBUM, tmpRect) + l = enlarge(playBounds.left.toFloat(), tmpRect.left.toFloat(), time).toInt() + r = l + playBounds.width() + } else { + calculateBounds(INDEX_PLAYLIST, tmpRect) + l = reduce(playBounds.left.toFloat(), tmpRect.left.toFloat(), time).toInt() + r = l + playBounds.width() + } + buttonBounds[INDEX_PLAY]!![l, t, r] = b + } + if (isBetween(position.toFloat(), COLLAPSE_SIZE_START_F, COLLAPSE_SIZE_END_F)) { + var time = normalize(position.toFloat(), COLLAPSE_SIZE_START_F, COLLAPSE_SIZE_END_F) + time = accDecInterpolator.getInterpolation(time) + paint.color = colorChanger.nextColor(time) + val l: Float + val r: Float + val b: Float + val height = radius * 2 + val t: Float = radius + b = t + height + if (expandDirection == DIRECTION_LEFT) { + r = widgetWidth + l = r - height - (widgetWidth - height) * (1 - time) + } else { + l = 0f + r = l + height + (widgetWidth - height) * (1 - time) + } + bounds[l, t, r] = b + } + } + + private fun expandCollapseElements(time: Float) { + val alpha = between(time * 255, 0f, 255f).toInt() + for (i in buttonBounds.indices) { + if (i != INDEX_PLAY) { + var padding = buttonPadding + if (i == INDEX_PREV || i == INDEX_NEXT) { + padding += prevNextExtraPadding + } + calculateBounds(i, buttonBounds[i]) + val size = time * (sizeStep / 2f - padding) + val cx = buttonBounds[i]!!.centerX() + val cy = buttonBounds[i]!!.centerY() + buttonBounds[i]!![(cx - size).toInt(), (cy - size).toInt(), (cx + size).toInt()] = + (cy + size).toInt() + drawables[i]!!.alpha = alpha + } + } + } + + fun onClick(x: Float, y: Float) { + if (isAnimationInProgress) return + val index = getTouchedAreaIndex(x.toInt(), y.toInt()) + if (index == INDEX_PLAY || index == INDEX_PREV || index == INDEX_NEXT) { + if (!bubblesTouchAnimator.isRunning) { + randomizeBubblesPosition() + bubblesTouchAnimator.start() + } + } + when (index) { + INDEX_PLAYLIST -> { + if (onControlsClickListener != null) { + onControlsClickListener!!.onPlaylistClicked() + } + } + INDEX_PREV -> { + if (onControlsClickListener != null) { + onControlsClickListener!!.onPreviousClicked() + } + } + INDEX_PLAY -> { + if (onControlsClickListener != null) { + onControlsClickListener!!.onPlayPauseClicked() + } + } + INDEX_NEXT -> { + if (onControlsClickListener != null) { + onControlsClickListener!!.onNextClicked() + } + } + INDEX_ALBUM -> { + if (onControlsClickListener != null) { + onControlsClickListener!!.onAlbumClicked() + } + } + else -> { + Log.w(ExpandCollapseWidget::class.java.simpleName, "Unknown index: $index") + } + } + } + + fun onLongClick(x: Float, y: Float) { + if (isAnimationInProgress) return + when (val index = getTouchedAreaIndex(x.toInt(), y.toInt())) { + INDEX_PLAYLIST -> { + if (onControlsClickListener != null) { + onControlsClickListener!!.onPlaylistLongClicked() + } + } + INDEX_PREV -> { + if (onControlsClickListener != null) { + onControlsClickListener!!.onPreviousLongClicked() + } + } + INDEX_PLAY -> { + if (onControlsClickListener != null) { + onControlsClickListener!!.onPlayPauseLongClicked() + } + } + INDEX_NEXT -> { + if (onControlsClickListener != null) { + onControlsClickListener!!.onNextLongClicked() + } + } + INDEX_ALBUM -> { + if (onControlsClickListener != null) { + onControlsClickListener!!.onAlbumLongClicked() + } + } + else -> { + Log.w(ExpandCollapseWidget::class.java.simpleName, "Unknown index: $index") + } + } + } + + private fun getTouchedAreaIndex(x: Int, y: Int): Int { + var index = -1 + for (i in buttonBounds.indices) { + calculateBounds(i, tmpRect, 0) + if (tmpRect.contains(x, y)) { + index = i + break + } + } + return index + } + + fun expand(expandDirection: Int) { + if (expanded) { + return + } + this.expandDirection = expandDirection + startExpandAnimation() + } + + private fun startExpandAnimation() { + if (isAnimationInProgress) return + animatingExpand = true + if (playbackState.state() == Configuration.STATE_PLAYING) { + colorChanger + .fromColor(playColor) + .toColor(widgetColor) + } else { + colorChanger + .fromColor(pauseColor) + .toColor(widgetColor) + } + randomizeBubblesPosition() + expandAnimator.start() + } + + private fun randomizeBubblesPosition() { + val half = TOTAL_BUBBLES_COUNT / 2 + val step = widgetWidth / half + for (i in 0 until TOTAL_BUBBLES_COUNT) { + val index = i % half + val speed = 0.3f + 0.7f * random.nextFloat() + val size = bubblesMinSize + (bubblesMaxSize - bubblesMinSize) * random.nextFloat() + val radius = size / 2f + val cx = + padding + index * step + step * random.nextFloat() * if (random.nextBoolean()) 1 else -1 + val cy = widgetHeight + padding + bubbleSpeeds[i] = speed + bubbleSizes[i] = radius + bubblePositions[2 * i] = cx + bubblePositions[2 * i + 1] = cy + } + } + + private fun startCollapseAnimation() { + if (isAnimationInProgress) { + return + } + collapseAnimator.start() + } + + val isAnimationInProgress: Boolean + get() = animatingCollapse || animatingExpand + + fun collapse(): Boolean { + if (!expanded) { + return false + } + if (playbackState.state() == Configuration.STATE_PLAYING) { + colorChanger + .fromColor(widgetColor) + .toColor(playColor) + } else { + colorChanger + .fromColor(widgetColor) + .toColor(pauseColor) + } + startCollapseAnimation() + return true + } + + override fun onStateChanged(oldState: Int, newState: Int, initiator: Any?) { + invalidate() + } + + override fun onProgressChanged(position: Int, duration: Int, percentage: Float) {} + + fun onWidgetStateChangedListener( + onWidgetStateChangedListener: OnWidgetStateChangedListener? + ): ExpandCollapseWidget { + this.onWidgetStateChangedListener = onWidgetStateChangedListener + return this + } + + fun expandDirection(): Int { + return expandDirection + } + + fun expandDirection(expandDirection: Int) { + this.expandDirection = expandDirection + } + + fun onControlsClickListener(onControlsClickListener: OnControlsClickListener?) { + this.onControlsClickListener = onControlsClickListener + } + + fun albumCover(albumCover: Drawable?) { + if (drawables[INDEX_ALBUM] === albumCover) return + if (albumCover == null) { + drawables[INDEX_ALBUM] = defaultAlbumCover + } else { + if (albumCover.constantState != null) + drawables[INDEX_ALBUM] = albumCover.constantState!!.newDrawable().mutate() + else + drawables[INDEX_ALBUM] = albumCover + } + invalidate() + } + + fun onTouched(x: Float, y: Float) { + val index = getTouchedAreaIndex(x.toInt(), y.toInt()) + if (index == INDEX_PLAY || index == INDEX_NEXT || index == INDEX_PREV) { + touchedButtonIndex = index + touchDownAnimator.start() + } + } + + fun onReleased(x: Float, y: Float) { + val index = getTouchedAreaIndex(x.toInt(), y.toInt()) + if (index == INDEX_PLAY || index == INDEX_NEXT || index == INDEX_PREV) { + touchedButtonIndex = index + touchUpAnimator.start() + } + } + + fun newBoundsChecker(offsetX: Int, offsetY: Int): BoundsChecker { + return BoundsCheckerImpl(radius, widgetWidth, offsetX, offsetY) + } + + fun setCollapseListener(collapseListener: AnimationProgressListener?) { + this.collapseListener = collapseListener + } + + internal interface AnimationProgressListener { + fun onValueChanged(percent: Float) + } + + private class BoundsCheckerImpl( + private val radius: Float, + private val widgetWidth: Float, + offsetX: Int, + offsetY: Int + ) : BoundsCheckerWithOffset(offsetX, offsetY) { + public override fun stickyLeftSideImpl(screenWidth: Float): Float { + return 0F + } + + public override fun stickyRightSideImpl(screenWidth: Float): Float { + return screenWidth - widgetWidth + } + + public override fun stickyBottomSideImpl(screenHeight: Float): Float { + return screenHeight - 3 * radius + } + + public override fun stickyTopSideImpl(screenHeight: Float): Float { + return -radius + } + } + + companion object { + const val DIRECTION_LEFT = 1 + const val DIRECTION_RIGHT = 2 + private const val EXPAND_DURATION_F = 34 * Configuration.FRAME_SPEED + private const val EXPAND_DURATION_L = EXPAND_DURATION_F.toLong() + private const val EXPAND_COLOR_END_F = 9 * Configuration.FRAME_SPEED + private const val EXPAND_SIZE_END_F = 12 * Configuration.FRAME_SPEED + private const val EXPAND_POSITION_START_F = 10 * Configuration.FRAME_SPEED + private const val EXPAND_POSITION_END_F = 18 * Configuration.FRAME_SPEED + private const val EXPAND_BUBBLES_START_F = 18 * Configuration.FRAME_SPEED + private const val EXPAND_BUBBLES_END_F = 32 * Configuration.FRAME_SPEED + private const val EXPAND_ELEMENTS_START_F = 20 * Configuration.FRAME_SPEED + private const val EXPAND_ELEMENTS_END_F = 27 * Configuration.FRAME_SPEED + private const val COLLAPSE_DURATION_F = 12 * Configuration.FRAME_SPEED + private const val COLLAPSE_DURATION_L = COLLAPSE_DURATION_F.toLong() + private const val COLLAPSE_ELEMENTS_END_F = 3 * Configuration.FRAME_SPEED + private const val COLLAPSE_SIZE_START_F = 2 * Configuration.FRAME_SPEED + private const val COLLAPSE_SIZE_END_F = 12 * Configuration.FRAME_SPEED + private const val COLLAPSE_POSITION_START_F = 3 * Configuration.FRAME_SPEED + private const val COLLAPSE_POSITION_END_F = 12 * Configuration.FRAME_SPEED + private const val INDEX_PLAYLIST = 0 + private const val INDEX_PREV = 1 + private const val INDEX_PLAY = 2 + private const val INDEX_NEXT = 3 + private const val INDEX_ALBUM = 4 + private const val INDEX_PAUSE = 5 + private const val TOTAL_BUBBLES_COUNT = 30 + } + + init { + setLayerType(LAYER_TYPE_SOFTWARE, null) + playbackState = configuration.playbackState + accDecInterpolator = configuration.accDecInterpolator + random = configuration.random + bubblesPaint = Paint() + bubblesPaint.style = Paint.Style.FILL + bubblesPaint.isAntiAlias = true + bubblesPaint.color = configuration.expandedColor + bubblesPaint.alpha = 0 + paint = Paint() + paint.color = configuration.expandedColor + paint.isAntiAlias = true + paint.setShadowLayer( + configuration.shadowRadius, + configuration.shadowDx, + configuration.shadowDy, + configuration.shadowColor + ) + radius = configuration.radius + widgetWidth = configuration.widgetWidth + colorChanger = ColorChanger() + playColor = configuration.darkColor + pauseColor = configuration.lightColor + widgetColor = configuration.expandedColor + buttonPadding = configuration.buttonPadding + prevNextExtraPadding = configuration.prevNextExtraPadding + bubblesMinSize = configuration.bubblesMinSize + bubblesMaxSize = configuration.bubblesMaxSize + tmpRect = Rect() + buttonBounds = arrayOfNulls(5) + drawables = arrayOfNulls(6) + bounds = RectF() + drawables[INDEX_PLAYLIST] = + configuration.playlistDrawable.constantState!!.newDrawable().mutate() + drawables[INDEX_PREV] = configuration.prevDrawable.constantState!!.newDrawable().mutate() + drawables[INDEX_PLAY] = configuration.playDrawable.constantState!!.newDrawable().mutate() + drawables[INDEX_PAUSE] = configuration.pauseDrawable.constantState!!.newDrawable().mutate() + drawables[INDEX_NEXT] = configuration.nextDrawable.constantState!!.newDrawable().mutate() + defaultAlbumCover = configuration.albumDrawable.constantState!!.newDrawable().mutate() + drawables[INDEX_ALBUM] = defaultAlbumCover + sizeStep = widgetWidth / 5f + widgetHeight = radius * 2 + for (i in buttonBounds.indices) { + buttonBounds[i] = Rect() + } + bubbleSizes = FloatArray(TOTAL_BUBBLES_COUNT) + bubbleSpeeds = FloatArray(TOTAL_BUBBLES_COUNT) + bubblePositions = FloatArray(TOTAL_BUBBLES_COUNT * 2) + playbackState.addPlaybackStateListener(this) + expandAnimator = ValueAnimator.ofPropertyValuesHolder( + PropertyValuesHolder.ofFloat("percent", 0f, 1f), + PropertyValuesHolder.ofInt("expandPosition", 0, EXPAND_DURATION_L.toInt()), + PropertyValuesHolder.ofFloat( + "alpha", + 0f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f + ) + ).setDuration(EXPAND_DURATION_L) + val interpolator = LinearInterpolator() + expandAnimator.interpolator = interpolator + expandAnimator.addUpdateListener { animation: ValueAnimator -> + updateExpandAnimation((animation.getAnimatedValue("expandPosition") as Int).toLong()) + alpha = animation.getAnimatedValue("alpha") as Float + invalidate() + expandListener?.onValueChanged(animation.getAnimatedValue("percent") as Float) + } + expandAnimator.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationStart(animation: Animator) { + animatingExpand = true + } + + override fun onAnimationEnd(animation: Animator) { + animatingExpand = false + expanded = true + onWidgetStateChangedListener?.onWidgetStateChanged(State.EXPANDED) + } + + override fun onAnimationCancel(animation: Animator) { + animatingExpand = false + } + }) + collapseAnimator = ValueAnimator.ofPropertyValuesHolder( + PropertyValuesHolder.ofFloat("percent", 0f, 1f), + PropertyValuesHolder.ofInt("expandPosition", 0, COLLAPSE_DURATION_L.toInt()), + PropertyValuesHolder.ofFloat("alpha", 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f, 0f) + ).setDuration(COLLAPSE_DURATION_L) + collapseAnimator.interpolator = interpolator + collapseAnimator.addUpdateListener { animation: ValueAnimator -> + updateCollapseAnimation( + (animation.getAnimatedValue("expandPosition") as Int).toLong() + ) + alpha = animation.getAnimatedValue("alpha") as Float + invalidate() + collapseListener?.onValueChanged(animation.getAnimatedValue("percent") as Float) + } + collapseAnimator.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationStart(animation: Animator) { + animatingCollapse = true + } + + override fun onAnimationEnd(animation: Animator) { + animatingCollapse = false + expanded = false + onWidgetStateChangedListener?.onWidgetStateChanged(State.COLLAPSED) + } + + override fun onAnimationCancel(animation: Animator) { + animatingCollapse = false + } + }) + padding = + configuration.context!!.resources.getDimensionPixelSize(R.dimen.aw_expand_collapse_widget_padding) + val listener = AnimatorUpdateListener { + if (touchedButtonIndex == -1 || touchedButtonIndex >= buttonBounds.size) { + return@AnimatorUpdateListener + } + calculateBounds(touchedButtonIndex, tmpRect) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + invalidate() + return@AnimatorUpdateListener + } + invalidate() + } + touchDownAnimator = + ValueAnimator.ofFloat(1f, 0.9f).setDuration(Configuration.TOUCH_ANIMATION_DURATION) + touchDownAnimator.addUpdateListener(listener) + touchUpAnimator = + ValueAnimator.ofFloat(0.9f, 1f).setDuration(Configuration.TOUCH_ANIMATION_DURATION) + touchUpAnimator.addUpdateListener(listener) + bubblesTouchAnimator = + ValueAnimator.ofFloat(0f, EXPAND_BUBBLES_END_F - EXPAND_BUBBLES_START_F) + .setDuration((EXPAND_BUBBLES_END_F - EXPAND_BUBBLES_START_F).toLong()) + bubblesTouchAnimator.interpolator = interpolator + bubblesTouchAnimator.addUpdateListener { + bubblesTime = it.animatedFraction + bubblesPaint.alpha = + customFunction(bubblesTime, 0f, 0f, 255f, 0.33f, 255f, 0.66f, 0f, 1f).toInt() + invalidate() + } + bubblesTouchAnimator.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + bubblesTime = 0f + } + + override fun onAnimationCancel(animation: Animator) { + bubblesTime = 0f + } + }) + } +} \ No newline at end of file diff --git a/audiowidget/src/main/java/com/cleveroad/audiowidget/PlayPauseButton.java b/audiowidget/src/main/java/com/cleveroad/audiowidget/PlayPauseButton.java deleted file mode 100644 index 7a45808..0000000 --- a/audiowidget/src/main/java/com/cleveroad/audiowidget/PlayPauseButton.java +++ /dev/null @@ -1,408 +0,0 @@ -package com.cleveroad.audiowidget; - -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.animation.ValueAnimator; -import android.annotation.SuppressLint; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.RectF; -import android.graphics.drawable.BitmapDrawable; -import android.graphics.drawable.Drawable; -import android.os.AsyncTask; -import android.view.animation.LinearInterpolator; -import android.widget.ImageView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.palette.graphics.Palette; - -import java.util.HashMap; -import java.util.Map; -import java.util.Random; - -/** - * Collapsed state view. - */ -@SuppressLint({"ViewConstructor", "AppCompatCustomView"}) -class PlayPauseButton extends ImageView implements PlaybackState.PlaybackStateListener { - - private static final float BUBBLES_ANGLE_STEP = 18.0f; - private static final float ANIMATION_TIME_F = 8 * Configuration.FRAME_SPEED; - private static final long ANIMATION_TIME_L = (long) ANIMATION_TIME_F; - private static final float COLOR_ANIMATION_TIME_F = ANIMATION_TIME_F / 4f; - private static final float COLOR_ANIMATION_TIME_START_F = (ANIMATION_TIME_F - COLOR_ANIMATION_TIME_F) / 2; - private static final float COLOR_ANIMATION_TIME_END_F = COLOR_ANIMATION_TIME_START_F + COLOR_ANIMATION_TIME_F; - private static final int TOTAL_BUBBLES_COUNT = (int) (360 / BUBBLES_ANGLE_STEP); - static final long PROGRESS_CHANGES_DURATION = (long) (6 * Configuration.FRAME_SPEED); - private static final long PROGRESS_STEP_DURATION = (long) (3 * Configuration.FRAME_SPEED); - private static final int ALBUM_COVER_PLACEHOLDER_ALPHA = 100; - - private final Paint albumPlaceholderPaint; - private final Paint buttonPaint; - private final Paint bubblesPaint; - private final Paint progressPaint; - private final int pausedColor; - private final int playingColor; - private final float[] bubbleSizes; - private final float[] bubbleSpeeds; - private final float[] bubbleSpeedCoefficients; - private final Random random; - private final ColorChanger colorChanger; - private final Drawable playDrawable; - private final Drawable pauseDrawable; - private final RectF bounds; - private final float radius; - private final PlaybackState playbackState; - private final ValueAnimator touchDownAnimator; - private final ValueAnimator touchUpAnimator; - private final ValueAnimator bubblesAnimator; - private final ValueAnimator progressAnimator; - private final float buttonPadding; - private final float bubblesMinSize; - private final float bubblesMaxSize; - private final Map isNeedToFillAlbumCoverMap = new HashMap<>(); - - private boolean animatingBubbles; - private float randomStartAngle; - private float buttonSize = 1.0f; - private float progress = 0.0f; - private float animatedProgress = 0; - private boolean progressChangesEnabled; - - @Nullable - private Drawable albumCover; - @Nullable - private AsyncTask lastPaletteAsyncTask; - private final float hsvArray[] = new float[3]; - - public PlayPauseButton(@NonNull Configuration configuration) { - super(configuration.context()); - setLayerType(LAYER_TYPE_SOFTWARE, null); - this.playbackState = configuration.playbackState(); - this.random = configuration.random(); - this.buttonPaint = new Paint(); - this.buttonPaint.setColor(configuration.lightColor()); - this.buttonPaint.setStyle(Paint.Style.FILL); - this.buttonPaint.setAntiAlias(true); - this.buttonPaint.setShadowLayer( - configuration.shadowRadius(), - configuration.shadowDx(), - configuration.shadowDy(), - configuration.shadowColor() - ); - this.bubblesMinSize = configuration.bubblesMinSize(); - this.bubblesMaxSize = configuration.bubblesMaxSize(); - this.bubblesPaint = new Paint(); - this.bubblesPaint.setStyle(Paint.Style.FILL); - this.progressPaint = new Paint(); - this.progressPaint.setAntiAlias(true); - this.progressPaint.setStyle(Paint.Style.STROKE); - this.progressPaint.setStrokeWidth(configuration.progressStrokeWidth()); - this.progressPaint.setColor(configuration.progressColor()); - this.albumPlaceholderPaint = new Paint(); - this.albumPlaceholderPaint.setStyle(Paint.Style.FILL); - this.albumPlaceholderPaint.setColor(configuration.lightColor()); - this.albumPlaceholderPaint.setAntiAlias(true); - this.albumPlaceholderPaint.setAlpha(ALBUM_COVER_PLACEHOLDER_ALPHA); - this.pausedColor = configuration.lightColor(); - this.playingColor = configuration.darkColor(); - this.radius = configuration.radius(); - this.buttonPadding = configuration.buttonPadding(); - this.bounds = new RectF(); - this.bubbleSizes = new float[TOTAL_BUBBLES_COUNT]; - this.bubbleSpeeds = new float[TOTAL_BUBBLES_COUNT]; - this.bubbleSpeedCoefficients = new float[TOTAL_BUBBLES_COUNT]; - this.colorChanger = new ColorChanger(); - this.playDrawable = configuration.playDrawable().getConstantState().newDrawable().mutate(); - this.pauseDrawable = configuration.pauseDrawable().getConstantState().newDrawable().mutate(); - this.pauseDrawable.setAlpha(0); - this.playbackState.addPlaybackStateListener(this); - final ValueAnimator.AnimatorUpdateListener listener = animation -> { - buttonSize = (float) animation.getAnimatedValue(); - invalidate(); - }; - this.touchDownAnimator = ValueAnimator.ofFloat(1, 0.9f).setDuration(Configuration.TOUCH_ANIMATION_DURATION); - this.touchDownAnimator.addUpdateListener(listener); - this.touchUpAnimator = ValueAnimator.ofFloat(0.9f, 1).setDuration(Configuration.TOUCH_ANIMATION_DURATION); - this.touchUpAnimator.addUpdateListener(listener); - this.bubblesAnimator = ValueAnimator.ofInt(0, (int)ANIMATION_TIME_L).setDuration(ANIMATION_TIME_L); - this.bubblesAnimator.setInterpolator(new LinearInterpolator()); - this.bubblesAnimator.addUpdateListener(animation -> { - long position = animation.getCurrentPlayTime(); - float fraction = animation.getAnimatedFraction(); - updateBubblesPosition(position, fraction); - invalidate(); - }); - this.bubblesAnimator.addListener(new AnimatorListenerAdapter() { - - @Override - public void onAnimationStart(Animator animation) { - super.onAnimationStart(animation); - animatingBubbles = true; - } - - @Override - public void onAnimationEnd(Animator animation) { - super.onAnimationEnd(animation); - animatingBubbles = false; - } - - @Override - public void onAnimationCancel(Animator animation) { - super.onAnimationCancel(animation); - animatingBubbles = false; - } - }); - this.progressAnimator = new ValueAnimator(); - this.progressAnimator.addUpdateListener(animation -> { - animatedProgress = (float) animation.getAnimatedValue(); - invalidate(); - }); - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - int size = MeasureSpec.makeMeasureSpec((int) (radius * 4), MeasureSpec.EXACTLY); - super.onMeasure(size , size); - } - - private void updateBubblesPosition(long position, float fraction) { - int alpha = (int) DrawableUtils.customFunction(fraction, 0, 0, 0, 0.3f, 255, 0.5f, 225, 0.7f, 0, 1f); - bubblesPaint.setAlpha(alpha); - if (DrawableUtils.isBetween(position, COLOR_ANIMATION_TIME_START_F, COLOR_ANIMATION_TIME_END_F)) { - float colorDt = DrawableUtils.normalize(position, COLOR_ANIMATION_TIME_START_F, COLOR_ANIMATION_TIME_END_F); - buttonPaint.setColor(colorChanger.nextColor(colorDt)); - if (playbackState.state() == Configuration.STATE_PLAYING) { - pauseDrawable.setAlpha((int) DrawableUtils.between(255 * colorDt, 0, 255)); - playDrawable.setAlpha((int) DrawableUtils.between(255 * (1 - colorDt), 0, 255)); - } else { - playDrawable.setAlpha((int) DrawableUtils.between(255 * colorDt, 0, 255)); - pauseDrawable.setAlpha((int) DrawableUtils.between(255 * (1 - colorDt), 0, 255)); - } - } - for (int i=0; i> 1; - float cy = getHeight() >> 1; - canvas.scale(buttonSize, buttonSize, cx, cy); - if (animatingBubbles) { - for (int i = 0; i < TOTAL_BUBBLES_COUNT; i++) { - float angle = randomStartAngle + BUBBLES_ANGLE_STEP * i; - float speed = bubbleSpeeds[i]; - float x = DrawableUtils.rotateX(cx, cy * (1 - speed), cx, cy, angle); - float y = DrawableUtils.rotateY(cx, cy * (1 - speed), cx, cy, angle); - canvas.drawCircle(x, y, bubbleSizes[i], bubblesPaint); - } - } else if (playbackState.state() != Configuration.STATE_PLAYING) { - playDrawable.setAlpha(255); - pauseDrawable.setAlpha(0); - // in case widget was drawn without animation in different state - if (buttonPaint.getColor() != pausedColor) { - buttonPaint.setColor(pausedColor); - } - } else { - playDrawable.setAlpha(0); - pauseDrawable.setAlpha(255); - // in case widget was drawn without animation in different state - if (buttonPaint.getColor() != playingColor) { - buttonPaint.setColor(playingColor); - } - } - - canvas.drawCircle(cx, cy, radius, buttonPaint); - if(albumCover != null) { - canvas.drawCircle(cx, cy, radius, buttonPaint); - albumCover.setBounds((int) (cx - radius), (int) (cy - radius), (int) (cx + radius), (int) (cy + radius)); - albumCover.draw(canvas); - Boolean isNeedToFillAlbumCover = isNeedToFillAlbumCoverMap.get(albumCover.hashCode()); - if(isNeedToFillAlbumCover != null && isNeedToFillAlbumCover) { - canvas.drawCircle(cx, cy, radius, albumPlaceholderPaint); - } - } - - float padding = progressPaint.getStrokeWidth() / 2f; - bounds.set(cx - radius + padding, cy - radius + padding, cx + radius - padding, cy + radius - padding); - canvas.drawArc(bounds, -90, animatedProgress, false, progressPaint); - - int l = (int) (cx - radius + buttonPadding); - int t = (int) (cy - radius + buttonPadding); - int r = (int) (cx + radius - buttonPadding); - int b = (int) (cy + radius - buttonPadding); - if (animatingBubbles || playbackState.state() != Configuration.STATE_PLAYING) { - playDrawable.setBounds(l, t, r, b); - playDrawable.draw(canvas); - } - if (animatingBubbles || playbackState.state() == Configuration.STATE_PLAYING) { - pauseDrawable.setBounds(l, t, r, b); - pauseDrawable.draw(canvas); - } - } - - @Override - public void onStateChanged(int oldState, int newState, Object initiator) { - if (initiator instanceof AudioWidget) - return; - if (newState == Configuration.STATE_PLAYING) { - buttonPaint.setColor(playingColor); - pauseDrawable.setAlpha(255); - playDrawable.setAlpha(0); - } else { - buttonPaint.setColor(pausedColor); - pauseDrawable.setAlpha(0); - playDrawable.setAlpha(255); - } - postInvalidate(); - } - - @Override - public void onProgressChanged(int position, int duration, float percentage) { - if (percentage > progress) { - float old = progress; - post(() -> { - if (animateProgressChanges(old * 360, percentage * 360, PROGRESS_STEP_DURATION)) { - progress = percentage; - } - }); - } else { - this.progress = percentage; - this.animatedProgress = percentage * 360; - postInvalidate(); - } - - } - - public void enableProgressChanges(boolean enable) { - if (progressChangesEnabled == enable) - return; - progressChangesEnabled = enable; - if (progressChangesEnabled) { - animateProgressChangesForce(0, progress * 360, PROGRESS_CHANGES_DURATION); - } else { - animateProgressChangesForce(progress * 360, 0, PROGRESS_CHANGES_DURATION); - } - } - - private void animateProgressChangesForce(float oldValue, float newValue, long duration) { - if (progressAnimator.isRunning()) { - progressAnimator.cancel(); - } - animateProgressChanges(oldValue, newValue, duration); - } - - private boolean animateProgressChanges(float oldValue, float newValue, long duration) { - if (progressAnimator.isRunning()) { - return false; - } - progressAnimator.setFloatValues(oldValue, newValue); - progressAnimator.setDuration(duration); - progressAnimator.start(); - return true; - } - - public TouchManager.BoundsChecker newBoundsChecker(int offsetX, int offsetY) { - return new BoundsCheckerImpl(radius, offsetX, offsetY); - } - - public void albumCover(Drawable newAlbumCover) { - if(this.albumCover == newAlbumCover) return; - this.albumCover = newAlbumCover; - - if(albumCover instanceof BitmapDrawable && !isNeedToFillAlbumCoverMap.containsKey(albumCover.hashCode())) { - Bitmap bitmap = ((BitmapDrawable) albumCover).getBitmap(); - if(bitmap != null && !bitmap.isRecycled()) { - if(lastPaletteAsyncTask != null && !lastPaletteAsyncTask.isCancelled()) { - lastPaletteAsyncTask.cancel(true); - } - lastPaletteAsyncTask = Palette.from(bitmap).generate(palette -> { - int dominantColor = palette.getDominantColor(Integer.MAX_VALUE); - if(dominantColor != Integer.MAX_VALUE) { - Color.colorToHSV(dominantColor, hsvArray); - isNeedToFillAlbumCoverMap.put(albumCover.hashCode(), hsvArray[2] > 0.65f); - postInvalidate(); - } - }); - } - } - postInvalidate(); - } - - private static final class BoundsCheckerImpl extends AudioWidget.BoundsCheckerWithOffset { - - private float radius; - - BoundsCheckerImpl(float radius, int offsetX, int offsetY) { - super(offsetX, offsetY); - this.radius = radius; - } - - @Override - public float stickyLeftSideImpl(float screenWidth) { - return -radius; - } - - @Override - public float stickyRightSideImpl(float screenWidth) { - return screenWidth - radius * 3; - } - - @Override - public float stickyBottomSideImpl(float screenHeight) { - return screenHeight - radius * 3; - } - - @Override - public float stickyTopSideImpl(float screenHeight) { - return -radius; - } - } -} \ No newline at end of file diff --git a/audiowidget/src/main/java/com/cleveroad/audiowidget/PlayPauseButton.kt b/audiowidget/src/main/java/com/cleveroad/audiowidget/PlayPauseButton.kt new file mode 100644 index 0000000..5d139b8 --- /dev/null +++ b/audiowidget/src/main/java/com/cleveroad/audiowidget/PlayPauseButton.kt @@ -0,0 +1,410 @@ +package com.cleveroad.audiowidget + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator +import android.animation.ValueAnimator.AnimatorUpdateListener +import android.annotation.SuppressLint +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.os.AsyncTask +import android.view.animation.LinearInterpolator +import android.widget.ImageView +import androidx.palette.graphics.Palette +import com.cleveroad.audiowidget.AudioWidget.BoundsCheckerWithOffset +import com.cleveroad.audiowidget.DrawableUtils.between +import com.cleveroad.audiowidget.DrawableUtils.customFunction +import com.cleveroad.audiowidget.DrawableUtils.isBetween +import com.cleveroad.audiowidget.DrawableUtils.normalize +import com.cleveroad.audiowidget.DrawableUtils.rotateX +import com.cleveroad.audiowidget.DrawableUtils.rotateY +import com.cleveroad.audiowidget.PlaybackState.PlaybackStateListener +import com.cleveroad.audiowidget.TouchManager.BoundsChecker +import java.util.* + +/** + * Collapsed state view. + */ +@SuppressLint("ViewConstructor", "AppCompatCustomView") +internal class PlayPauseButton( + configuration: Configuration +) : ImageView(configuration.context), PlaybackStateListener { + private val albumPlaceholderPaint: Paint = Paint() + private val buttonPaint: Paint = Paint() + private val bubblesPaint: Paint = Paint() + private val progressPaint: Paint = Paint() + private val pausedColor: Int + private val playingColor: Int + private val bubbleSizes: FloatArray + private val bubbleSpeeds: FloatArray + private val bubbleSpeedCoefficients: FloatArray + private val random: Random + private val colorChanger: ColorChanger + private val playDrawable: Drawable + private val pauseDrawable: Drawable + private val bounds: RectF = RectF() + private val radius: Float + private val playbackState: PlaybackState + private val touchDownAnimator: ValueAnimator + private val touchUpAnimator: ValueAnimator + private val bubblesAnimator: ValueAnimator + private val progressAnimator: ValueAnimator = ValueAnimator() + private val buttonPadding: Int + private val bubblesMinSize: Float + private val bubblesMaxSize: Float + private val isNeedToFillAlbumCoverMap: MutableMap = HashMap() + var isAnimationInProgress = false + private var randomStartAngle = 0f + private var buttonSize = 1.0f + private var progress = 0.0f + private var animatedProgress = 0f + private var progressChangesEnabled = false + private var albumCover: Drawable? = null + private var lastPaletteAsyncTask: AsyncTask<*, *, *>? = null + private val hsvArray = FloatArray(3) + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val size = MeasureSpec.makeMeasureSpec((radius * 4).toInt(), MeasureSpec.EXACTLY) + super.onMeasure(size, size) + } + + private fun updateBubblesPosition(position: Long, fraction: Float) { + val alpha = + customFunction(fraction, 0f, 0f, 0f, 0.3f, 255f, 0.5f, 225f, 0.7f, 0f, 1f).toInt() + bubblesPaint.alpha = alpha + if (isBetween( + position.toFloat(), + COLOR_ANIMATION_TIME_START_F, + COLOR_ANIMATION_TIME_END_F + ) + ) { + val colorDt = normalize( + position.toFloat(), + COLOR_ANIMATION_TIME_START_F, + COLOR_ANIMATION_TIME_END_F + ) + buttonPaint.color = colorChanger.nextColor(colorDt) + if (playbackState.state() == Configuration.STATE_PLAYING) { + pauseDrawable.alpha = between(255 * colorDt, 0f, 255f).toInt() + playDrawable.alpha = between(255 * (1 - colorDt), 0f, 255f).toInt() + } else { + playDrawable.alpha = between(255 * colorDt, 0f, 255f).toInt() + pauseDrawable.alpha = between(255 * (1 - colorDt), 0f, 255f).toInt() + } + } + for (i in 0 until TOTAL_BUBBLES_COUNT) { + bubbleSpeeds[i] = fraction * bubbleSpeedCoefficients[i] + } + } + + fun onClick() { + if (isAnimationInProgress) { + return + } + if (playbackState.state() == Configuration.STATE_PLAYING) { + colorChanger + .fromColor(playingColor) + .toColor(pausedColor) + bubblesPaint.color = pausedColor + } else { + colorChanger + .fromColor(pausedColor) + .toColor(playingColor) + bubblesPaint.color = playingColor + } + startBubblesAnimation() + } + + private fun startBubblesAnimation() { + randomStartAngle = 360 * random.nextFloat() + for (i in 0 until TOTAL_BUBBLES_COUNT) { + val speed = 0.5f + 0.5f * random.nextFloat() + val size = bubblesMinSize + (bubblesMaxSize - bubblesMinSize) * random.nextFloat() + val radius = size / 2f + bubbleSizes[i] = radius + bubbleSpeedCoefficients[i] = speed + } + bubblesAnimator.start() + } + + fun onTouchDown() { + touchDownAnimator.start() + } + + fun onTouchUp() { + touchUpAnimator.start() + } + + public override fun onDraw(canvas: Canvas) { + val cx = (width shr 1.toFloat().toInt()).toFloat() + val cy = (height shr 1.toFloat().toInt()).toFloat() + canvas.scale(buttonSize, buttonSize, cx, cy) + if (isAnimationInProgress) { + for (i in 0 until TOTAL_BUBBLES_COUNT) { + val angle = randomStartAngle + BUBBLES_ANGLE_STEP * i + val speed = bubbleSpeeds[i] + val x = rotateX(cx, cy * (1 - speed), cx, cy, angle) + val y = rotateY(cx, cy * (1 - speed), cx, cy, angle) + canvas.drawCircle(x, y, bubbleSizes[i], bubblesPaint) + } + } else if (playbackState.state() != Configuration.STATE_PLAYING) { + playDrawable.alpha = 255 + pauseDrawable.alpha = 0 + // in case widget was drawn without animation in different state + if (buttonPaint.color != pausedColor) { + buttonPaint.color = pausedColor + } + } else { + playDrawable.alpha = 0 + pauseDrawable.alpha = 255 + // in case widget was drawn without animation in different state + if (buttonPaint.color != playingColor) { + buttonPaint.color = playingColor + } + } + canvas.drawCircle(cx, cy, radius, buttonPaint) + if (albumCover != null) { + canvas.drawCircle(cx, cy, radius, buttonPaint) + albumCover!!.setBounds( + (cx - radius).toInt(), + (cy - radius).toInt(), + (cx + radius).toInt(), + (cy + radius).toInt() + ) + albumCover!!.draw(canvas) + val isNeedToFillAlbumCover = isNeedToFillAlbumCoverMap[albumCover.hashCode()] + if (isNeedToFillAlbumCover != null && isNeedToFillAlbumCover) { + canvas.drawCircle(cx, cy, radius, albumPlaceholderPaint) + } + } + val padding = progressPaint.strokeWidth / 2f + bounds[cx - radius + padding, cy - radius + padding, cx + radius - padding] = + cy + radius - padding + canvas.drawArc(bounds, -90f, animatedProgress, false, progressPaint) + val l = (cx - radius + buttonPadding).toInt() + val t = (cy - radius + buttonPadding).toInt() + val r = (cx + radius - buttonPadding).toInt() + val b = (cy + radius - buttonPadding).toInt() + if (isAnimationInProgress || playbackState.state() != Configuration.STATE_PLAYING) { + playDrawable.setBounds(l, t, r, b) + playDrawable.draw(canvas) + } + if (isAnimationInProgress || playbackState.state() == Configuration.STATE_PLAYING) { + pauseDrawable.setBounds(l, t, r, b) + pauseDrawable.draw(canvas) + } + } + + override fun onStateChanged( + oldState: Int, + newState: Int, + initiator: Any? + ) { + if (initiator is AudioWidget) return + if (newState == Configuration.STATE_PLAYING) { + buttonPaint.color = playingColor + pauseDrawable.alpha = 255 + playDrawable.alpha = 0 + } else { + buttonPaint.color = pausedColor + pauseDrawable.alpha = 0 + playDrawable.alpha = 255 + } + postInvalidate() + } + + override fun onProgressChanged( + position: Int, + duration: Int, + percentage: Float + ) { + if (percentage > progress) { + val old = progress + post { + if (animateProgressChanges(old * 360, percentage * 360, PROGRESS_STEP_DURATION)) { + progress = percentage + } + } + } else { + progress = percentage + animatedProgress = percentage * 360 + postInvalidate() + } + } + + fun enableProgressChanges(enable: Boolean) { + if (progressChangesEnabled == enable) return + progressChangesEnabled = enable + if (progressChangesEnabled) { + animateProgressChangesForce(0f, progress * 360, PROGRESS_CHANGES_DURATION) + } else { + animateProgressChangesForce(progress * 360, 0f, PROGRESS_CHANGES_DURATION) + } + } + + private fun animateProgressChangesForce( + oldValue: Float, + newValue: Float, + duration: Long + ) { + if (progressAnimator.isRunning) { + progressAnimator.cancel() + } + animateProgressChanges(oldValue, newValue, duration) + } + + private fun animateProgressChanges( + oldValue: Float, + newValue: Float, + duration: Long + ): Boolean { + if (progressAnimator.isRunning) { + return false + } + progressAnimator.setFloatValues(oldValue, newValue) + progressAnimator.duration = duration + progressAnimator.start() + return true + } + + fun newBoundsChecker(offsetX: Int, offsetY: Int): BoundsChecker { + return BoundsCheckerImpl(radius, offsetX, offsetY) + } + + fun albumCover(newAlbumCover: Drawable?) { + if (albumCover === newAlbumCover) return + albumCover = newAlbumCover + if (albumCover is BitmapDrawable && !isNeedToFillAlbumCoverMap.containsKey(albumCover.hashCode())) { + val bitmap = (albumCover as BitmapDrawable).bitmap + if (bitmap != null && !bitmap.isRecycled) { + if (lastPaletteAsyncTask != null && !lastPaletteAsyncTask!!.isCancelled) { + lastPaletteAsyncTask!!.cancel(true) + } + lastPaletteAsyncTask = Palette.from(bitmap).generate { palette: Palette? -> + val dominantColor = palette!!.getDominantColor(Int.MAX_VALUE) + if (dominantColor != Int.MAX_VALUE) { + Color.colorToHSV(dominantColor, hsvArray) + isNeedToFillAlbumCoverMap[albumCover.hashCode()] = hsvArray[2] > 0.65f + postInvalidate() + } + } + } + } + postInvalidate() + } + + private class BoundsCheckerImpl( + private val radius: Float, + offsetX: Int, + offsetY: Int + ) : BoundsCheckerWithOffset(offsetX, offsetY) { + public override fun stickyLeftSideImpl(screenWidth: Float): Float { + return -radius + } + + public override fun stickyRightSideImpl(screenWidth: Float): Float { + return screenWidth - radius * 3 + } + + public override fun stickyBottomSideImpl(screenHeight: Float): Float { + return screenHeight - radius * 3 + } + + public override fun stickyTopSideImpl(screenHeight: Float): Float { + return -radius + } + } + + companion object { + private const val BUBBLES_ANGLE_STEP = 18.0f + private const val ANIMATION_TIME_F = 8 * Configuration.FRAME_SPEED + private const val ANIMATION_TIME_L = ANIMATION_TIME_F.toLong() + private const val COLOR_ANIMATION_TIME_F = ANIMATION_TIME_F / 4f + private const val COLOR_ANIMATION_TIME_START_F = + (ANIMATION_TIME_F - COLOR_ANIMATION_TIME_F) / 2 + private const val COLOR_ANIMATION_TIME_END_F = + COLOR_ANIMATION_TIME_START_F + COLOR_ANIMATION_TIME_F + private const val TOTAL_BUBBLES_COUNT = (360 / BUBBLES_ANGLE_STEP).toInt() + const val PROGRESS_CHANGES_DURATION = (6 * Configuration.FRAME_SPEED).toLong() + private const val PROGRESS_STEP_DURATION = (3 * Configuration.FRAME_SPEED).toLong() + private const val ALBUM_COVER_PLACEHOLDER_ALPHA = 100 + } + + init { + setLayerType(LAYER_TYPE_SOFTWARE, null) + playbackState = configuration.playbackState + random = configuration.random + buttonPaint.color = configuration.lightColor + buttonPaint.style = Paint.Style.FILL + buttonPaint.isAntiAlias = true + buttonPaint.setShadowLayer( + configuration.shadowRadius, + configuration.shadowDx, + configuration.shadowDy, + configuration.shadowColor + ) + bubblesMinSize = configuration.bubblesMinSize + bubblesMaxSize = configuration.bubblesMaxSize + bubblesPaint.style = Paint.Style.FILL + progressPaint.isAntiAlias = true + progressPaint.style = Paint.Style.STROKE + progressPaint.strokeWidth = configuration.progressStrokeWidth + progressPaint.color = configuration.progressColor + albumPlaceholderPaint.style = Paint.Style.FILL + albumPlaceholderPaint.color = configuration.lightColor + albumPlaceholderPaint.isAntiAlias = true + albumPlaceholderPaint.alpha = ALBUM_COVER_PLACEHOLDER_ALPHA + pausedColor = configuration.lightColor + playingColor = configuration.darkColor + radius = configuration.radius + buttonPadding = configuration.buttonPadding + bubbleSizes = FloatArray(TOTAL_BUBBLES_COUNT) + bubbleSpeeds = FloatArray(TOTAL_BUBBLES_COUNT) + bubbleSpeedCoefficients = FloatArray(TOTAL_BUBBLES_COUNT) + colorChanger = ColorChanger() + playDrawable = configuration.playDrawable.constantState!!.newDrawable().mutate() + pauseDrawable = configuration.pauseDrawable.constantState!!.newDrawable().mutate() + pauseDrawable.alpha = 0 + playbackState.addPlaybackStateListener(this) + val listener = AnimatorUpdateListener { animation: ValueAnimator -> + buttonSize = animation.animatedValue as Float + invalidate() + } + touchDownAnimator = + ValueAnimator.ofFloat(1f, 0.9f).setDuration(Configuration.TOUCH_ANIMATION_DURATION) + touchDownAnimator.addUpdateListener(listener) + touchUpAnimator = + ValueAnimator.ofFloat(0.9f, 1f).setDuration(Configuration.TOUCH_ANIMATION_DURATION) + touchUpAnimator.addUpdateListener(listener) + bubblesAnimator = + ValueAnimator.ofInt(0, ANIMATION_TIME_L.toInt()).setDuration(ANIMATION_TIME_L) + bubblesAnimator.interpolator = LinearInterpolator() + bubblesAnimator.addUpdateListener { animation: ValueAnimator -> + val position = animation.currentPlayTime + val fraction = animation.animatedFraction + updateBubblesPosition(position, fraction) + invalidate() + } + bubblesAnimator.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationStart(animation: Animator) { + isAnimationInProgress = true + } + + override fun onAnimationEnd(animation: Animator) { + isAnimationInProgress = false + } + + override fun onAnimationCancel(animation: Animator) { + isAnimationInProgress = false + } + }) + progressAnimator.addUpdateListener { animation: ValueAnimator -> + animatedProgress = animation.animatedValue as Float + invalidate() + } + } +} \ No newline at end of file diff --git a/audiowidget/src/main/java/com/cleveroad/audiowidget/PlaybackState.java b/audiowidget/src/main/java/com/cleveroad/audiowidget/PlaybackState.java deleted file mode 100644 index 8e7d6e4..0000000 --- a/audiowidget/src/main/java/com/cleveroad/audiowidget/PlaybackState.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.cleveroad.audiowidget; - -import androidx.annotation.NonNull; - -import java.util.HashSet; -import java.util.Set; - -/** - * Helper class for managing playback state. - */ -class PlaybackState { - - private int state = Configuration.STATE_STOPPED; - - private int position; - private int duration; - - private final Set stateListeners; - - PlaybackState() { - stateListeners = new HashSet<>(); - } - - boolean addPlaybackStateListener(@NonNull PlaybackStateListener playbackStateListener) { - return stateListeners.add(playbackStateListener); - } - - public boolean removePlaybackStateListener(@NonNull PlaybackStateListener playbackStateListener) { - return stateListeners.remove(playbackStateListener); - } - - public int state() { - return state; - } - - public int position() { - return position; - } - - public int duration() { - return duration; - } - - public PlaybackState position(int position) { - this.position = position; - notifyProgressChanged(position); - return this; - } - - public PlaybackState duration(int duration) { - this.duration = duration; - return this; - } - - public void start(Object initiator) { - state(Configuration.STATE_PLAYING, initiator); - } - - void pause(Object initiator) { - state(Configuration.STATE_PAUSED, initiator); - } - - void stop(Object initiator) { - state(Configuration.STATE_STOPPED, initiator); - position(0); - } - - private void state(int state, Object initiator) { - if (this.state == state) - return; - int oldState = this.state; - this.state = state; - for (PlaybackStateListener listener : stateListeners) { - listener.onStateChanged(oldState, state, initiator); - } - } - - private void notifyProgressChanged(int position) { - float progress = 1f * position / duration; - for (PlaybackStateListener listener : stateListeners) { - listener.onProgressChanged(position, duration, progress); - } - } - - /** - * Playback state listener. - */ - interface PlaybackStateListener { - - /** - * Called when playback state is changed. - * @param oldState old playback state - * @param newState new playback state - * @param initiator who initiate changes - */ - void onStateChanged(int oldState, int newState, Object initiator); - - /** - * Called when playback progress changed. - * @param position current position of track - * @param duration duration of track - * @param percentage value equals to {@code position / duration} - */ - void onProgressChanged(int position, int duration, float percentage); - } -} diff --git a/audiowidget/src/main/java/com/cleveroad/audiowidget/PlaybackState.kt b/audiowidget/src/main/java/com/cleveroad/audiowidget/PlaybackState.kt new file mode 100644 index 0000000..f2e2c98 --- /dev/null +++ b/audiowidget/src/main/java/com/cleveroad/audiowidget/PlaybackState.kt @@ -0,0 +1,94 @@ +package com.cleveroad.audiowidget + +import java.util.* + +/** + * Helper class for managing playback state. + */ +internal class PlaybackState { + private var state = Configuration.STATE_STOPPED + private var position = 0 + private var duration = 0 + private val stateListeners: MutableSet = HashSet() + + fun addPlaybackStateListener(playbackStateListener: PlaybackStateListener): Boolean { + return stateListeners.add(playbackStateListener) + } + + fun removePlaybackStateListener(playbackStateListener: PlaybackStateListener): Boolean { + return stateListeners.remove(playbackStateListener) + } + + fun state(): Int { + return state + } + + fun position(): Int { + return position + } + + fun duration(): Int { + return duration + } + + fun position(position: Int): PlaybackState { + this.position = position + notifyProgressChanged(position) + return this + } + + fun duration(duration: Int): PlaybackState { + this.duration = duration + return this + } + + fun start(initiator: Any) { + state(Configuration.STATE_PLAYING, initiator) + } + + fun pause(initiator: Any) { + state(Configuration.STATE_PAUSED, initiator) + } + + fun stop(initiator: Any) { + state(Configuration.STATE_STOPPED, initiator) + position(0) + } + + private fun state(state: Int, initiator: Any) { + if (this.state == state) return + val oldState = this.state + this.state = state + for (listener in stateListeners) { + listener.onStateChanged(oldState, state, initiator) + } + } + + private fun notifyProgressChanged(position: Int) { + val progress = 1f * position / duration + for (listener in stateListeners) { + listener.onProgressChanged(position, duration, progress) + } + } + + /** + * Playback state listener. + */ + internal interface PlaybackStateListener { + /** + * Called when playback state is changed. + * @param oldState old playback state + * @param newState new playback state + * @param initiator who initiate changes + */ + fun onStateChanged(oldState: Int, newState: Int, initiator: Any?) + + /** + * Called when playback progress changed. + * @param position current position of track + * @param duration duration of track + * @param percentage value equals to `position / duration` + */ + fun onProgressChanged(position: Int, duration: Int, percentage: Float) + } +} \ No newline at end of file diff --git a/audiowidget/src/main/java/com/cleveroad/audiowidget/RemoveWidgetView.java b/audiowidget/src/main/java/com/cleveroad/audiowidget/RemoveWidgetView.java deleted file mode 100644 index 42216ad..0000000 --- a/audiowidget/src/main/java/com/cleveroad/audiowidget/RemoveWidgetView.java +++ /dev/null @@ -1,101 +0,0 @@ -package com.cleveroad.audiowidget; - -import android.animation.ValueAnimator; -import android.annotation.SuppressLint; -import android.graphics.Canvas; -import android.graphics.Paint; -import android.view.View; - -import androidx.annotation.NonNull; - -/** - * Remove widget view. - */ -@SuppressLint("ViewConstructor") -class RemoveWidgetView extends View { - - static final float SCALE_DEFAULT = 1.0f; - static final float SCALE_LARGE = 1.5f; - - private final float size; - private final float radius; - private final Paint paint; - private final int defaultColor; - private final int overlappedColor; - private final ValueAnimator sizeAnimator; - private float scale = 1.0f; - - public RemoveWidgetView(@NonNull Configuration configuration) { - super(configuration.context()); - this.radius = configuration.radius(); - this.size = configuration.radius() * SCALE_LARGE * 2; - this.paint = new Paint(); - this.defaultColor = configuration.crossColor(); - this.overlappedColor = configuration.crossOverlappedColor(); - paint.setAntiAlias(true); - paint.setStyle(Paint.Style.STROKE); - paint.setStrokeWidth(configuration.crossStrokeWidth()); - paint.setColor(configuration.crossColor()); - paint.setStrokeCap(Paint.Cap.ROUND); - sizeAnimator = new ValueAnimator(); - sizeAnimator.addUpdateListener(animation -> { - scale = (float) animation.getAnimatedValue(); - invalidate(); - }); - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - int size = MeasureSpec.makeMeasureSpec((int) (this.size), MeasureSpec.EXACTLY); - super.onMeasure(size, size); - } - - @Override - protected void onDraw(Canvas canvas) { - super.onDraw(canvas); - int cx = canvas.getWidth() >> 1; - int cy = canvas.getHeight() >> 1; - float rad = radius * 0.75f; - canvas.save(); - canvas.scale(scale, scale, cx, cy); - canvas.drawCircle(cx, cy, rad, paint); - drawCross(canvas, cx, cy, rad * 0.5f, 45); - canvas.restore(); - } - - private void drawCross(@NonNull Canvas canvas, float cx, float cy, float radius, float startAngle) { - drawLine(canvas, cx, cy, radius, startAngle); - drawLine(canvas, cx, cy, radius, startAngle + 90); - } - - private void drawLine(@NonNull Canvas canvas, float cx, float cy, float radius, float angle) { - float x1 = DrawableUtils.rotateX(cx, cy + radius, cx, cy, angle); - float y1 = DrawableUtils.rotateY(cx, cy + radius, cx, cy, angle); - angle += 180; - float x2 = DrawableUtils.rotateX(cx, cy + radius, cx, cy, angle); - float y2 = DrawableUtils.rotateY(cx, cy + radius, cx, cy, angle); - canvas.drawLine(x1, y1, x2, y2, paint); - } - - /** - * Set overlapped state. - * @param overlapped true if widget overlapped, false otherwise - */ - public void setOverlapped(boolean overlapped) { - sizeAnimator.cancel(); - if (overlapped) { - sizeAnimator.setFloatValues(scale, SCALE_LARGE); - if (paint.getColor() != overlappedColor) { - paint.setColor(overlappedColor); - invalidate(); - } - } else { - sizeAnimator.setFloatValues(scale, SCALE_DEFAULT); - if (paint.getColor() != defaultColor) { - paint.setColor(defaultColor); - invalidate(); - } - } - sizeAnimator.start(); - } -} diff --git a/audiowidget/src/main/java/com/cleveroad/audiowidget/RemoveWidgetView.kt b/audiowidget/src/main/java/com/cleveroad/audiowidget/RemoveWidgetView.kt new file mode 100644 index 0000000..f56419f --- /dev/null +++ b/audiowidget/src/main/java/com/cleveroad/audiowidget/RemoveWidgetView.kt @@ -0,0 +1,113 @@ +package com.cleveroad.audiowidget + +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.graphics.Canvas +import android.graphics.Paint +import android.view.View +import com.cleveroad.audiowidget.DrawableUtils.rotateX +import com.cleveroad.audiowidget.DrawableUtils.rotateY + +/** + * Remove widget view. + */ +@SuppressLint("ViewConstructor") +internal class RemoveWidgetView(configuration: Configuration) : View(configuration.context) { + private val size: Float = configuration.radius * SCALE_LARGE * 2 + private val radius: Float = configuration.radius + private val defaultColor: Int = configuration.crossColor + private val overlappedColor: Int = configuration.crossOverlappedColor + private val sizeAnimator: ValueAnimator = ValueAnimator() + private var scale = 1.0f + + private val paint: Paint by lazy { + val p = Paint() + p.isAntiAlias = true + p.style = Paint.Style.STROKE + p.strokeWidth = configuration.crossStrokeWidth + p.color = configuration.crossColor + p.strokeCap = Paint.Cap.ROUND + p + } + + override fun onMeasure( + widthMeasureSpec: Int, + heightMeasureSpec: Int + ) { + val size = MeasureSpec.makeMeasureSpec(size.toInt(), MeasureSpec.EXACTLY) + super.onMeasure(size, size) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val cx = canvas.width shr 1 + val cy = canvas.height shr 1 + val rad = radius * 0.75f + canvas.save() + canvas.scale(scale, scale, cx.toFloat(), cy.toFloat()) + canvas.drawCircle(cx.toFloat(), cy.toFloat(), rad, paint) + drawCross(canvas, cx.toFloat(), cy.toFloat(), rad * 0.5f, 45f) + canvas.restore() + } + + private fun drawCross( + canvas: Canvas, + cx: Float, + cy: Float, + radius: Float, + startAngle: Float + ) { + drawLine(canvas, cx, cy, radius, startAngle) + drawLine(canvas, cx, cy, radius, startAngle + 90) + } + + private fun drawLine( + canvas: Canvas, + cx: Float, + cy: Float, + radius: Float, + angle: Float + ) { + var angle = angle + val x1 = rotateX(cx, cy + radius, cx, cy, angle) + val y1 = rotateY(cx, cy + radius, cx, cy, angle) + angle += 180f + val x2 = rotateX(cx, cy + radius, cx, cy, angle) + val y2 = rotateY(cx, cy + radius, cx, cy, angle) + canvas.drawLine(x1, y1, x2, y2, paint) + } + + /** + * Set overlapped state. + * @param overlapped true if widget overlapped, false otherwise + */ + fun setOverlapped(overlapped: Boolean) { + sizeAnimator.cancel() + if (overlapped) { + sizeAnimator.setFloatValues(scale, SCALE_LARGE) + if (paint.color != overlappedColor) { + paint.color = overlappedColor + invalidate() + } + } else { + sizeAnimator.setFloatValues(scale, SCALE_DEFAULT) + if (paint.color != defaultColor) { + paint.color = defaultColor + invalidate() + } + } + sizeAnimator.start() + } + + init { + sizeAnimator.addUpdateListener { animation: ValueAnimator -> + scale = animation.animatedValue as Float + invalidate() + } + } + + companion object { + const val SCALE_DEFAULT = 1.0f + const val SCALE_LARGE = 1.5f + } +} \ No newline at end of file diff --git a/audiowidget/src/main/java/com/cleveroad/audiowidget/TouchManager.java b/audiowidget/src/main/java/com/cleveroad/audiowidget/TouchManager.java deleted file mode 100644 index 927b015..0000000 --- a/audiowidget/src/main/java/com/cleveroad/audiowidget/TouchManager.java +++ /dev/null @@ -1,521 +0,0 @@ -package com.cleveroad.audiowidget; - -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.animation.FloatEvaluator; -import android.animation.IntEvaluator; -import android.animation.PropertyValuesHolder; -import android.animation.ValueAnimator; -import android.content.Context; -import android.os.SystemClock; -import android.view.GestureDetector; -import android.view.MotionEvent; -import android.view.View; -import android.view.WindowManager; -import android.view.animation.DecelerateInterpolator; -import android.view.animation.Interpolator; -import android.view.animation.OvershootInterpolator; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -/** - * Touch detector for views. - */ -class TouchManager implements View.OnTouchListener { - - private final View view; - private final BoundsChecker boundsChecker; - private final WindowManager windowManager; - private final StickyEdgeAnimator stickyEdgeAnimator; - private final FlingGestureAnimator velocityAnimator; - - private GestureListener gestureListener; - private GestureDetector gestureDetector; - private Callback callback; - private int screenWidth; - private int screenHeight; - private Float lastRawX, lastRawY; - private boolean touchCanceled; - - TouchManager(@NonNull View view, @NonNull BoundsChecker boundsChecker) { - this.gestureDetector = new GestureDetector(view.getContext(), gestureListener = new GestureListener()); - gestureDetector.setIsLongpressEnabled(true); - this.view = view; - this.boundsChecker = boundsChecker; - this.view.setOnTouchListener(this); - Context context = view.getContext().getApplicationContext(); - this.windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); - this.screenWidth = context.getResources().getDisplayMetrics().widthPixels; - this.screenHeight = context.getResources().getDisplayMetrics().heightPixels - context.getResources().getDimensionPixelSize(R.dimen.aw_status_bar_height); - stickyEdgeAnimator = new StickyEdgeAnimator(); - velocityAnimator = new FlingGestureAnimator(); - } - - TouchManager screenWidth(int screenWidth) { - this.screenWidth = screenWidth; - return this; - } - - TouchManager screenHeight(int screenHeight) { - this.screenHeight = screenHeight; - return this; - } - - TouchManager callback(Callback callback) { - this.callback = callback; - return this; - } - - @Override - public boolean onTouch(@NonNull View v, @NonNull MotionEvent event) { - boolean res = (!touchCanceled || event.getAction() == MotionEvent.ACTION_UP) && gestureDetector.onTouchEvent(event); - if (event.getAction() == MotionEvent.ACTION_DOWN) { - touchCanceled = false; - gestureListener.onDown(event); - } else if (event.getAction() == MotionEvent.ACTION_UP) { - if (!touchCanceled) { - gestureListener.onUpEvent(event); - } - } else if (event.getAction() == MotionEvent.ACTION_MOVE) { - if (!touchCanceled) { - gestureListener.onMove(event); - } - } else if (event.getAction() == MotionEvent.ACTION_OUTSIDE) { - gestureListener.onTouchOutsideEvent(event); - touchCanceled = false; - } else if (event.getAction() == MotionEvent.ACTION_CANCEL) { - touchCanceled = true; - } - return res; - } - - /** - * Touch manager callback. - */ - interface Callback { - - /** - * Called when user clicks on view. - * @param x click x coordinate - * @param y click y coordinate - */ - void onClick(float x, float y); - - /** - * Called when user long clicks on view. - * @param x click x coordinate - * @param y click y coordinate - */ - void onLongClick(float x, float y); - - /** - * Called when user touches screen outside view's bounds. - */ - void onTouchOutside(); - - /** - * Called when user touches widget but not removed finger from it. - * @param x x coordinate - * @param y y coordinate - */ - void onTouched(float x, float y); - - /** - * Called when user drags widget. - * @param diffX movement by X axis - * @param diffY movement by Y axis - */ - void onMoved(float diffX, float diffY); - - /** - * Called when user releases finger from widget. - * @param x x coordinate - * @param y y coordinate - */ - void onReleased(float x, float y); - - /** - * Called when sticky edge animation completed. - */ - void onAnimationCompleted(); - } - - static class SimpleCallback implements Callback { - - @Override - public void onClick(float x, float y) { - - } - - @Override - public void onLongClick(float x, float y) { - - } - - @Override - public void onTouchOutside() { - - } - - @Override - public void onTouched(float x, float y) { - - } - - @Override - public void onMoved(float diffX, float diffY) { - - } - - @Override - public void onReleased(float x, float y) { - - } - - @Override - public void onAnimationCompleted() { - - } - - } - - /** - * Interface that return sticky bounds for widget. - */ - interface BoundsChecker { - - /** - * Get sticky left position. - * @param screenWidth screen width - * @return sticky left position - */ - float stickyLeftSide(float screenWidth); - - /** - * Get sticky right position. - * @param screenWidth screen width - * @return sticky right position - */ - float stickyRightSide(float screenWidth); - - /** - * Get sticky top position. - * @param screenHeight screen height - * @return sticky top position - */ - float stickyTopSide(float screenHeight); - - /** - * Get sticky bottom position. - * @param screenHeight screen height - * @return sticky bottom position - */ - float stickyBottomSide(float screenHeight); - } - - /** - * View's gesture listener. - */ - private class GestureListener extends GestureDetector.SimpleOnGestureListener { - - private int prevX, prevY; - private float velX, velY; - private long lastEventTime; - - @Override - public boolean onDown(MotionEvent e) { - WindowManager.LayoutParams params = (WindowManager.LayoutParams) view.getLayoutParams(); - prevX = params.x; - prevY = params.y; - boolean result = !stickyEdgeAnimator.isAnimating(); - if (result) { - if (callback != null) { - callback.onTouched(e.getX(), e.getY()); - } - } - return result; - } - - @Override - public boolean onSingleTapUp(MotionEvent e) { - if (callback != null) { - callback.onClick(e.getX(), e.getY()); - } - return true; - } - - @Override - public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { - float diffX = e2.getRawX() - e1.getRawX(); - float diffY = e2.getRawY() - e1.getRawY(); - float l = prevX + diffX; - float t = prevY + diffY; - WindowManager.LayoutParams params = (WindowManager.LayoutParams) view.getLayoutParams(); - params.x = (int) l; - params.y = (int) t; - try { - windowManager.updateViewLayout(view, params); - } catch (IllegalArgumentException e) { - // view not attached to window - } - if (callback != null) { - callback.onMoved(distanceX, distanceY); - } - return true; - } - - @Override - public void onLongPress(MotionEvent e) { - if (callback != null) { - callback.onLongClick(e.getX(), e.getY()); - } - long downTime = SystemClock.uptimeMillis(); - long eventTime = SystemClock.uptimeMillis() + 100; - float x = 0.0f; - float y = 0.0f; - int metaState = 0; - MotionEvent event = MotionEvent.obtain( - downTime, - eventTime, - MotionEvent.ACTION_CANCEL, - x, - y, - metaState - ); - view.dispatchTouchEvent(event); -// onUpEvent(e); - } - - @Override - public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { - velocityAnimator.animate(velX, velY); - return true; - } - - private void onMove(MotionEvent e2) { - if (lastRawX != null && lastRawY != null) { - long diff = e2.getEventTime() - lastEventTime; - float dt = diff == 0 ? 0 : 1000f / diff; - float newVelX = (e2.getRawX() - lastRawX) * dt; - float newVelY = (e2.getRawY() - lastRawY) * dt; - velX = DrawableUtils.smooth(velX, newVelX, 0.2f); - velY = DrawableUtils.smooth(velY, newVelY, 0.2f); - } - lastRawX = e2.getRawX(); - lastRawY = e2.getRawY(); - lastEventTime = e2.getEventTime(); - } - - private void onUpEvent(MotionEvent e) { - if (callback != null) { - callback.onReleased(e.getX(), e.getY()); - } - lastRawX = null; - lastRawY = null; - lastEventTime = 0; - velX = velY = 0; - if (!velocityAnimator.isAnimating()) { - stickyEdgeAnimator.animate(boundsChecker); - } - } - - private void onTouchOutsideEvent(MotionEvent e) { - if (callback != null) { - callback.onTouchOutside(); - } - } - } - - /** - * Helper class for animating fling gesture. - */ - private class FlingGestureAnimator { - private static final long DEFAULT_ANIM_DURATION = 200; - private final ValueAnimator flingGestureAnimator; - private final PropertyValuesHolder dxHolder; - private final PropertyValuesHolder dyHolder; - private final Interpolator interpolator; - private WindowManager.LayoutParams params; - - FlingGestureAnimator() { - interpolator = new DecelerateInterpolator(); - dxHolder = PropertyValuesHolder.ofFloat("x", 0, 0); - dyHolder = PropertyValuesHolder.ofFloat("y", 0, 0); - dxHolder.setEvaluator(new FloatEvaluator()); - dyHolder.setEvaluator(new FloatEvaluator()); - flingGestureAnimator = ValueAnimator.ofPropertyValuesHolder(dxHolder, dyHolder); - flingGestureAnimator.setInterpolator(interpolator); - flingGestureAnimator.setDuration(DEFAULT_ANIM_DURATION); - flingGestureAnimator.addUpdateListener(animation -> { - float newX = (float) animation.getAnimatedValue("x"); - float newY = (float) animation.getAnimatedValue("y"); - if (callback != null) { - callback.onMoved(newX - params.x, newY - params.y); - } - params.x = (int) newX; - params.y = (int) newY; - - try { - windowManager.updateViewLayout(view, params); - } catch (IllegalArgumentException e) { - animation.cancel(); - } - }); - flingGestureAnimator.addListener(new AnimatorListenerAdapter() { - - @Override - public void onAnimationEnd(Animator animation) { - super.onAnimationEnd(animation); - stickyEdgeAnimator.animate(boundsChecker); - } - - @Override - public void onAnimationCancel(Animator animation) { - super.onAnimationCancel(animation); - stickyEdgeAnimator.animate(boundsChecker); - } - }); - } - - void animate(float velocityX, float velocityY) { - if (isAnimating()) { - return; - } - - params = (WindowManager.LayoutParams) view.getLayoutParams(); - - float dx = velocityX / 1000f * DEFAULT_ANIM_DURATION; - float dy = velocityY / 1000f * DEFAULT_ANIM_DURATION; - - final float newX, newY; - - if(dx + params.x > screenWidth / 2f) { - newX = boundsChecker.stickyRightSide(screenWidth) + Math.min(view.getWidth(), view.getHeight()) / 2f; - } else { - newX = boundsChecker.stickyLeftSide(screenWidth) - Math.min(view.getWidth(), view.getHeight()) / 2f; - } - - newY = params.y + dy; - - dxHolder.setFloatValues(params.x, newX); - dyHolder.setFloatValues(params.y, newY); - - flingGestureAnimator.start(); - } - - boolean isAnimating() { - return flingGestureAnimator.isRunning(); - } - } - - /** - * Helper class for animating sticking to screen edge. - */ - private class StickyEdgeAnimator { - private static final long DEFAULT_ANIM_DURATION = 300; - private final PropertyValuesHolder dxHolder; - private final PropertyValuesHolder dyHolder; - private final ValueAnimator edgeAnimator; - private final Interpolator interpolator; - private WindowManager.LayoutParams params; - - public StickyEdgeAnimator() { - interpolator = new OvershootInterpolator(); - dxHolder = PropertyValuesHolder.ofInt("x", 0, 0); - dyHolder = PropertyValuesHolder.ofInt("y", 0, 0); - dxHolder.setEvaluator(new IntEvaluator()); - dyHolder.setEvaluator(new IntEvaluator()); - edgeAnimator = ValueAnimator.ofPropertyValuesHolder(dxHolder, dyHolder); - edgeAnimator.setInterpolator(interpolator); - edgeAnimator.setDuration(DEFAULT_ANIM_DURATION); - edgeAnimator.addUpdateListener(animation -> { - int x = (int) animation.getAnimatedValue("x"); - int y = (int) animation.getAnimatedValue("y"); - if (callback != null) { - callback.onMoved(x - params.x, y - params.y); - } - params.x = x; - params.y = y; - try { - windowManager.updateViewLayout(view, params); - } catch (IllegalArgumentException e) { - // view not attached to window - animation.cancel(); - } - }); - edgeAnimator.addListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationEnd(Animator animation) { - super.onAnimationEnd(animation); - if (callback != null) { - callback.onAnimationCompleted(); - } - } - }); - } - - private void animate(BoundsChecker boundsChecker) { - animate(boundsChecker, null); - } - - public void animate(BoundsChecker boundsChecker, @Nullable Runnable afterAnimation) { - if (edgeAnimator.isRunning()) { - return; - } - params = (WindowManager.LayoutParams) view.getLayoutParams(); - float cx = params.x + view.getWidth() / 2f; - float cy = params.y + view.getWidth() / 2f; - int x; - if (cx < screenWidth / 2f) { - x = (int) boundsChecker.stickyLeftSide(screenWidth); - } else { - x = (int) boundsChecker.stickyRightSide(screenWidth); - } - int y = params.y; - int top = (int) boundsChecker.stickyTopSide(screenHeight); - int bottom = (int) boundsChecker.stickyBottomSide(screenHeight); - if (params.y > bottom || params.y < top) { - if (cy < screenHeight / 2f) { - y = top; - } else { - y = bottom; - } - } - dxHolder.setIntValues(params.x, x); - dyHolder.setIntValues(params.y, y); - edgeAnimator.addListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationCancel(Animator animation) { - super.onAnimationCancel(animation); - edgeAnimator.removeListener(this); - if (afterAnimation != null) { - afterAnimation.run(); - } - } - - @Override - public void onAnimationEnd(Animator animation) { - super.onAnimationEnd(animation); - edgeAnimator.removeListener(this); - if (afterAnimation != null) { - afterAnimation.run(); - } - } - }); - edgeAnimator.start(); - } - - public boolean isAnimating() { - return edgeAnimator.isRunning(); - } - } - - void animateToBounds(BoundsChecker boundsChecker, @Nullable Runnable afterAnimation) { - stickyEdgeAnimator.animate(boundsChecker, afterAnimation); - } - - void animateToBounds() { - stickyEdgeAnimator.animate(boundsChecker, null); - } -} diff --git a/audiowidget/src/main/java/com/cleveroad/audiowidget/TouchManager.kt b/audiowidget/src/main/java/com/cleveroad/audiowidget/TouchManager.kt new file mode 100644 index 0000000..0cc0171 --- /dev/null +++ b/audiowidget/src/main/java/com/cleveroad/audiowidget/TouchManager.kt @@ -0,0 +1,474 @@ +package com.cleveroad.audiowidget + +import android.animation.* +import android.content.Context +import android.os.SystemClock +import android.view.GestureDetector +import android.view.GestureDetector.SimpleOnGestureListener +import android.view.MotionEvent +import android.view.View +import android.view.View.OnTouchListener +import android.view.WindowManager +import android.view.animation.DecelerateInterpolator +import android.view.animation.Interpolator +import android.view.animation.OvershootInterpolator +import com.cleveroad.audiowidget.DrawableUtils.smooth + +/** + * Touch detector for views. + */ +internal class TouchManager(view: View, boundsChecker: BoundsChecker) : + OnTouchListener { + private val view: View + private val boundsChecker: BoundsChecker + private val windowManager: WindowManager + private val stickyEdgeAnimator: StickyEdgeAnimator + private val velocityAnimator: FlingGestureAnimator + private var gestureListener: GestureListener? = null + private val gestureDetector: GestureDetector + private var callback: Callback? = null + private var screenWidth: Int + private var screenHeight: Int + private var lastRawX: Float? = null + private var lastRawY: Float? = null + private var touchCanceled = false + fun screenWidth(screenWidth: Int): TouchManager { + this.screenWidth = screenWidth + return this + } + + fun screenHeight(screenHeight: Int): TouchManager { + this.screenHeight = screenHeight + return this + } + + fun callback(callback: Callback?): TouchManager { + this.callback = callback + return this + } + + override fun onTouch(v: View, event: MotionEvent): Boolean { + val res = + (!touchCanceled || event.action == MotionEvent.ACTION_UP) && gestureDetector.onTouchEvent( + event + ) + if (event.action == MotionEvent.ACTION_DOWN) { + touchCanceled = false + gestureListener!!.onDown(event) + } else if (event.action == MotionEvent.ACTION_UP) { + if (!touchCanceled) { + gestureListener!!.onUpEvent(event) + } + } else if (event.action == MotionEvent.ACTION_MOVE) { + if (!touchCanceled) { + gestureListener!!.onMove(event) + } + } else if (event.action == MotionEvent.ACTION_OUTSIDE) { + gestureListener!!.onTouchOutsideEvent(event) + touchCanceled = false + } else if (event.action == MotionEvent.ACTION_CANCEL) { + touchCanceled = true + } + return res + } + + /** + * Touch manager callback. + */ + internal interface Callback { + /** + * Called when user clicks on view. + * @param x click x coordinate + * @param y click y coordinate + */ + fun onClick(x: Float, y: Float) {} + + /** + * Called when user long clicks on view. + * @param x click x coordinate + * @param y click y coordinate + */ + fun onLongClick(x: Float, y: Float) {} + + /** + * Called when user touches screen outside view's bounds. + */ + fun onTouchOutside() {} + + /** + * Called when user touches widget but not removed finger from it. + * @param x x coordinate + * @param y y coordinate + */ + fun onTouched(x: Float, y: Float) {} + + /** + * Called when user drags widget. + * @param diffX movement by X axis + * @param diffY movement by Y axis + */ + fun onMoved(diffX: Float, diffY: Float) {} + + /** + * Called when user releases finger from widget. + * @param x x coordinate + * @param y y coordinate + */ + fun onReleased(x: Float, y: Float) {} + + /** + * Called when sticky edge animation completed. + */ + fun onAnimationCompleted() {} + } + + /** + * Interface that return sticky bounds for widget. + */ + internal interface BoundsChecker { + /** + * Get sticky left position. + * @param screenWidth screen width + * @return sticky left position + */ + fun stickyLeftSide(screenWidth: Float): Float + + /** + * Get sticky right position. + * @param screenWidth screen width + * @return sticky right position + */ + fun stickyRightSide(screenWidth: Float): Float + + /** + * Get sticky top position. + * @param screenHeight screen height + * @return sticky top position + */ + fun stickyTopSide(screenHeight: Float): Float + + /** + * Get sticky bottom position. + * @param screenHeight screen height + * @return sticky bottom position + */ + fun stickyBottomSide(screenHeight: Float): Float + } + + /** + * View's gesture listener. + */ + private inner class GestureListener : SimpleOnGestureListener() { + private var prevX = 0 + private var prevY = 0 + private var velX = 0f + private var velY = 0f + private var lastEventTime: Long = 0 + override fun onDown(e: MotionEvent): Boolean { + val params = view.layoutParams as WindowManager.LayoutParams + prevX = params.x + prevY = params.y + val result = !stickyEdgeAnimator.isAnimating + if (result) { + if (callback != null) { + callback!!.onTouched(e.x, e.y) + } + } + return result + } + + override fun onSingleTapUp(e: MotionEvent): Boolean { + if (callback != null) { + callback!!.onClick(e.x, e.y) + } + return true + } + + override fun onScroll( + e1: MotionEvent, + e2: MotionEvent, + distanceX: Float, + distanceY: Float + ): Boolean { + val diffX = e2.rawX - e1.rawX + val diffY = e2.rawY - e1.rawY + val l = prevX + diffX + val t = prevY + diffY + val params = view.layoutParams as WindowManager.LayoutParams + params.x = l.toInt() + params.y = t.toInt() + try { + windowManager.updateViewLayout(view, params) + } catch (e: IllegalArgumentException) { + // view not attached to window + } + if (callback != null) { + callback!!.onMoved(distanceX, distanceY) + } + return true + } + + override fun onLongPress(e: MotionEvent) { + if (callback != null) { + callback!!.onLongClick(e.x, e.y) + } + val downTime = SystemClock.uptimeMillis() + val eventTime = SystemClock.uptimeMillis() + 100 + val x = 0.0f + val y = 0.0f + val metaState = 0 + val event = MotionEvent.obtain( + downTime, + eventTime, + MotionEvent.ACTION_CANCEL, + x, + y, + metaState + ) + view.dispatchTouchEvent(event) + // onUpEvent(e); + } + + override fun onFling( + e1: MotionEvent, + e2: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + velocityAnimator.animate(velX, velY) + return true + } + + fun onMove(e2: MotionEvent) { + if (lastRawX != null && lastRawY != null) { + val diff = e2.eventTime - lastEventTime + val dt: Float = if (diff == 0L) 0F else 1000f / diff + val newVelX = (e2.rawX - lastRawX!!) * dt + val newVelY = (e2.rawY - lastRawY!!) * dt + velX = smooth(velX, newVelX, 0.2f) + velY = smooth(velY, newVelY, 0.2f) + } + lastRawX = e2.rawX + lastRawY = e2.rawY + lastEventTime = e2.eventTime + } + + fun onUpEvent(e: MotionEvent) { + if (callback != null) { + callback!!.onReleased(e.x, e.y) + } + lastRawX = null + lastRawY = null + lastEventTime = 0 + velY = 0f + velX = velY + if (!velocityAnimator.isAnimating) { + stickyEdgeAnimator.animate(boundsChecker) + } + } + + fun onTouchOutsideEvent(e: MotionEvent) { + if (callback != null) { + callback!!.onTouchOutside() + } + } + } + + /** + * Helper class for animating fling gesture. + */ + private inner class FlingGestureAnimator() { + private val flingGestureAnimator: ValueAnimator + private val dxHolder: PropertyValuesHolder + private val dyHolder: PropertyValuesHolder + private val interpolator: Interpolator + private var params: WindowManager.LayoutParams? = null + private val DEFAULT_ANIM_DURATION: Long = 200 + + fun animate(velocityX: Float, velocityY: Float) { + if (isAnimating) { + return + } + params = view.layoutParams as WindowManager.LayoutParams + val dx = velocityX / 1000f * DEFAULT_ANIM_DURATION + val dy = velocityY / 1000f * DEFAULT_ANIM_DURATION + val newX: Float + val newY: Float + newX = if (dx + params!!.x > screenWidth / 2f) { + boundsChecker.stickyRightSide(screenWidth.toFloat()) + Math.min( + view.width, + view.height + ) / 2f + } else { + boundsChecker.stickyLeftSide(screenWidth.toFloat()) - Math.min( + view.width, + view.height + ) / 2f + } + newY = params!!.y + dy + dxHolder.setFloatValues(params!!.x.toFloat(), newX) + dyHolder.setFloatValues(params!!.y.toFloat(), newY) + flingGestureAnimator.start() + } + + val isAnimating: Boolean + get() = flingGestureAnimator.isRunning + + init { + interpolator = DecelerateInterpolator() + dxHolder = PropertyValuesHolder.ofFloat("x", 0f, 0f) + dyHolder = PropertyValuesHolder.ofFloat("y", 0f, 0f) + dxHolder.setEvaluator(FloatEvaluator()) + dyHolder.setEvaluator(FloatEvaluator()) + flingGestureAnimator = ValueAnimator.ofPropertyValuesHolder(dxHolder, dyHolder) + flingGestureAnimator.interpolator = interpolator + flingGestureAnimator.duration = DEFAULT_ANIM_DURATION + flingGestureAnimator.addUpdateListener { animation: ValueAnimator -> + val newX = animation.getAnimatedValue("x") as Float + val newY = animation.getAnimatedValue("y") as Float + if (callback != null) { + callback!!.onMoved(newX - params!!.x, newY - params!!.y) + } + params!!.x = newX.toInt() + params!!.y = newY.toInt() + try { + windowManager.updateViewLayout(view, params) + } catch (e: IllegalArgumentException) { + animation.cancel() + } + } + flingGestureAnimator.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + super.onAnimationEnd(animation) + stickyEdgeAnimator.animate(boundsChecker) + } + + override fun onAnimationCancel(animation: Animator) { + super.onAnimationCancel(animation) + stickyEdgeAnimator.animate(boundsChecker) + } + }) + } + } + + /** + * Helper class for animating sticking to screen edge. + */ + private inner class StickyEdgeAnimator { + private val DEFAULT_ANIM_DURATION: Long = 300 + private val dxHolder: PropertyValuesHolder + private val dyHolder: PropertyValuesHolder + private val edgeAnimator: ValueAnimator + private val interpolator: Interpolator + private var params: WindowManager.LayoutParams? = null + + fun animate(boundsChecker: BoundsChecker) { + animate(boundsChecker, null) + } + + fun animate(boundsChecker: BoundsChecker, afterAnimation: Runnable?) { + if (edgeAnimator.isRunning) { + return + } + params = view.layoutParams as WindowManager.LayoutParams + val cx = params!!.x + view.width / 2f + val cy = params!!.y + view.width / 2f + val x: Int + x = if (cx < screenWidth / 2f) { + boundsChecker.stickyLeftSide(screenWidth.toFloat()).toInt() + } else { + boundsChecker.stickyRightSide(screenWidth.toFloat()).toInt() + } + var y = params!!.y + val top = boundsChecker.stickyTopSide(screenHeight.toFloat()).toInt() + val bottom = boundsChecker.stickyBottomSide(screenHeight.toFloat()).toInt() + if (params!!.y > bottom || params!!.y < top) { + y = if (cy < screenHeight / 2f) { + top + } else { + bottom + } + } + dxHolder.setIntValues(params!!.x, x) + dyHolder.setIntValues(params!!.y, y) + edgeAnimator.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationCancel(animation: Animator) { + super.onAnimationCancel(animation) + edgeAnimator.removeListener(this) + afterAnimation?.run() + } + + override fun onAnimationEnd(animation: Animator) { + super.onAnimationEnd(animation) + edgeAnimator.removeListener(this) + afterAnimation?.run() + } + }) + edgeAnimator.start() + } + + val isAnimating: Boolean + get() = edgeAnimator.isRunning + + init { + interpolator = OvershootInterpolator() + dxHolder = PropertyValuesHolder.ofInt("x", 0, 0) + dyHolder = PropertyValuesHolder.ofInt("y", 0, 0) + dxHolder.setEvaluator(IntEvaluator()) + dyHolder.setEvaluator(IntEvaluator()) + edgeAnimator = ValueAnimator.ofPropertyValuesHolder(dxHolder, dyHolder) + edgeAnimator.interpolator = interpolator + edgeAnimator.duration = DEFAULT_ANIM_DURATION + edgeAnimator.addUpdateListener { animation: ValueAnimator -> + val x = animation.getAnimatedValue("x") as Int + val y = animation.getAnimatedValue("y") as Int + if (callback != null) { + callback!!.onMoved(x - params!!.x.toFloat(), y - params!!.y.toFloat()) + } + params!!.x = x + params!!.y = y + try { + windowManager.updateViewLayout(view, params) + } catch (e: IllegalArgumentException) { + // view not attached to window + animation.cancel() + } + } + edgeAnimator.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + super.onAnimationEnd(animation) + if (callback != null) { + callback!!.onAnimationCompleted() + } + } + }) + } + } + + fun animateToBounds(boundsChecker: BoundsChecker, afterAnimation: Runnable?) { + stickyEdgeAnimator.animate(boundsChecker, afterAnimation) + } + + fun animateToBounds() { + stickyEdgeAnimator.animate(boundsChecker, null) + } + + init { + gestureDetector = GestureDetector(view.context, GestureListener().also { + gestureListener = it + }) + gestureDetector.setIsLongpressEnabled(true) + this.view = view + this.boundsChecker = boundsChecker + this.view.setOnTouchListener(this) + val context = view.context.applicationContext + windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + screenWidth = context.resources.displayMetrics.widthPixels + screenHeight = + context.resources.displayMetrics.heightPixels - context.resources.getDimensionPixelSize( + R.dimen.aw_status_bar_height + ) + stickyEdgeAnimator = StickyEdgeAnimator() + velocityAnimator = FlingGestureAnimator() + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index b16e529..3917d08 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,8 @@ apply from: 'dependencies.gradle' buildscript { - ext.build_gradle_version = '3.4.1' + ext.kotlin_version = '1.4.10' + ext.build_gradle_version = '4.0.2' repositories { google() @@ -9,8 +10,11 @@ buildscript { maven { url "https://plugins.gradle.org/m2/" } maven { url 'https://jitpack.io' } } + + dependencies { classpath "com.android.tools.build:gradle:$build_gradle_version" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } diff --git a/gradle.properties b/gradle.properties index af8a23c..4365d4e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,4 +17,8 @@ POM_DEVELOPER_ID=cleveroad POM_DEVELOPER_NAME=Cleveroad android.useAndroidX=true -android.enableJetifier=true \ No newline at end of file +android.enableJetifier=true + + +kotlin.code.style=official +org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 179c1ad..d3a5e5f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Mon May 27 17:24:38 EEST 2019 +#Wed Sep 16 17:36:01 CEST 2020 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip