diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a0fbf65a5..7b98977bc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -166,7 +166,6 @@ composeCompiler { } dependencies { - implementation(project(":ui")) implementation(project(":crash")) implementation(project(":component")) implementation(project(":lplaylist")) diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt index 62c8f76f9..4d6fcf1ca 100644 --- a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/PlaylistLayout.kt @@ -33,10 +33,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.media3.common.MediaItem -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListUpdateCallback import coil3.compose.AsyncImage import com.lalilu.component.navigation.AppRouter +import com.lalilu.lmusic.compose.screen.playing.util.DiffUtil +import com.lalilu.lmusic.compose.screen.playing.util.ListUpdateCallback import com.lalilu.lplayer.extensions.PlayerAction diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/util/BatchingListUpdateCallback.java b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/util/BatchingListUpdateCallback.java new file mode 100644 index 000000000..0eb6d60fc --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/util/BatchingListUpdateCallback.java @@ -0,0 +1,121 @@ +package com.lalilu.lmusic.compose.screen.playing.util; + +/* + * Copyright 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import android.annotation.SuppressLint; + +import androidx.annotation.NonNull; + + +public class BatchingListUpdateCallback implements ListUpdateCallback { + private static final int TYPE_NONE = 0; + private static final int TYPE_ADD = 1; + private static final int TYPE_REMOVE = 2; + private static final int TYPE_CHANGE = 3; + + final ListUpdateCallback mWrapped; + + int mLastEventType = TYPE_NONE; + int mLastEventPosition = -1; + int mLastEventCount = -1; + Object mLastEventPayload = null; + + public BatchingListUpdateCallback(@NonNull ListUpdateCallback callback) { + mWrapped = callback; + } + + /** + * BatchingListUpdateCallback holds onto the last event to see if it can be merged with the + * next one. When stream of events finish, you should call this method to dispatch the last + * event. + */ + public void dispatchLastEvent() { + if (mLastEventType == TYPE_NONE) { + return; + } + switch (mLastEventType) { + case TYPE_ADD: + mWrapped.onInserted(mLastEventPosition, mLastEventCount); + break; + case TYPE_REMOVE: + mWrapped.onRemoved(mLastEventPosition, mLastEventCount); + break; + case TYPE_CHANGE: + mWrapped.onChanged(mLastEventPosition, mLastEventCount, mLastEventPayload); + break; + } + mLastEventPayload = null; + mLastEventType = TYPE_NONE; + } + + /** {@inheritDoc} */ + @Override + public void onInserted(int position, int count) { + if (mLastEventType == TYPE_ADD && position >= mLastEventPosition + && position <= mLastEventPosition + mLastEventCount) { + mLastEventCount += count; + mLastEventPosition = Math.min(position, mLastEventPosition); + return; + } + dispatchLastEvent(); + mLastEventPosition = position; + mLastEventCount = count; + mLastEventType = TYPE_ADD; + } + + /** {@inheritDoc} */ + @Override + public void onRemoved(int position, int count) { + if (mLastEventType == TYPE_REMOVE && mLastEventPosition >= position && + mLastEventPosition <= position + count) { + mLastEventCount += count; + mLastEventPosition = position; + return; + } + dispatchLastEvent(); + mLastEventPosition = position; + mLastEventCount = count; + mLastEventType = TYPE_REMOVE; + } + + /** {@inheritDoc} */ + @Override + public void onMoved(int fromPosition, int toPosition) { + dispatchLastEvent(); // moves are not merged + mWrapped.onMoved(fromPosition, toPosition); + } + + /** {@inheritDoc} */ + @Override + @SuppressLint("UnknownNullness") // b/240775049: Cannot annotate properly + public void onChanged(int position, int count, Object payload) { + if (mLastEventType == TYPE_CHANGE && + !(position > mLastEventPosition + mLastEventCount + || position + count < mLastEventPosition || mLastEventPayload != payload)) { + // take potential overlap into account + int previousEnd = mLastEventPosition + mLastEventCount; + mLastEventPosition = Math.min(position, mLastEventPosition); + mLastEventCount = Math.max(previousEnd, position + count) - mLastEventPosition; + return; + } + dispatchLastEvent(); + mLastEventPosition = position; + mLastEventCount = count; + mLastEventPayload = payload; + mLastEventType = TYPE_CHANGE; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/util/DiffUtil.java b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/util/DiffUtil.java new file mode 100644 index 000000000..25508874f --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/util/DiffUtil.java @@ -0,0 +1,887 @@ +package com.lalilu.lmusic.compose.screen.playing.util; + +/* + * Copyright 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; + +public class DiffUtil { + private DiffUtil() { + // utility class, no instance. + } + + private static final Comparator DIAGONAL_COMPARATOR = new Comparator() { + @Override + public int compare(Diagonal o1, Diagonal o2) { + return o1.x - o2.x; + } + }; + + // Myers' algorithm uses two lists as axis labels. In DiffUtil's implementation, `x` axis is + // used for old list and `y` axis is used for new list. + + /** + * Calculates the list of update operations that can covert one list into the other one. + * + * @param cb The callback that acts as a gateway to the backing list data + * @return A DiffResult that contains the information about the edit sequence to convert the + * old list into the new list. + */ + @NonNull + public static DiffResult calculateDiff(@NonNull Callback cb) { + return calculateDiff(cb, true); + } + + /** + * Calculates the list of update operations that can covert one list into the other one. + *

+ * If your old and new lists are sorted by the same constraint and items never move (swap + * positions), you can disable move detection which takes O(N^2) time where + * N is the number of added, moved, removed items. + * + * @param cb The callback that acts as a gateway to the backing list data + * @param detectMoves True if DiffUtil should try to detect moved items, false otherwise. + * @return A DiffResult that contains the information about the edit sequence to convert the + * old list into the new list. + */ + @NonNull + public static DiffResult calculateDiff(@NonNull Callback cb, boolean detectMoves) { + final int oldSize = cb.getOldListSize(); + final int newSize = cb.getNewListSize(); + + final List diagonals = new ArrayList<>(); + + // instead of a recursive implementation, we keep our own stack to avoid potential stack + // overflow exceptions + final List stack = new ArrayList<>(); + + stack.add(new Range(0, oldSize, 0, newSize)); + + final int max = (oldSize + newSize + 1) / 2; + // allocate forward and backward k-lines. K lines are diagonal lines in the matrix. (see the + // paper for details) + // These arrays lines keep the max reachable position for each k-line. + final CenteredArray forward = new CenteredArray(max * 2 + 1); + final CenteredArray backward = new CenteredArray(max * 2 + 1); + + // We pool the ranges to avoid allocations for each recursive call. + final List rangePool = new ArrayList<>(); + while (!stack.isEmpty()) { + final Range range = stack.remove(stack.size() - 1); + final Snake snake = midPoint(range, cb, forward, backward); + if (snake != null) { + // if it has a diagonal, save it + if (snake.diagonalSize() > 0) { + diagonals.add(snake.toDiagonal()); + } + // add new ranges for left and right + final Range left = rangePool.isEmpty() ? new Range() : rangePool.remove( + rangePool.size() - 1); + left.oldListStart = range.oldListStart; + left.newListStart = range.newListStart; + left.oldListEnd = snake.startX; + left.newListEnd = snake.startY; + stack.add(left); + + // re-use range for right + //noinspection UnnecessaryLocalVariable + final Range right = range; + right.oldListEnd = range.oldListEnd; + right.newListEnd = range.newListEnd; + right.oldListStart = snake.endX; + right.newListStart = snake.endY; + stack.add(right); + } else { + rangePool.add(range); + } + + } + // sort snakes + Collections.sort(diagonals, DIAGONAL_COMPARATOR); + + return new DiffResult(cb, diagonals, + forward.backingData(), backward.backingData(), + detectMoves); + } + + /** + * Finds a middle snake in the given range. + */ + @Nullable + private static Snake midPoint( + Range range, + Callback cb, + CenteredArray forward, + CenteredArray backward) { + if (range.oldSize() < 1 || range.newSize() < 1) { + return null; + } + int max = (range.oldSize() + range.newSize() + 1) / 2; + forward.set(1, range.oldListStart); + backward.set(1, range.oldListEnd); + for (int d = 0; d < max; d++) { + Snake snake = forward(range, cb, forward, backward, d); + if (snake != null) { + return snake; + } + snake = backward(range, cb, forward, backward, d); + if (snake != null) { + return snake; + } + } + return null; + } + + @Nullable + private static Snake forward( + Range range, + Callback cb, + CenteredArray forward, + CenteredArray backward, + int d) { + boolean checkForSnake = Math.abs(range.oldSize() - range.newSize()) % 2 == 1; + int delta = range.oldSize() - range.newSize(); + for (int k = -d; k <= d; k += 2) { + // we either come from d-1, k-1 OR d-1. k+1 + // as we move in steps of 2, array always holds both current and previous d values + // k = x - y and each array value holds the max X, y = x - k + final int startX; + final int startY; + int x, y; + if (k == -d || (k != d && forward.get(k + 1) > forward.get(k - 1))) { + // picking k + 1, incrementing Y (by simply not incrementing X) + x = startX = forward.get(k + 1); + } else { + // picking k - 1, incrementing X + startX = forward.get(k - 1); + x = startX + 1; + } + y = range.newListStart + (x - range.oldListStart) - k; + startY = (d == 0 || x != startX) ? y : y - 1; + // now find snake size + while (x < range.oldListEnd + && y < range.newListEnd + && cb.areItemsTheSame(x, y)) { + x++; + y++; + } + // now we have furthest reaching x, record it + forward.set(k, x); + if (checkForSnake) { + // see if we did pass over a backwards array + // mapping function: delta - k + int backwardsK = delta - k; + // if backwards K is calculated and it passed me, found match + if (backwardsK >= -d + 1 + && backwardsK <= d - 1 + && backward.get(backwardsK) <= x) { + // match + Snake snake = new Snake(); + snake.startX = startX; + snake.startY = startY; + snake.endX = x; + snake.endY = y; + snake.reverse = false; + return snake; + } + } + } + return null; + } + + @Nullable + private static Snake backward( + Range range, + Callback cb, + CenteredArray forward, + CenteredArray backward, + int d) { + boolean checkForSnake = (range.oldSize() - range.newSize()) % 2 == 0; + int delta = range.oldSize() - range.newSize(); + // same as forward but we go backwards from end of the lists to be beginning + // this also means we'll try to optimize for minimizing x instead of maximizing it + for (int k = -d; k <= d; k += 2) { + // we either come from d-1, k-1 OR d-1, k+1 + // as we move in steps of 2, array always holds both current and previous d values + // k = x - y and each array value holds the MIN X, y = x - k + // when x's are equal, we prioritize deletion over insertion + final int startX; + final int startY; + int x, y; + + if (k == -d || (k != d && backward.get(k + 1) < backward.get(k - 1))) { + // picking k + 1, decrementing Y (by simply not decrementing X) + x = startX = backward.get(k + 1); + } else { + // picking k - 1, decrementing X + startX = backward.get(k - 1); + x = startX - 1; + } + y = range.newListEnd - ((range.oldListEnd - x) - k); + startY = (d == 0 || x != startX) ? y : y + 1; + // now find snake size + while (x > range.oldListStart + && y > range.newListStart + && cb.areItemsTheSame(x - 1, y - 1)) { + x--; + y--; + } + // now we have furthest point, record it (min X) + backward.set(k, x); + if (checkForSnake) { + // see if we did pass over a backwards array + // mapping function: delta - k + int forwardsK = delta - k; + // if forwards K is calculated and it passed me, found match + if (forwardsK >= -d + && forwardsK <= d + && forward.get(forwardsK) >= x) { + // match + Snake snake = new Snake(); + // assignment are reverse since we are a reverse snake + snake.startX = x; + snake.startY = y; + snake.endX = startX; + snake.endY = startY; + snake.reverse = true; + return snake; + } + } + } + return null; + } + + /** + * A Callback class used by DiffUtil while calculating the diff between two lists. + */ + public abstract static class Callback { + /** + * Returns the size of the old list. + * + * @return The size of the old list. + */ + public abstract int getOldListSize(); + + /** + * Returns the size of the new list. + * + * @return The size of the new list. + */ + public abstract int getNewListSize(); + + /** + * Called by the DiffUtil to decide whether two object represent the same Item. + *

+ * For example, if your items have unique ids, this method should check their id equality. + * + * @param oldItemPosition The position of the item in the old list + * @param newItemPosition The position of the item in the new list + * @return True if the two items represent the same object or false if they are different. + */ + public abstract boolean areItemsTheSame(int oldItemPosition, int newItemPosition); + + public abstract boolean areContentsTheSame(int oldItemPosition, int newItemPosition); + + + @Nullable + public Object getChangePayload(int oldItemPosition, int newItemPosition) { + return null; + } + } + + /** + * Callback for calculating the diff between two non-null items in a list. + *

+ * {@link Callback} serves two roles - list indexing, and item diffing. ItemCallback handles + * just the second of these, which allows separation of code that indexes into an array or List + * from the presentation-layer and content specific diffing code. + * + * @param Type of items to compare. + */ + public abstract static class ItemCallback { + /** + * Called to check whether two objects represent the same item. + *

+ * For example, if your items have unique ids, this method should check their id equality. + *

+ * Note: {@code null} items in the list are assumed to be the same as another {@code null} + * item and are assumed to not be the same as a non-{@code null} item. This callback will + * not be invoked for either of those cases. + * + * @param oldItem The item in the old list. + * @param newItem The item in the new list. + * @return True if the two items represent the same object or false if they are different. + * @see Callback#areItemsTheSame(int, int) + */ + public abstract boolean areItemsTheSame(@NonNull T oldItem, @NonNull T newItem); + + + public abstract boolean areContentsTheSame(@NonNull T oldItem, @NonNull T newItem); + + + @SuppressWarnings({"unused"}) + @Nullable + public Object getChangePayload(@NonNull T oldItem, @NonNull T newItem) { + return null; + } + } + + /** + * A diagonal is a match in the graph. + * Rather than snakes, we only record the diagonals in the path. + */ + static class Diagonal { + public final int x; + public final int y; + public final int size; + + Diagonal(int x, int y, int size) { + this.x = x; + this.y = y; + this.size = size; + } + + int endX() { + return x + size; + } + + int endY() { + return y + size; + } + } + + /** + * Snakes represent a match between two lists. It is optionally prefixed or postfixed with an + * add or remove operation. See the Myers' paper for details. + */ + @SuppressWarnings("WeakerAccess") + static class Snake { + /** + * Position in the old list + */ + public int startX; + + /** + * Position in the new list + */ + public int startY; + + /** + * End position in the old list, exclusive + */ + public int endX; + + /** + * End position in the new list, exclusive + */ + public int endY; + + /** + * True if this snake was created in the reverse search, false otherwise. + */ + public boolean reverse; + + boolean hasAdditionOrRemoval() { + return endY - startY != endX - startX; + } + + boolean isAddition() { + return endY - startY > endX - startX; + } + + int diagonalSize() { + return Math.min(endX - startX, endY - startY); + } + + /** + * Extract the diagonal of the snake to make reasoning easier for the rest of the + * algorithm where we try to produce a path and also find moves. + */ + @NonNull + Diagonal toDiagonal() { + if (hasAdditionOrRemoval()) { + if (reverse) { + // snake edge it at the end + return new Diagonal(startX, startY, diagonalSize()); + } else { + // snake edge it at the beginning + if (isAddition()) { + return new Diagonal(startX, startY + 1, diagonalSize()); + } else { + return new Diagonal(startX + 1, startY, diagonalSize()); + } + } + } else { + // we are a pure diagonal + return new Diagonal(startX, startY, endX - startX); + } + } + } + + /** + * Represents a range in two lists that needs to be solved. + *

+ * This internal class is used when running Myers' algorithm without recursion. + *

+ * Ends are exclusive + */ + static class Range { + + int oldListStart, oldListEnd; + + int newListStart, newListEnd; + + public Range() { + } + + public Range(int oldListStart, int oldListEnd, int newListStart, int newListEnd) { + this.oldListStart = oldListStart; + this.oldListEnd = oldListEnd; + this.newListStart = newListStart; + this.newListEnd = newListEnd; + } + + int oldSize() { + return oldListEnd - oldListStart; + } + + int newSize() { + return newListEnd - newListStart; + } + } + + public static class DiffResult { + /** + * Signifies an item not present in the list. + */ + public static final int NO_POSITION = -1; + + + /** + * While reading the flags below, keep in mind that when multiple items move in a list, + * Myers's may pick any of them as the anchor item and consider that one NOT_CHANGED while + * picking others as additions and removals. This is completely fine as we later detect + * all moves. + *

+ * Below, when an item is mentioned to stay in the same "location", it means we won't + * dispatch a move/add/remove for it, it DOES NOT mean the item is still in the same + * position. + */ + // item stayed the same. + private static final int FLAG_NOT_CHANGED = 1; + // item stayed in the same location but changed. + private static final int FLAG_CHANGED = FLAG_NOT_CHANGED << 1; + // Item has moved and also changed. + private static final int FLAG_MOVED_CHANGED = FLAG_CHANGED << 1; + // Item has moved but did not change. + private static final int FLAG_MOVED_NOT_CHANGED = FLAG_MOVED_CHANGED << 1; + // Item moved + private static final int FLAG_MOVED = FLAG_MOVED_CHANGED | FLAG_MOVED_NOT_CHANGED; + + // since we are re-using the int arrays that were created in the Myers' step, we mask + // change flags + private static final int FLAG_OFFSET = 4; + + private static final int FLAG_MASK = (1 << FLAG_OFFSET) - 1; + + // The diagonals extracted from The Myers' snakes. + private final List mDiagonals; + + // The list to keep oldItemStatuses. As we traverse old items, we assign flags to them + // which also includes whether they were a real removal or a move (and its new index). + private final int[] mOldItemStatuses; + // The list to keep newItemStatuses. As we traverse new items, we assign flags to them + // which also includes whether they were a real addition or a move(and its old index). + private final int[] mNewItemStatuses; + // The callback that was given to calculate diff method. + private final Callback mCallback; + + private final int mOldListSize; + + private final int mNewListSize; + + private final boolean mDetectMoves; + + /** + * @param callback The callback that was used to calculate the diff + * @param diagonals Matches between the two lists + * @param oldItemStatuses An int[] that can be re-purposed to keep metadata + * @param newItemStatuses An int[] that can be re-purposed to keep metadata + * @param detectMoves True if this DiffResult will try to detect moved items + */ + DiffResult(Callback callback, List diagonals, int[] oldItemStatuses, + int[] newItemStatuses, boolean detectMoves) { + mDiagonals = diagonals; + mOldItemStatuses = oldItemStatuses; + mNewItemStatuses = newItemStatuses; + Arrays.fill(mOldItemStatuses, 0); + Arrays.fill(mNewItemStatuses, 0); + mCallback = callback; + mOldListSize = callback.getOldListSize(); + mNewListSize = callback.getNewListSize(); + mDetectMoves = detectMoves; + addEdgeDiagonals(); + findMatchingItems(); + } + + /** + * Add edge diagonals so that we can iterate as long as there are diagonals w/o lots of + * null checks around + */ + private void addEdgeDiagonals() { + Diagonal first = mDiagonals.isEmpty() ? null : mDiagonals.get(0); + // see if we should add 1 to the 0,0 + if (first == null || first.x != 0 || first.y != 0) { + mDiagonals.add(0, new Diagonal(0, 0, 0)); + } + // always add one last + mDiagonals.add(new Diagonal(mOldListSize, mNewListSize, 0)); + } + + /** + * Find position mapping from old list to new list. + * If moves are requested, we'll also try to do an n^2 search between additions and + * removals to find moves. + */ + private void findMatchingItems() { + for (Diagonal diagonal : mDiagonals) { + for (int offset = 0; offset < diagonal.size; offset++) { + int posX = diagonal.x + offset; + int posY = diagonal.y + offset; + final boolean theSame = mCallback.areContentsTheSame(posX, posY); + final int changeFlag = theSame ? FLAG_NOT_CHANGED : FLAG_CHANGED; + mOldItemStatuses[posX] = (posY << FLAG_OFFSET) | changeFlag; + mNewItemStatuses[posY] = (posX << FLAG_OFFSET) | changeFlag; + } + } + // now all matches are marked, lets look for moves + if (mDetectMoves) { + // traverse each addition / removal from the end of the list, find matching + // addition removal from before + findMoveMatches(); + } + } + + private void findMoveMatches() { + // for each removal, find matching addition + int posX = 0; + for (Diagonal diagonal : mDiagonals) { + while (posX < diagonal.x) { + if (mOldItemStatuses[posX] == 0) { + // there is a removal, find matching addition from the rest + findMatchingAddition(posX); + } + posX++; + } + // snap back for the next diagonal + posX = diagonal.endX(); + } + } + + /** + * Search the whole list to find the addition for the given removal of position posX + * + * @param posX position in the old list + */ + private void findMatchingAddition(int posX) { + int posY = 0; + final int diagonalsSize = mDiagonals.size(); + for (int i = 0; i < diagonalsSize; i++) { + final Diagonal diagonal = mDiagonals.get(i); + while (posY < diagonal.y) { + // found some additions, evaluate + if (mNewItemStatuses[posY] == 0) { // not evaluated yet + boolean matching = mCallback.areItemsTheSame(posX, posY); + if (matching) { + // yay found it, set values + boolean contentsMatching = mCallback.areContentsTheSame(posX, posY); + final int changeFlag = contentsMatching ? FLAG_MOVED_NOT_CHANGED + : FLAG_MOVED_CHANGED; + // once we process one of these, it will mark the other one as ignored. + mOldItemStatuses[posX] = (posY << FLAG_OFFSET) | changeFlag; + mNewItemStatuses[posY] = (posX << FLAG_OFFSET) | changeFlag; + return; + } + } + posY++; + } + posY = diagonal.endY(); + } + } + + /** + * Given a position in the old list, returns the position in the new list, or + * {@code NO_POSITION} if it was removed. + * + * @param oldListPosition Position of item in old list + * @return Position of item in new list, or {@code NO_POSITION} if not present. + * @see #NO_POSITION + * @see #convertNewPositionToOld(int) + */ + public int convertOldPositionToNew(@IntRange(from = 0) int oldListPosition) { + if (oldListPosition < 0 || oldListPosition >= mOldListSize) { + throw new IndexOutOfBoundsException("Index out of bounds - passed position = " + + oldListPosition + ", old list size = " + mOldListSize); + } + final int status = mOldItemStatuses[oldListPosition]; + if ((status & FLAG_MASK) == 0) { + return NO_POSITION; + } else { + return status >> FLAG_OFFSET; + } + } + + /** + * Given a position in the new list, returns the position in the old list, or + * {@code NO_POSITION} if it was removed. + * + * @param newListPosition Position of item in new list + * @return Position of item in old list, or {@code NO_POSITION} if not present. + * @see #NO_POSITION + * @see #convertOldPositionToNew(int) + */ + public int convertNewPositionToOld(@IntRange(from = 0) int newListPosition) { + if (newListPosition < 0 || newListPosition >= mNewListSize) { + throw new IndexOutOfBoundsException("Index out of bounds - passed position = " + + newListPosition + ", new list size = " + mNewListSize); + } + final int status = mNewItemStatuses[newListPosition]; + if ((status & FLAG_MASK) == 0) { + return NO_POSITION; + } else { + return status >> FLAG_OFFSET; + } + } + + + public void dispatchUpdatesTo(@NonNull ListUpdateCallback updateCallback) { + final BatchingListUpdateCallback batchingCallback; + + if (updateCallback instanceof BatchingListUpdateCallback) { + batchingCallback = (BatchingListUpdateCallback) updateCallback; + } else { + batchingCallback = new BatchingListUpdateCallback(updateCallback); + // replace updateCallback with a batching callback and override references to + // updateCallback so that we don't call it directly by mistake + //noinspection UnusedAssignment + updateCallback = batchingCallback; + } + // track up to date current list size for moves + // when a move is found, we record its position from the end of the list (which is + // less likely to change since we iterate in reverse). + // Later when we find the match of that move, we dispatch the update + int currentListSize = mOldListSize; + // list of postponed moves + final Collection postponedUpdates = new ArrayDeque<>(); + // posX and posY are exclusive + int posX = mOldListSize; + int posY = mNewListSize; + // iterate from end of the list to the beginning. + // this just makes offsets easier since changes in the earlier indices has an effect + // on the later indices. + for (int diagonalIndex = mDiagonals.size() - 1; diagonalIndex >= 0; diagonalIndex--) { + final Diagonal diagonal = mDiagonals.get(diagonalIndex); + int endX = diagonal.endX(); + int endY = diagonal.endY(); + // dispatch removals and additions until we reach to that diagonal + // first remove then add so that it can go into its place and we don't need + // to offset values + while (posX > endX) { + posX--; + // REMOVAL + int status = mOldItemStatuses[posX]; + if ((status & FLAG_MOVED) != 0) { + int newPos = status >> FLAG_OFFSET; + // get postponed addition + PostponedUpdate postponedUpdate = getPostponedUpdate(postponedUpdates, + newPos, false); + if (postponedUpdate != null) { + // this is an addition that was postponed. Now dispatch it. + int updatedNewPos = currentListSize - postponedUpdate.currentPos; + batchingCallback.onMoved(posX, updatedNewPos - 1); + if ((status & FLAG_MOVED_CHANGED) != 0) { + Object changePayload = mCallback.getChangePayload(posX, newPos); + batchingCallback.onChanged(updatedNewPos - 1, 1, changePayload); + } + } else { + // first time we are seeing this, we'll see a matching addition + postponedUpdates.add(new PostponedUpdate( + posX, + currentListSize - posX - 1, + true + )); + } + } else { + // simple removal + batchingCallback.onRemoved(posX, 1); + currentListSize--; + } + } + while (posY > endY) { + posY--; + // ADDITION + int status = mNewItemStatuses[posY]; + if ((status & FLAG_MOVED) != 0) { + // this is a move not an addition. + // see if this is postponed + int oldPos = status >> FLAG_OFFSET; + // get postponed removal + PostponedUpdate postponedUpdate = getPostponedUpdate(postponedUpdates, + oldPos, true); + // empty size returns 0 for indexOf + if (postponedUpdate == null) { + // postpone it until we see the removal + postponedUpdates.add(new PostponedUpdate( + posY, + currentListSize - posX, + false + )); + } else { + // oldPosFromEnd = foundListSize - posX + // we can find posX if we swap the list sizes + // posX = listSize - oldPosFromEnd + int updatedOldPos = currentListSize - postponedUpdate.currentPos - 1; + batchingCallback.onMoved(updatedOldPos, posX); + if ((status & FLAG_MOVED_CHANGED) != 0) { + Object changePayload = mCallback.getChangePayload(oldPos, posY); + batchingCallback.onChanged(posX, 1, changePayload); + } + } + } else { + // simple addition + batchingCallback.onInserted(posX, 1); + currentListSize++; + } + } + // now dispatch updates for the diagonal + posX = diagonal.x; + posY = diagonal.y; + for (int i = 0; i < diagonal.size; i++) { + // dispatch changes + if ((mOldItemStatuses[posX] & FLAG_MASK) == FLAG_CHANGED) { + Object changePayload = mCallback.getChangePayload(posX, posY); + batchingCallback.onChanged(posX, 1, changePayload); + } + posX++; + posY++; + } + // snap back for the next diagonal + posX = diagonal.x; + posY = diagonal.y; + } + batchingCallback.dispatchLastEvent(); + } + + @Nullable + private static PostponedUpdate getPostponedUpdate( + Collection postponedUpdates, + int posInList, + boolean removal) { + PostponedUpdate postponedUpdate = null; + Iterator itr = postponedUpdates.iterator(); + while (itr.hasNext()) { + PostponedUpdate update = itr.next(); + if (update.posInOwnerList == posInList && update.removal == removal) { + postponedUpdate = update; + itr.remove(); + break; + } + } + while (itr.hasNext()) { + // re-offset all others + PostponedUpdate update = itr.next(); + if (removal) { + update.currentPos--; + } else { + update.currentPos++; + } + } + return postponedUpdate; + } + } + + /** + * Represents an update that we skipped because it was a move. + *

+ * When an update is skipped, it is tracked as other updates are dispatched until the matching + * add/remove operation is found at which point the tracked position is used to dispatch the + * update. + */ + private static class PostponedUpdate { + /** + * position in the list that owns this item + */ + int posInOwnerList; + + /** + * position wrt to the end of the list + */ + int currentPos; + + /** + * true if this is a removal, false otherwise + */ + boolean removal; + + PostponedUpdate(int posInOwnerList, int currentPos, boolean removal) { + this.posInOwnerList = posInOwnerList; + this.currentPos = currentPos; + this.removal = removal; + } + } + + /** + * Array wrapper w/ negative index support. + * We use this array instead of a regular array so that algorithm is easier to read without + * too many offsets when accessing the "k" array in the algorithm. + */ + static class CenteredArray { + private final int[] mData; + private final int mMid; + + CenteredArray(int size) { + mData = new int[size]; + mMid = mData.length / 2; + } + + int get(int index) { + return mData[index + mMid]; + } + + int[] backingData() { + return mData; + } + + void set(int index, int value) { + mData[index + mMid] = value; + } + + public void fill(int value) { + Arrays.fill(mData, value); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/util/ListUpdateCallback.java b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/util/ListUpdateCallback.java new file mode 100644 index 000000000..bdac414b0 --- /dev/null +++ b/app/src/main/java/com/lalilu/lmusic/compose/screen/playing/util/ListUpdateCallback.java @@ -0,0 +1,59 @@ +package com.lalilu.lmusic.compose.screen.playing.util; + +/* + * Copyright 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import androidx.annotation.Nullable; + +/** + * An interface that can receive Update operations that are applied to a list. + *

+ * This class can be used together with DiffUtil to detect changes between two lists. + */ +public interface ListUpdateCallback { + /** + * Called when {@code count} number of items are inserted at the given position. + * + * @param position The position of the new item. + * @param count The number of items that have been added. + */ + void onInserted(int position, int count); + + /** + * Called when {@code count} number of items are removed from the given position. + * + * @param position The position of the item which has been removed. + * @param count The number of items which have been removed. + */ + void onRemoved(int position, int count); + + /** + * Called when an item changes its position in the list. + * + * @param fromPosition The previous position of the item before the move. + * @param toPosition The new position of the item. + */ + void onMoved(int fromPosition, int toPosition); + + /** + * Called when {@code count} number of items are updated at the given position. + * + * @param position The position of the item which has been updated. + * @param count The number of items which has changed. + * @param payload The payload for the changed items. + */ + void onChanged(int position, int count, @Nullable Object payload); +} diff --git a/app/src/main/java/com/lalilu/lmusic/utils/extension/Extensions.kt b/app/src/main/java/com/lalilu/lmusic/utils/extension/Extensions.kt index 893fa3371..25fdc1c12 100644 --- a/app/src/main/java/com/lalilu/lmusic/utils/extension/Extensions.kt +++ b/app/src/main/java/com/lalilu/lmusic/utils/extension/Extensions.kt @@ -24,8 +24,6 @@ import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat import androidx.activity.ComponentActivity import androidx.annotation.RequiresApi -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView import com.blankj.utilcode.util.GsonUtils import com.blankj.utilcode.util.LogUtils import com.google.gson.reflect.TypeToken @@ -230,18 +228,6 @@ fun List.removeAt(index: Int): List { } } -fun calculateExtraLayoutSpace(context: Context, size: Int): LinearLayoutManager { - return object : LinearLayoutManager(context) { - override fun calculateExtraLayoutSpace( - state: RecyclerView.State, - extraLayoutSpace: IntArray - ) { - extraLayoutSpace[0] = size - extraLayoutSpace[1] = size - } - } -} - /** * 简易的防抖实现 */ diff --git a/common/src/main/java/com/lalilu/common/kv/BaseKV.kt b/common/src/main/java/com/lalilu/common/kv/BaseKV.kt index 6e56339c7..3f20c05c9 100644 --- a/common/src/main/java/com/lalilu/common/kv/BaseKV.kt +++ b/common/src/main/java/com/lalilu/common/kv/BaseKV.kt @@ -32,7 +32,7 @@ abstract class BaseKV(val prefix: String = "") { T::class == Float::class -> FloatListKVImpl(actualKey) T::class == Double::class -> DoubleListKVImpl(actualKey) T::class == String::class -> StringListKVImpl(actualKey) - else -> throw IllegalArgumentException("Unsupported type") + else -> ObjectListKVImpl(actualKey, T::class.java) } } as KVListItem } diff --git a/common/src/main/java/com/lalilu/common/kv/KVListItem.kt b/common/src/main/java/com/lalilu/common/kv/KVListItem.kt index efc7f46c3..c8c7c5f46 100644 --- a/common/src/main/java/com/lalilu/common/kv/KVListItem.kt +++ b/common/src/main/java/com/lalilu/common/kv/KVListItem.kt @@ -144,6 +144,27 @@ class BoolListKVImpl( override fun set(value: List?) { super.set(value) + if (value == null) { + SPUtils.getInstance().remove(key) + } else { + val json = GsonUtils.toJson(value) + SPUtils.getInstance().put(key, json) + } + } +} + +class ObjectListKVImpl( + private val key: String, + private val clazz: Class +) : KVListItem() { + override fun get(): List? { + val json = SPUtils.getInstance().getString(key) + return GsonUtils.fromJson>(json, clazz) + } + + override fun set(value: List?) { + super.set(value) + if (value == null) { SPUtils.getInstance().remove(key) } else { diff --git a/component/build.gradle.kts b/component/build.gradle.kts index 7b277dc99..c35d703dc 100644 --- a/component/build.gradle.kts +++ b/component/build.gradle.kts @@ -35,27 +35,28 @@ composeCompiler { } dependencies { - api(libs.lottie.compose) - - api(libs.bundles.voyager) + // compose + api(platform(libs.compose.bom.alpha)) + api(libs.activity.compose) + api(libs.bundles.compose) + api(libs.bundles.compose.debug) // accompanist // https://google.github.io/accompanist api(libs.bundles.accompanist) + api(libs.bundles.voyager) + api(libs.bundles.coil3) + api(libs.lottie.compose) api(project(":lmedia")) api(project(":common")) api(project(":lplayer")) - api(libs.bundles.coil3) - // https://github.com/Calvin-LL/Reorderable // Apache-2.0 license - api("sh.calvin.reorderable:reorderable:1.1.0") + api("sh.calvin.reorderable:reorderable:2.4.0") api("com.github.cy745:AnyPopDialog-Compose:cb92c5b6dc") api("me.rosuh:AndroidFilePicker:1.0.1") - api("androidx.constraintlayout:constraintlayout-compose:1.1.0-alpha13") - api("com.github.cy745.KRouter:core:fcf40f4b15") api("com.cheonjaeung.compose.grid:grid:2.0.0") api("com.github.cy745.RemixIcon-Kmp:core:1a3c554a35") api("com.github.nanihadesuka:LazyColumnScrollbar:2.2.0") @@ -64,11 +65,4 @@ dependencies { // https://mvnrepository.com/artifact/org.jetbrains.androidx.navigation/navigation-compose api("org.jetbrains.androidx.navigation:navigation-compose:2.8.0-alpha10") api("androidx.compose.material3:material3-adaptive-navigation-suite") - - // compose -// api(platform(libs.compose.bom)) - api(platform(libs.compose.bom.alpha)) - api(libs.activity.compose) - api(libs.bundles.compose) - api(libs.bundles.compose.debug) } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fda25fec7..6520fb92e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,11 +2,9 @@ compile_version = "35" min_sdk_version = "21" -agp_version = "8.5.0" +agp_version = "8.5.2" kotlin_version = "2.0.0" -coroutines_version = "1.8.1" ksp_version = "2.0.0-1.0.22" -#serialization_json_version = "1.6.0" koin_version = "4.0.0" koin_ksp_version = "1.4.0" @@ -16,35 +14,26 @@ accompanist_version = "0.32.0" voyager = "1.1.0-beta03" lottie-compose = "5.2.0" -kotlinpoet = "1.14.2" coil3_version = "3.0.0-alpha07" utilcodex_version = "1.31.1" # androidx appcompat = "1.7.0" -core-ktx = "1.13.1" +core-ktx = "1.15.0" palette-ktx = "1.0.0" dynamicanimation-ktx = "1.0.0-alpha03" startup-runtime = "1.2.0" -constraintlayout = "2.1.4" -coordinatorlayout = "1.2.0" -gridlayout = "1.0.0" -recyclerview = "1.3.2" -activity-compose = "1.8.2" -lifecycle_version = "2.6.2" -navigation_version = "2.7.4" +activity-compose = "1.9.3" room_version = "2.5.2" media = "1.7.0" +media3 = "1.5.0" flyjingfish-aop = "1.9.7" krouter_version = "0.0.1" [libraries] # kotlin -kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin_version" } kotlin-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin_version" } -kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines_version" } -kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines_version" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.7.3" } # compose @@ -65,7 +54,6 @@ compose-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } lottie-compose = { module = "com.airbnb.android:lottie-compose", version.ref = "lottie-compose" } voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } -voyager-bottomSheetNavigator = { module = "cafe.adriel.voyager:voyager-bottom-sheet-navigator", version.ref = "voyager" } voyager-tabNavigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" } voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" } voyager-koin = { module = "cafe.adriel.voyager:voyager-koin", version.ref = "voyager" } @@ -89,21 +77,16 @@ coil3-android = { module = "io.coil-kt.coil3:coil-android", version.ref = "coil3 coil3-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil3_version" } coil3-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil3_version" } +# media3 +media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" } +media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } + # androidx appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } palette-ktx = { module = "androidx.palette:palette-ktx", version.ref = "palette-ktx" } dynamicanimation-ktx = { module = "androidx.dynamicanimation:dynamicanimation-ktx", version.ref = "dynamicanimation-ktx" } -constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } -coordinatorlayout = { module = "androidx.coordinatorlayout:coordinatorlayout", version.ref = "coordinatorlayout" } -gridlayout = { module = "androidx.gridlayout:gridlayout", version.ref = "gridlayout" } -recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } startup-runtime = { module = "androidx.startup:startup-runtime", version.ref = "startup-runtime" } -lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle_version" } -lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle_version" } -lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle_version" } -lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle_version" } -navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation_version" } activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "room_version" } room-compiler = { module = "androidx.room:room-compiler", version.ref = "room_version" } @@ -133,8 +116,16 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = " [bundles] -common = ["kotlin-stdlib", "kotlinx-coroutines-core", "kotlinx-coroutines-android"] -accompanist = ["accompanist-flowlayout", "accompanist-permissions", "accompanist-systemuicontroller"] +accompanist = [ + "accompanist-flowlayout", + "accompanist-permissions", + "accompanist-systemuicontroller" +] +media3 = [ + "media3-session", + "media3-exoplayer" +] + compose-debug = ["compose-tooling", "compose-tooling-preview"] compose = [ "compose-bom", diff --git a/lmedia b/lmedia index 94feeb64c..40d9fb5e1 160000 --- a/lmedia +++ b/lmedia @@ -1 +1 @@ -Subproject commit 94feeb64c480d5fc0b40e5fd9a15a47f213c5763 +Subproject commit 40d9fb5e14c1bf48ce28c8c9e2e8b86942622056 diff --git a/lplayer/build.gradle.kts b/lplayer/build.gradle.kts index 0a590bd3e..108c2ceb8 100644 --- a/lplayer/build.gradle.kts +++ b/lplayer/build.gradle.kts @@ -29,8 +29,6 @@ dependencies { implementation(project(":common")) implementation(project(":lmedia")) implementation(libs.startup.runtime) - implementation("com.github.cy745:AndroidVideoCache:2.7.2") - implementation("androidx.media3:media3-exoplayer:1.4.1") - api("androidx.media3:media3-session:1.4.1") + api(libs.bundles.media3) } \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/MPlayerKV.kt b/lplayer/src/main/java/com/lalilu/lplayer/MPlayerKV.kt index 2f516a5f4..4cc3175de 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/MPlayerKV.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/MPlayerKV.kt @@ -4,4 +4,6 @@ import com.lalilu.common.kv.BaseKV object MPlayerKV : BaseKV(prefix = "mplayer") { val historyPlaylistIds = obtainList("history_playlist_ids") + val handleAudioFocus = obtain("handleAudioFocus") + val handleBecomeNoisy = obtain("handleBecomeNoisy") } \ No newline at end of file diff --git a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt index 33c24dff5..ff5488c38 100644 --- a/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt +++ b/lplayer/src/main/java/com/lalilu/lplayer/service/MService.kt @@ -4,6 +4,8 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import androidx.annotation.OptIn +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.util.UnstableApi @@ -26,6 +28,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlin.coroutines.CoroutineContext @OptIn(UnstableApi::class) @@ -34,6 +40,14 @@ class MService : MediaLibraryService(), CoroutineScope { private var exoPlayer: ExoPlayer? = null private var mediaSession: MediaLibrarySession? = null + private val defaultAudioAttributes by lazy { + AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setSpatializationBehavior(C.SPATIALIZATION_BEHAVIOR_AUTO) + .setAllowedCapturePolicy(C.ALLOW_CAPTURE_BY_ALL) + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .build() + } override fun onCreate() { super.onCreate() @@ -45,12 +59,16 @@ class MService : MediaLibraryService(), CoroutineScope { exoPlayer = ExoPlayer .Builder(this) .setRenderersFactory(FadeTransitionRenderersFactory(this, this)) + .setHandleAudioBecomingNoisy(MPlayerKV.handleBecomeNoisy.value ?: true) + .setAudioAttributes(defaultAudioAttributes, MPlayerKV.handleAudioFocus.value ?: true) .build() mediaSession = MediaLibrarySession .Builder(this, exoPlayer!!, MServiceCallback()) .setSessionActivity(getLauncherPendingIntent()) .build() + + startListenForValuesUpdate() } override fun onDestroy() { @@ -66,6 +84,20 @@ class MService : MediaLibraryService(), CoroutineScope { override fun onGetSession( controllerInfo: MediaSession.ControllerInfo ): MediaLibrarySession? = mediaSession + + private fun startListenForValuesUpdate() = launch { + MPlayerKV.handleAudioFocus.flow().onEach { + withContext(Dispatchers.Main) { + exoPlayer?.setAudioAttributes(defaultAudioAttributes, it ?: true) + } + }.launchIn(this) + + MPlayerKV.handleBecomeNoisy.flow().onEach { + withContext(Dispatchers.Main) { + exoPlayer?.setHandleAudioBecomingNoisy(it ?: true) + } + }.launchIn(this) + } } @OptIn(UnstableApi::class) @@ -192,7 +224,7 @@ private fun Context.getLauncherPendingIntent(): PendingIntent { internal fun getHistoryItems(): List { val history = MPlayerKV.historyPlaylistIds.get() - return if (history != null) LMedia.mapItems(history) + return if (!history.isNullOrEmpty()) LMedia.mapItems(history) else LMedia.getChildren(MServiceCallback.ALL_SONGS) } diff --git a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt index e146b0b76..4e3966b1e 100644 --- a/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt +++ b/lplaylist/src/main/java/com/lalilu/lplaylist/screen/PlaylistScreen.kt @@ -90,7 +90,6 @@ data object PlaylistScreen : TabScreen, ScreenBarFactory { } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun Screen.PlaylistScreen( playlistSM: PlaylistScreenModel = rememberScreenModel { PlaylistScreenModel() }, @@ -244,7 +243,7 @@ private fun Screen.PlaylistScreen( contentType = { LPlaylist::class.java } ) { playlist -> ReorderableItem( - reorderableLazyListState = reorderableState, + state = reorderableState, key = playlist.id ) { isDragging -> PlaylistCard( diff --git a/settings.gradle.kts b/settings.gradle.kts index 15454fca0..07c33817d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,14 +18,15 @@ dependencyResolutionManagement { rootProject.name = "LMusic" include(":app") -include(":ui") include(":common") +include(":component") +include(":crash") + include(":lmedia") include(":lplayer") + include(":lplaylist") include(":lhistory") include(":lartist") include(":lalbum") include(":ldictionary") -include(":crash") -include(":component") \ No newline at end of file diff --git a/ui/.gitignore b/ui/.gitignore deleted file mode 100644 index 42afabfd2..000000000 --- a/ui/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts deleted file mode 100644 index c032dfd7b..000000000 --- a/ui/build.gradle.kts +++ /dev/null @@ -1,34 +0,0 @@ -plugins { - id("com.android.library") - kotlin("android") -} - -android { - namespace = "com.lalilu.ui" - compileSdk = libs.versions.compile.version.get().toIntOrNull() - - defaultConfig { - minSdk = libs.versions.min.sdk.version.get().toIntOrNull() - } - buildTypes { - release { - consumerProguardFiles("proguard-rules.pro") - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = "1.8" - } -} - -dependencies { - api(libs.gridlayout) - api(libs.constraintlayout) - api(libs.coordinatorlayout) - api(libs.recyclerview) - - implementation(project(":common")) -} \ No newline at end of file diff --git a/ui/proguard-rules.pro b/ui/proguard-rules.pro deleted file mode 100644 index ff59496d8..000000000 --- a/ui/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle.kts. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/ui/src/main/AndroidManifest.xml b/ui/src/main/AndroidManifest.xml deleted file mode 100644 index 44008a433..000000000 --- a/ui/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/ui/src/main/java/com/lalilu/ui/NewProgressBar.kt b/ui/src/main/java/com/lalilu/ui/NewProgressBar.kt deleted file mode 100644 index 1c4600d51..000000000 --- a/ui/src/main/java/com/lalilu/ui/NewProgressBar.kt +++ /dev/null @@ -1,353 +0,0 @@ -package com.lalilu.ui - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.Path -import android.graphics.RectF -import android.graphics.drawable.Drawable -import android.text.TextPaint -import android.util.AttributeSet -import android.view.View -import androidx.annotation.FloatRange -import androidx.annotation.IntRange -import com.blankj.utilcode.util.SizeUtils - -fun interface OnValueChangeListener { - fun onValueChange(value: Float) -} - -open class NewProgressBar @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null -) : View(context, attrs) { - - var bgColor = Color.argb(50, 100, 100, 100) - set(value) { - field = value - invalidate() - } - - /** - * 圆角半径 - */ - var radius: Float = 30f - set(value) { - field = value - updatePath() - invalidate() - } - - - var padding: Float = 0f - set(value) { - field = value - updatePath() - invalidate() - } - - /** - * 记录最大值 - */ - var minValue: Float = 0f - set(value) { - field = value - invalidate() - } - - /** - * 记录最大值 - */ - var maxValue: Float = 0f - set(value) { - field = value - invalidate() - } - - /** - * 当前的数据 - */ - var nowValue: Float = 0f - set(value) { - field = value.coerceIn(minValue, maxValue) - onValueChange(value) - invalidate() - } - - protected open fun onValueChange(value: Float) { - onValueChangeListener.forEach { it.onValueChange(value) } - } - - var minIncrement: Float = 0f - - /** - * 当前值文字颜色 - */ - var nowTextDarkModeColor: Int? = null - var nowTextColor: Int = Color.WHITE - get() { - if (isDarkModeNow()) return nowTextDarkModeColor ?: field - return field - } - set(value) { - field = value - nowTextPaint.color = value - invalidate() - } - - /** - * 最大值文字颜色 - */ - var maxTextDarkModeColor: Int? = null - var maxTextColor: Int = Color.WHITE - get() { - if (isDarkModeNow()) return maxTextDarkModeColor ?: field - return field - } - set(value) { - field = value - maxTextPaint.color = value - invalidate() - } - - /** - * 上层滑块颜色 - */ - var thumbDarkModeColor: Int? = null - var thumbColor: Int = Color.DKGRAY - get() { - if (isDarkModeNow()) return thumbDarkModeColor ?: field - return field - } - set(value) { - field = value - thumbPaint.color = value - invalidate() - } - - /** - * 外部框背景颜色 - * 绘制时将忽略该值的透明度 - * 由 [outSideAlpha] 控制其透明度 - */ - var outSideDarkModeColor: Int? = Color.DKGRAY - var outSideColor: Int = Color.WHITE - get() { - if (isDarkModeNow()) return outSideDarkModeColor ?: field - return field - } - set(value) { - field = value - invalidate() - } - - /** - * 外部框背景透明度 - */ - @IntRange(from = 0, to = 255) - var outSideAlpha: Int = 0 - set(value) { - field = value - invalidate() - } - - @FloatRange(from = 0.0, to = 1.0) - var switchModeProgress: Float = 0f - set(value) { - if (thumbTabs.isEmpty()) return - field = value - invalidate() - } - - var switchMoveX: Float = 0f - set(value) { - field = value - invalidate() - } - - val onValueChangeListener = HashSet() - protected val thumbTabs = ArrayList() - private var thumbWidth: Float = 0f - private var maxValueText: String = "" - private var nowValueText: String = "" - private var maxValueTextWidth: Float = 0f - private var nowValueTextWidth: Float = 0f - private var nowValueTextOffset: Float = 0f - private var thumbLeft: Float = 0f - private var thumbRight: Float = 0f - private val thumbCount: Int - get() = if (thumbTabs.size > 0) thumbTabs.size else 3 - - private var textHeight: Float = SizeUtils.sp2px(18f).toFloat() - private var textPadding: Long = 40L - private var pathInside = Path() - private var pathOutside = Path() - private var rect = RectF() - - private var thumbPaint: Paint = - Paint(Paint.ANTI_ALIAS_FLAG).also { - it.color = Color.DKGRAY - } - private var maxTextPaint = - TextPaint(Paint.ANTI_ALIAS_FLAG).also { - it.textSize = textHeight - it.isSubpixelText = true - } - private var nowTextPaint = - TextPaint(Paint.ANTI_ALIAS_FLAG).also { - it.textSize = textHeight - it.isSubpixelText = true - } - - override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { - updatePath() - } - - /** - * 将value转为String以用于绘制 - * 可按需求转成各种格式 - * eg. 00:00 - */ - open fun valueToText(value: Float): String { - return value.toString() - } - - /** - * 判断当前是否处于深色模式 - */ - open fun isDarkModeNow(): Boolean { - return false - } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - - val actualWidth = width - padding * 2f - - // 通过Value计算Progress,从而获取滑块应有的宽度 - thumbWidth = normalize(nowValue, minValue, maxValue) * actualWidth - thumbWidth = lerp(thumbWidth, actualWidth / thumbCount, switchModeProgress) - - maxValueText = valueToText(maxValue) - nowValueText = valueToText(nowValue) - maxValueTextWidth = maxTextPaint.measureText(maxValueText) - nowValueTextWidth = nowTextPaint.measureText(nowValueText) - - val textCenterHeight = height / 2f - (maxTextPaint.ascent() + maxTextPaint.descent()) / 2f - val offsetTemp = nowValueTextWidth + textPadding * 2 - - nowValueTextOffset = if (offsetTemp < thumbWidth) thumbWidth else offsetTemp - nowTextPaint.color = nowTextColor - maxTextPaint.color = maxTextColor - - nowTextPaint.alpha = lerp(0f, 255f, 1f - switchModeProgress).toInt() - maxTextPaint.alpha = nowTextPaint.alpha - thumbPaint.color = thumbColor - - thumbLeft = padding - val switchProgress = normalize(switchMoveX, thumbWidth / 2f, width - thumbWidth / 2f) - val switchOffset = lerp(thumbLeft, width - thumbLeft - thumbWidth, switchProgress) - thumbLeft = lerp(thumbLeft, switchOffset, switchModeProgress) - thumbRight = (thumbLeft + thumbWidth).coerceIn(0f, width.toFloat()) - - // 截取外部框范围 - canvas.clipPath(pathOutside) - - // 绘制外部框背景 - canvas.drawARGB( - outSideAlpha, - Color.red(outSideColor), - Color.green(outSideColor), - Color.blue(outSideColor) - ) - - // 只保留圆角矩形path部分 - canvas.clipPath(pathInside) - - // 绘制背景 - canvas.drawColor(bgColor) - - if (nowTextPaint.alpha != 0) { - // 绘制总时长文字 - canvas.drawText( - maxValueText, - width - maxValueTextWidth - textPadding, - textCenterHeight, - maxTextPaint - ) - } - - // 绘制进度条滑动块 - canvas.drawRoundRect( - thumbLeft, padding, - thumbRight, height - padding, - radius, radius, thumbPaint - ) - - if (nowTextPaint.alpha != 0) { - // 绘制进度时间文字 - canvas.drawText( - nowValueText, - nowValueTextOffset - nowValueTextWidth - textPadding, - textCenterHeight, - nowTextPaint - ) - } - - val switchThumbWidth = width / thumbCount - val switchModeAlpha = lerp(0f, 255f, switchModeProgress).toInt() - if (switchModeAlpha > 0) { - var drawX = 0f - for (tab in thumbTabs) { - tab.apply { - alpha = switchModeAlpha - - // 计算Drawable的原始宽高比 - val ratio = (intrinsicWidth.toFloat() / intrinsicHeight.toFloat()) - .takeIf { it > 0 } ?: 1f - - val itemHeight = textHeight * 1.2f - val itemWidth = itemHeight * ratio - - val itemLeft = drawX + (switchThumbWidth - itemWidth) / 2f - val itemTop = (height - itemHeight) / 2f - - setBounds( - itemLeft.toInt(), - itemTop.toInt(), - (itemLeft + itemWidth).toInt(), - (itemTop + itemHeight).toInt() - ) - draw(canvas) - } - drawX += switchThumbWidth - } - } - } - - private fun normalize(value: Float, min: Float, max: Float): Float { - return ((value - min) / (max - min)) - .coerceIn(0f, 1f) - } - - private fun lerp(from: Float, to: Float, fraction: Float): Float { - return (from + (to - from) * fraction) - .coerceIn(minOf(from, to), maxOf(from, to)) - } - - open fun updatePath() { - rect.set(0f, 0f, width.toFloat(), height.toFloat()) - pathOutside.reset() - pathOutside.addRoundRect(rect, radius * 1.2f, radius * 1.2f, Path.Direction.CW) - - rect.set(padding, padding, width - padding, height - padding) - pathInside.reset() - pathInside.addRoundRect(rect, radius, radius, Path.Direction.CW) - } - - init { - val attr = context.obtainStyledAttributes(attrs, R.styleable.NewProgressBar) - radius = attr.getDimension(R.styleable.NewProgressBar_radius, 30f) - attr.recycle() - } -} \ No newline at end of file diff --git a/ui/src/main/java/com/lalilu/ui/NewSeekBar.kt b/ui/src/main/java/com/lalilu/ui/NewSeekBar.kt deleted file mode 100644 index 52a0c09e8..000000000 --- a/ui/src/main/java/com/lalilu/ui/NewSeekBar.kt +++ /dev/null @@ -1,426 +0,0 @@ -package com.lalilu.ui - -import android.annotation.SuppressLint -import android.content.Context -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import android.view.GestureDetector -import android.view.MotionEvent -import androidx.annotation.IntDef -import androidx.core.view.GestureDetectorCompat -import androidx.dynamicanimation.animation.SpringAnimation -import androidx.dynamicanimation.animation.SpringForce -import androidx.dynamicanimation.animation.springAnimationOf -import androidx.dynamicanimation.animation.withSpringForceProperties -import com.blankj.utilcode.util.SizeUtils -import com.blankj.utilcode.util.TimeUtils -import com.lalilu.common.SystemUiUtil -import kotlin.math.abs - -const val CLICK_PART_UNSPECIFIED = 0 -const val CLICK_PART_LEFT = 1 -const val CLICK_PART_MIDDLE = 2 -const val CLICK_PART_RIGHT = 3 - -@IntDef( - CLICK_PART_UNSPECIFIED, - CLICK_PART_LEFT, - CLICK_PART_MIDDLE, - CLICK_PART_RIGHT -) -@Retention(AnnotationRetention.SOURCE) -annotation class ClickPart - -const val THRESHOLD_STATE_UNREACHED = 0 -const val THRESHOLD_STATE_REACHED = 1 -const val THRESHOLD_STATE_RETURN = 2 - -@IntDef( - THRESHOLD_STATE_REACHED, - THRESHOLD_STATE_UNREACHED, - THRESHOLD_STATE_RETURN -) -@Retention(AnnotationRetention.SOURCE) -annotation class ThresholdState - -fun interface OnSeekBarScrollListener { - fun onScroll(scrollValue: Float) -} - -fun interface OnSeekBarCancelListener { - fun onCancel() -} - -fun interface OnSeekBarSeekToListener { - fun onSeekTo(value: Float) -} - -fun interface OnTapEventListener { - fun onTapEvent() -} - -interface OnSeekBarClickListener { - fun onClick( - @ClickPart clickPart: Int = CLICK_PART_UNSPECIFIED, - action: Int - ) - - fun onLongClick( - @ClickPart clickPart: Int = CLICK_PART_UNSPECIFIED, - action: Int - ) - - fun onDoubleClick( - @ClickPart clickPart: Int = CLICK_PART_UNSPECIFIED, - action: Int - ) -} - -abstract class OnSeekBarScrollToThresholdListener( - private val threshold: () -> Number -) : OnSeekBarScrollListener { - abstract fun onScrollToThreshold() - open fun onScrollRecover() {} - - @ThresholdState - var state: Int = THRESHOLD_STATE_UNREACHED - set(value) { - if (field == value) return - when (value) { - THRESHOLD_STATE_REACHED -> onScrollToThreshold() - THRESHOLD_STATE_RETURN -> onScrollRecover() - } - field = value - } - - override fun onScroll(scrollValue: Float) { - state = if (scrollValue >= threshold().toFloat()) { - THRESHOLD_STATE_REACHED - } else { - if (state == THRESHOLD_STATE_REACHED) THRESHOLD_STATE_RETURN - else THRESHOLD_STATE_UNREACHED - } - } -} - -class NewSeekBar @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null -) : NewProgressBar(context, attrs) { - var cancelThreshold = 100f - - val scrollListeners = HashSet() - val clickListeners = HashSet() - val cancelListeners = HashSet() - val seekToListeners = HashSet() - val onTapLeaveListeners = HashSet() - val onTapEnterListeners = HashSet() - var valueToText: ((Float) -> String)? = null - private var switchToCallbacks = ArrayList Unit>>() - var switchIndexUpdateCallback: (Int) -> Unit = {} - - private var moved = false - private var canceled = true - private var touching = false - private var switchMode = false - - private var startValue: Float = nowValue - private var dataValue: Float = nowValue - private var sensitivity: Float = 1.3f - - private var downX: Float = 0f - private var downY: Float = 0f - private var lastX: Float = 0f - private var lastY: Float = 0f - - private val cancelScrollListener = - object : OnSeekBarScrollToThresholdListener(this::cancelThreshold) { - override fun onScrollToThreshold() { - animateValueTo(dataValue) - animateSwitchModeProgressTo(0f) - cancelListeners.forEach { it.onCancel() } - canceled = true - } - - override fun onScrollRecover() { - canceled = false - if (switchMode) { - animateSwitchModeProgressTo(100f) - } - } - } - - private val mProgressAnimation: SpringAnimation by lazy { - springAnimationOf( - setter = { updateProgress(it, false) }, - getter = { nowValue }, - finalPosition = nowValue - ).withSpringForceProperties { - dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY - stiffness = SpringForce.STIFFNESS_LOW - } - } - - private val mPaddingAnimation: SpringAnimation by lazy { - springAnimationOf( - setter = { - padding = it - outSideAlpha = (it * 50f).toInt() - }, - getter = { padding }, - finalPosition = padding - ).withSpringForceProperties { - dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY - stiffness = SpringForce.STIFFNESS_LOW - } - } - - private val mOutSideAlphaAnimation: SpringAnimation by lazy { - springAnimationOf( - setter = { outSideAlpha = it.toInt() }, - getter = { outSideAlpha.toFloat() }, - finalPosition = outSideAlpha.toFloat() - ).withSpringForceProperties { - dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY - stiffness = SpringForce.STIFFNESS_LOW - } - } - - private val mAlphaAnimation: SpringAnimation by lazy { - springAnimationOf( - setter = { alpha = it / 100f }, - getter = { alpha * 100f }, - finalPosition = 100f - ).withSpringForceProperties { - dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY - stiffness = SpringForce.STIFFNESS_LOW - } - } - - private val switchModeAnimation: SpringAnimation by lazy { - springAnimationOf( - setter = { switchModeProgress = it / 100f }, - getter = { switchModeProgress * 100f }, - finalPosition = 100f - ).withSpringForceProperties { - dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY - stiffness = SpringForce.STIFFNESS_LOW - } - } - - override fun valueToText(value: Float): String { - return valueToText?.invoke(value) ?: TimeUtils.millis2String(value.toLong(), "mm:ss") - } - - override fun isDarkModeNow(): Boolean { - return SystemUiUtil.isDarkMode(context) - } - - /** - * 判断触摸事件所点击的部分位置 - */ - fun checkClickPart(e: MotionEvent): Int { - return when (e.x.toInt()) { - in 0..(width * 1 / 3) -> CLICK_PART_LEFT - in (width * 1 / 3)..(width * 2 / 3) -> CLICK_PART_MIDDLE - in (width * 2 / 3)..width -> CLICK_PART_RIGHT - else -> CLICK_PART_UNSPECIFIED - } - } - - private val gestureDetector = GestureDetectorCompat(context, - object : GestureDetector.SimpleOnGestureListener() { - override fun onDown(e: MotionEvent): Boolean { - touching = true - moved = false - canceled = false - switchMode = false - startValue = nowValue - dataValue = nowValue - downX = e.x - downY = e.y - lastX = downX - lastY = downY - - animateScaleTo(SizeUtils.dp2px(3f).toFloat()) - animateOutSideAlphaTo(255f) - animateAlphaTo(100f) - - onTapEnterListeners.forEach(OnTapEventListener::onTapEvent) - return super.onDown(e) - } - - override fun onSingleTapConfirmed(e: MotionEvent): Boolean { - clickListeners.forEach { it.onClick(checkClickPart(e), e.action) } - performClick() - return super.onSingleTapConfirmed(e) - } - - override fun onDoubleTap(e: MotionEvent): Boolean { - clickListeners.forEach { it.onDoubleClick(checkClickPart(e), e.action) } - return super.onDoubleTap(e) - } - - override fun onLongPress(e: MotionEvent) { - clickListeners.forEach { it.onLongClick(checkClickPart(e), e.action) } - animateValueTo(startValue) - updateSwitchMoveX(e.x) - animateSwitchModeProgressTo(100f) - switchMode = true - } - }) - - private fun updateValueByDelta(delta: Float) { - if (touching && !canceled && !switchMode) { - mProgressAnimation.cancel() - val value = nowValue + delta / width * (maxValue - minValue) * sensitivity - updateProgress(value, true) - } - } - - private var switchIndex: Int = 0 - set(value) { - if (field == value) return - field = value - switchIndexUpdateCallback(value) - } - - fun updateSwitchMoveX(moveX: Float) { - switchMoveX = moveX - } - - fun updateSwitchIndex() { - switchIndex = getIntervalIndex( - a = 0f, - b = width.toFloat(), - n = switchToCallbacks.size, - x = switchMoveX - ) - } - - fun updateValue(value: Float) { - if (value !in minValue..maxValue) return - - if (!touching || canceled) { - animateValueTo(value) - } - dataValue = value - } - - fun updateProgress(value: Float, fromUser: Boolean = false) { - nowValue = value - } - - override fun onValueChange(value: Float) { - val actualValue = if (touching) value else dataValue - super.onValueChange(actualValue) - } - - fun setSwitchToCallback(vararg callbackPair: Pair Unit>) { - switchToCallbacks.clear() - switchToCallbacks.addAll(callbackPair) - thumbTabs.clear() - thumbTabs.addAll(switchToCallbacks.map { it.first }) - } - - /** - * GestureDetector 没有抬起相关的事件回调, - * 在OnTouchView中自行处理抬起相关逻辑 - */ - @SuppressLint("ClickableViewAccessibility") - override fun onTouchEvent(event: MotionEvent): Boolean { - gestureDetector.onTouchEvent(event) - when (event.action) { - MotionEvent.ACTION_UP, - MotionEvent.ACTION_POINTER_UP, - MotionEvent.ACTION_CANCEL -> { - onTapLeaveListeners.forEach(OnTapEventListener::onTapEvent) - - if (moved && !canceled) { - if (switchMode) { - updateSwitchIndex() - switchToCallbacks.getOrNull(switchIndex)?.second?.invoke() - } else if (abs(nowValue - startValue) > minIncrement) { - seekToListeners.forEach { it.onSeekTo(nowValue) } - } - } - animateScaleTo(0f) - animateOutSideAlphaTo(0f) - animateSwitchModeProgressTo(0f) - touching = false - canceled = false - switchMode = false - moved = false - - scrollListeners.forEach { - if (it is OnSeekBarScrollToThresholdListener) { - it.state = THRESHOLD_STATE_UNREACHED - } - } - - parent.requestDisallowInterceptTouchEvent(false) - } - - - // GestureDetector在OnLongPressed后不会再回调OnScrolled,所以自己处理ACTION_MOVE事件 - MotionEvent.ACTION_MOVE -> { - if (!touching) return true - - val deltaX: Float = event.x - lastX - val deltaY: Float = event.y - lastY - - moved = true - if (switchMode) { - updateSwitchMoveX(event.x) - updateSwitchIndex() - } else { - updateValueByDelta(deltaX) - } - - scrollListeners.forEach { it.onScroll((-event.y).coerceAtLeast(0f)) } - parent.requestDisallowInterceptTouchEvent(true) - - lastX = event.x - lastY = event.y - } - } - return true - } - - private fun getIntervalIndex(a: Float, b: Float, n: Int, x: Float): Int { - if (x < a) return 0 - if (x > b) return n - 1 - - val intervalSize = (b - a) / n // 区间大小 - val index = ((x - a) / intervalSize).toInt() // 计算区间索引 - return if (index >= n) n - 1 else index - } - - fun animateOutSideAlphaTo(value: Float) { - mOutSideAlphaAnimation.cancel() - mOutSideAlphaAnimation.animateToFinalPosition(value) - } - - fun animateScaleTo(value: Float) { - mPaddingAnimation.cancel() - mPaddingAnimation.animateToFinalPosition(value) - } - - fun animateValueTo(value: Float) { - mProgressAnimation.cancel() - mProgressAnimation.animateToFinalPosition(value) - } - - fun animateAlphaTo(value: Float) { - mAlphaAnimation.cancel() - mAlphaAnimation.animateToFinalPosition(value) - } - - fun animateSwitchModeProgressTo(value: Float) { - switchModeAnimation.cancel() - switchModeAnimation.animateToFinalPosition(value) - } - - init { - scrollListeners.add(cancelScrollListener) - } -} \ No newline at end of file diff --git a/ui/src/main/res/values/attrs.xml b/ui/src/main/res/values/attrs.xml deleted file mode 100644 index d757273be..000000000 --- a/ui/src/main/res/values/attrs.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file