The {@link SeekBar} within the preference can be defined adjustable or not by setting {@code
- * adjustable} attribute. If adjustable, the preference will be responsive to DPAD left/right keys.
- * Otherwise, it skips those keys.
- *
- * The {@link SeekBar} value view can be shown or disabled by setting {@code showSeekBarValue}
- * attribute to true or false, respectively.
- *
- *
Other {@link SeekBar} specific attributes (e.g. {@code title, summary, defaultValue, min,
- * max})
- * can be set directly on the preference widget layout.
- */
-public class NeoSeekBarPreference extends Preference {
-
- private static final String TAG = "SeekBarPreference";
- @SuppressWarnings("WeakerAccess") /* synthetic access */
- int mSeekBarValue;
- @SuppressWarnings("WeakerAccess") /* synthetic access */
- int mMin;
- private int mMax;
- private int mSeekBarIncrement;
- @SuppressWarnings("WeakerAccess") /* synthetic access */
- boolean mTrackingTouch;
- @SuppressWarnings("WeakerAccess") /* synthetic access */
- SeekBar mSeekBar;
- private TextView mSeekBarValueTextView;
- // Whether the SeekBar should respond to the left/right keys
- @SuppressWarnings("WeakerAccess") /* synthetic access */
- boolean mAdjustable;
- // Whether to show the SeekBar value TextView next to the bar
- private boolean mShowSeekBarValue;
- // Whether the SeekBarPreference should continuously save the Seekbar value while it is being
- // dragged.
- @SuppressWarnings("WeakerAccess") /* synthetic access */
- boolean mUpdatesContinuously;
- /**
- * Listener reacting to the {@link SeekBar} changing value by the user
- */
- private final OnSeekBarChangeListener mSeekBarChangeListener = new OnSeekBarChangeListener() {
- @Override
- public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
- if (fromUser && (mUpdatesContinuously || !mTrackingTouch)) {
- syncValueInternal(seekBar);
- } else {
- // We always want to update the text while the seekbar is being dragged
- updateLabelValue(progress + mMin);
- }
- }
-
- @Override
- public void onStartTrackingTouch(SeekBar seekBar) {
- mTrackingTouch = true;
- }
-
- @Override
- public void onStopTrackingTouch(SeekBar seekBar) {
- mTrackingTouch = false;
- if (seekBar.getProgress() + mMin != mSeekBarValue) {
- syncValueInternal(seekBar);
- }
- }
- };
-
- /**
- * Listener reacting to the user pressing DPAD left/right keys if {@code
- * adjustable} attribute is set to true; it transfers the key presses to the {@link SeekBar}
- * to be handled accordingly.
- */
- private final View.OnKeyListener mSeekBarKeyListener = new View.OnKeyListener() {
- @Override
- public boolean onKey(View v, int keyCode, KeyEvent event) {
- if (event.getAction() != KeyEvent.ACTION_DOWN) {
- return false;
- }
-
- if (!mAdjustable && (keyCode == KeyEvent.KEYCODE_DPAD_LEFT
- || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT)) {
- // Right or left keys are pressed when in non-adjustable mode; Skip the keys.
- return false;
- }
-
- // We don't want to propagate the click keys down to the SeekBar view since it will
- // create the ripple effect for the thumb.
- if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) {
- return false;
- }
-
- if (mSeekBar == null) {
- Log.e(TAG, "SeekBar view is null and hence cannot be adjusted.");
- return false;
- }
- return mSeekBar.onKeyDown(keyCode, event);
- }
- };
-
- public NeoSeekBarPreference(
- @NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr,
- int defStyleRes) {
- super(context, attrs, defStyleAttr, defStyleRes);
-
- TypedArray a = context.obtainStyledAttributes(
- attrs, R.styleable.SeekBarPreference, defStyleAttr, defStyleRes);
-
- // The ordering of these two statements are important. If we want to set max first, we need
- // to perform the same steps by changing min/max to max/min as following:
- // mMax = a.getInt(...) and setMin(...).
- mMin = a.getInt(R.styleable.SeekBarPreference_min, 0);
- setMax(a.getInt(R.styleable.SeekBarPreference_android_max, 100));
- setSeekBarIncrement(a.getInt(R.styleable.SeekBarPreference_seekBarIncrement, 0));
- mAdjustable = a.getBoolean(R.styleable.SeekBarPreference_adjustable, true);
- mShowSeekBarValue = a.getBoolean(R.styleable.SeekBarPreference_showSeekBarValue, false);
- mUpdatesContinuously = a.getBoolean(R.styleable.SeekBarPreference_updatesContinuously,
- false);
- a.recycle();
- }
-
- public NeoSeekBarPreference(@NonNull Context context, @Nullable AttributeSet attrs,
- int defStyleAttr) {
- this(context, attrs, defStyleAttr, 0);
- }
-
- public NeoSeekBarPreference(@NonNull Context context, @Nullable AttributeSet attrs) {
- this(context, attrs, R.attr.seekBarPreferenceStyle);
- }
-
- public NeoSeekBarPreference(@NonNull Context context) {
- this(context, null);
- }
-
- @Override
- public void onBindViewHolder(@NonNull PreferenceViewHolder holder) {
- super.onBindViewHolder(holder);
- holder.itemView.setOnKeyListener(mSeekBarKeyListener);
- mSeekBar = (SeekBar) holder.findViewById(R.id.seekbar);
- mSeekBarValueTextView = (TextView) holder.findViewById(R.id.seekbar_value);
- if (mShowSeekBarValue) {
- mSeekBarValueTextView.setVisibility(View.VISIBLE);
- } else {
- mSeekBarValueTextView.setVisibility(View.GONE);
- mSeekBarValueTextView = null;
- }
-
- if (mSeekBar == null) {
- Log.e(TAG, "SeekBar view is null in onBindViewHolder.");
- return;
- }
- mSeekBar.setOnSeekBarChangeListener(mSeekBarChangeListener);
- mSeekBar.setMax(mMax - mMin);
- // If the increment is not zero, use that. Otherwise, use the default mKeyProgressIncrement
- // in AbsSeekBar when it's zero. This default increment value is set by AbsSeekBar
- // after calling setMax. That's why it's important to call setKeyProgressIncrement after
- // calling setMax() since setMax() can change the increment value.
- if (mSeekBarIncrement != 0) {
- mSeekBar.setKeyProgressIncrement(mSeekBarIncrement);
- } else {
- mSeekBarIncrement = mSeekBar.getKeyProgressIncrement();
- }
-
- mSeekBar.setProgress(mSeekBarValue - mMin);
- updateLabelValue(mSeekBarValue);
- mSeekBar.setEnabled(isEnabled());
- }
-
- @Override
- protected void onSetInitialValue(Object defaultValue) {
- if (defaultValue == null) {
- defaultValue = 0;
- }
- setValue(getPersistedInt((Integer) defaultValue));
- }
-
- @Override
- protected @Nullable
- Object onGetDefaultValue(@NonNull TypedArray a, int index) {
- return a.getInt(index, 0);
- }
-
- /**
- * Gets the lower bound set on the {@link SeekBar}.
- *
- * @return The lower bound set
- */
- public int getMin() {
- return mMin;
- }
-
- /**
- * Sets the lower bound on the {@link SeekBar}.
- *
- * @param min The lower bound to set
- */
- public void setMin(int min) {
- if (min > mMax) {
- min = mMax;
- }
- if (min != mMin) {
- mMin = min;
- notifyChanged();
- }
- }
-
- /**
- * Returns the amount of increment change via each arrow key click. This value is derived from
- * user's specified increment value if it's not zero. Otherwise, the default value is picked
- * from the default mKeyProgressIncrement value in {@link android.widget.AbsSeekBar}.
- *
- * @return The amount of increment on the {@link SeekBar} performed after each user's arrow
- * key press
- */
- public final int getSeekBarIncrement() {
- return mSeekBarIncrement;
- }
-
- /**
- * Sets the increment amount on the {@link SeekBar} for each arrow key press.
- *
- * @param seekBarIncrement The amount to increment or decrement when the user presses an
- * arrow key.
- */
- public final void setSeekBarIncrement(int seekBarIncrement) {
- if (seekBarIncrement != mSeekBarIncrement) {
- mSeekBarIncrement = Math.min(mMax - mMin, Math.abs(seekBarIncrement));
- notifyChanged();
- }
- }
-
- /**
- * Gets the upper bound set on the {@link SeekBar}.
- *
- * @return The upper bound set
- */
- public int getMax() {
- return mMax;
- }
-
- /**
- * Sets the upper bound on the {@link SeekBar}.
- *
- * @param max The upper bound to set
- */
- public final void setMax(int max) {
- if (max < mMin) {
- max = mMin;
- }
- if (max != mMax) {
- mMax = max;
- notifyChanged();
- }
- }
-
- /**
- * Gets whether the {@link SeekBar} should respond to the left/right keys.
- *
- * @return Whether the {@link SeekBar} should respond to the left/right keys
- */
- public boolean isAdjustable() {
- return mAdjustable;
- }
-
- /**
- * Sets whether the {@link SeekBar} should respond to the left/right keys.
- *
- * @param adjustable Whether the {@link SeekBar} should respond to the left/right keys
- */
- public void setAdjustable(boolean adjustable) {
- mAdjustable = adjustable;
- }
-
- /**
- * Gets whether the {@link NeoSeekBarPreference} should continuously save the {@link SeekBar} value
- * while it is being dragged. Note that when the value is true,
- * {@link Preference.OnPreferenceChangeListener} will be called continuously as well.
- *
- * @return Whether the {@link NeoSeekBarPreference} should continuously save the {@link SeekBar}
- * value while it is being dragged
- * @see #setUpdatesContinuously(boolean)
- */
- public boolean getUpdatesContinuously() {
- return mUpdatesContinuously;
- }
-
- /**
- * Sets whether the {@link NeoSeekBarPreference} should continuously save the {@link SeekBar} value
- * while it is being dragged.
- *
- * @param updatesContinuously Whether the {@link NeoSeekBarPreference} should continuously save
- * the {@link SeekBar} value while it is being dragged
- * @see #getUpdatesContinuously()
- */
- public void setUpdatesContinuously(boolean updatesContinuously) {
- mUpdatesContinuously = updatesContinuously;
- }
-
- /**
- * Gets whether the current {@link SeekBar} value is displayed to the user.
- *
- * @return Whether the current {@link SeekBar} value is displayed to the user
- * @see #setShowSeekBarValue(boolean)
- */
- public boolean getShowSeekBarValue() {
- return mShowSeekBarValue;
- }
-
- /**
- * Sets whether the current {@link SeekBar} value is displayed to the user.
- *
- * @param showSeekBarValue Whether the current {@link SeekBar} value is displayed to the user
- * @see #getShowSeekBarValue()
- */
- public void setShowSeekBarValue(boolean showSeekBarValue) {
- mShowSeekBarValue = showSeekBarValue;
- notifyChanged();
- }
-
- private void setValueInternal(int seekBarValue, boolean notifyChanged) {
- if (seekBarValue < mMin) {
- seekBarValue = mMin;
- }
- if (seekBarValue > mMax) {
- seekBarValue = mMax;
- }
-
- if (seekBarValue != mSeekBarValue) {
- mSeekBarValue = seekBarValue;
- updateLabelValue(mSeekBarValue);
- persistInt(seekBarValue);
- if (notifyChanged) {
- notifyChanged();
- }
- }
- }
-
- /**
- * Gets the current progress of the {@link SeekBar}.
- *
- * @return The current progress of the {@link SeekBar}
- */
- public int getValue() {
- return mSeekBarValue;
- }
-
- /**
- * Sets the current progress of the {@link SeekBar}.
- *
- * @param seekBarValue The current progress of the {@link SeekBar}
- */
- public void setValue(int seekBarValue) {
- setValueInternal(seekBarValue, true);
- }
-
- /**
- * Persist the {@link SeekBar}'s SeekBar value if callChangeListener returns true, otherwise
- * set the {@link SeekBar}'s value to the stored value.
- */
- @SuppressWarnings("WeakerAccess") /* synthetic access */
- void syncValueInternal(@NonNull SeekBar seekBar) {
- int seekBarValue = mMin + seekBar.getProgress();
- if (seekBarValue != mSeekBarValue) {
- if (callChangeListener(seekBarValue)) {
- setValueInternal(seekBarValue, false);
- } else {
- seekBar.setProgress(mSeekBarValue - mMin);
- updateLabelValue(mSeekBarValue);
- }
- }
- }
-
- // MODIFIED: Custom label formatter
- private Function labelFormatter = String::valueOf;
-
- public void setLabelFormatter(Function formatter) {
- labelFormatter = formatter;
- }
-
- /**
- * Attempts to update the TextView label that displays the current value.
- *
- * @param value the value to display next to the {@link SeekBar}
- */
- @SuppressWarnings("WeakerAccess") /* synthetic access */
- void updateLabelValue(int value) {
- if (mSeekBarValueTextView != null) {
- mSeekBarValueTextView.setText(labelFormatter.apply(value));
- }
- }
-
- @Nullable
- @Override
- protected Parcelable onSaveInstanceState() {
- final Parcelable superState = super.onSaveInstanceState();
- if (isPersistent()) {
- // No need to save instance state since it's persistent
- return superState;
- }
-
- // Save the instance state
- final SavedState myState = new SavedState(superState);
- myState.mSeekBarValue = mSeekBarValue;
- myState.mMin = mMin;
- myState.mMax = mMax;
- return myState;
- }
-
- @Override
- protected void onRestoreInstanceState(@Nullable Parcelable state) {
- if (state == null || !state.getClass().equals(SavedState.class)) {
- // Didn't save state for us in onSaveInstanceState
- super.onRestoreInstanceState(state);
- return;
- }
-
- // Restore the instance state
- SavedState myState = (SavedState) state;
- super.onRestoreInstanceState(myState.getSuperState());
- mSeekBarValue = myState.mSeekBarValue;
- mMin = myState.mMin;
- mMax = myState.mMax;
- notifyChanged();
- }
-
- /**
- * SavedState, a subclass of {@link BaseSavedState}, will store the state of this preference.
- *
- * It is important to always call through to super methods.
- */
- private static class SavedState extends BaseSavedState {
- public static final Parcelable.Creator CREATOR =
- new Parcelable.Creator() {
- @Override
- public SavedState createFromParcel(Parcel in) {
- return new SavedState(in);
- }
-
- @Override
- public SavedState[] newArray(int size) {
- return new SavedState[size];
- }
- };
-
- int mSeekBarValue;
- int mMin;
- int mMax;
-
- SavedState(Parcel source) {
- super(source);
-
- // Restore the click counter
- mSeekBarValue = source.readInt();
- mMin = source.readInt();
- mMax = source.readInt();
- }
-
- SavedState(Parcelable superState) {
- super(superState);
- }
-
- @Override
- public void writeToParcel(Parcel dest, int flags) {
- super.writeToParcel(dest, flags);
-
- // Save the click counter
- dest.writeInt(mSeekBarValue);
- dest.writeInt(mMin);
- dest.writeInt(mMax);
- }
- }
-}
diff --git a/app/src/main/java/com/google/android/material/bottomsheet/NeoBottomSheetBehavior.java b/app/src/main/java/com/google/android/material/bottomsheet/NeoBottomSheetBehavior.java
deleted file mode 100644
index 39c0d54a5..000000000
--- a/app/src/main/java/com/google/android/material/bottomsheet/NeoBottomSheetBehavior.java
+++ /dev/null
@@ -1,2210 +0,0 @@
-/*
- * Copyright (C) 2015 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.
- */
-
-package com.google.android.material.bottomsheet;
-
-import com.google.android.material.R;
-
-import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
-import static java.lang.Math.max;
-import static java.lang.Math.min;
-
-import android.animation.ValueAnimator;
-import android.animation.ValueAnimator.AnimatorUpdateListener;
-import android.annotation.SuppressLint;
-import android.content.Context;
-import android.content.res.ColorStateList;
-import android.content.res.TypedArray;
-import android.os.Build;
-import android.os.Build.VERSION;
-import android.os.Build.VERSION_CODES;
-import android.os.Parcel;
-import android.os.Parcelable;
-import android.util.AttributeSet;
-import android.util.Log;
-import android.util.TypedValue;
-import android.view.MotionEvent;
-import android.view.VelocityTracker;
-import android.view.View;
-import android.view.View.MeasureSpec;
-import android.view.ViewConfiguration;
-import android.view.ViewGroup;
-import android.view.ViewGroup.MarginLayoutParams;
-import android.view.ViewParent;
-import android.view.accessibility.AccessibilityEvent;
-import androidx.annotation.FloatRange;
-import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.Px;
-import androidx.annotation.RestrictTo;
-import androidx.annotation.StringRes;
-import androidx.annotation.VisibleForTesting;
-import androidx.coordinatorlayout.widget.CoordinatorLayout;
-import androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams;
-import androidx.core.graphics.Insets;
-import androidx.core.math.MathUtils;
-import androidx.core.view.ViewCompat;
-import androidx.core.view.WindowInsetsCompat;
-import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
-import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat;
-import androidx.core.view.accessibility.AccessibilityViewCommand;
-import androidx.customview.view.AbsSavedState;
-import androidx.customview.widget.ViewDragHelper;
-import com.google.android.material.internal.ViewUtils;
-import com.google.android.material.internal.ViewUtils.RelativePadding;
-import com.google.android.material.resources.MaterialResources;
-import com.google.android.material.shape.MaterialShapeDrawable;
-import com.google.android.material.shape.ShapeAppearanceModel;
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-import java.lang.ref.WeakReference;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.Map;
-
-/**
- * An interaction behavior plugin for a child view of {@link CoordinatorLayout} to make it work as a
- * bottom sheet.
- *
- * To send useful accessibility events, set a title on bottom sheets that are windows or are
- * window-like. For BottomSheetDialog use {@link BottomSheetDialog#setTitle(int)}, and for
- * BottomSheetDialogFragment use {@link ViewCompat#setAccessibilityPaneTitle(View, CharSequence)}.
- */
-public class NeoBottomSheetBehavior extends CoordinatorLayout.Behavior {
-
- /** Callback for monitoring events about bottom sheets. */
- public abstract static class BottomSheetCallback {
-
- /**
- * Called when the bottom sheet changes its state.
- *
- * @param bottomSheet The bottom sheet view.
- * @param newState The new state. This will be one of {@link #STATE_DRAGGING}, {@link
- * #STATE_SETTLING}, {@link #STATE_EXPANDED}, {@link #STATE_COLLAPSED}, {@link
- * #STATE_HIDDEN}, or {@link #STATE_HALF_EXPANDED}.
- */
- public abstract void onStateChanged(@NonNull View bottomSheet, @State int newState);
-
- /**
- * Called when the bottom sheet is being dragged.
- *
- * @param bottomSheet The bottom sheet view.
- * @param slideOffset The new offset of this bottom sheet within [-1,1] range. Offset increases
- * as this bottom sheet is moving upward. From 0 to 1 the sheet is between collapsed and
- * expanded states and from -1 to 0 it is between hidden and collapsed states.
- */
- public abstract void onSlide(@NonNull View bottomSheet, float slideOffset);
-
- void onLayout(@NonNull View bottomSheet) {}
- }
-
- /** The bottom sheet is dragging. */
- public static final int STATE_DRAGGING = 1;
-
- /** The bottom sheet is settling. */
- public static final int STATE_SETTLING = 2;
-
- /** The bottom sheet is expanded. */
- public static final int STATE_EXPANDED = 3;
-
- /** The bottom sheet is collapsed. */
- public static final int STATE_COLLAPSED = 4;
-
- /** The bottom sheet is hidden. */
- public static final int STATE_HIDDEN = 5;
-
- /** The bottom sheet is half-expanded (used when fitToContents is false). */
- public static final int STATE_HALF_EXPANDED = 6;
-
- /** @hide */
- @RestrictTo(LIBRARY_GROUP)
- @IntDef({
- STATE_EXPANDED,
- STATE_COLLAPSED,
- STATE_DRAGGING,
- STATE_SETTLING,
- STATE_HIDDEN,
- STATE_HALF_EXPANDED
- })
- @Retention(RetentionPolicy.SOURCE)
- public @interface State {}
-
- /**
- * Stable states that can be set by the {@link #setState(int)} method. These includes all the
- * possible states a bottom sheet can be in when it's settled.
- *
- * @hide
- */
- @RestrictTo(LIBRARY_GROUP)
- @IntDef({STATE_EXPANDED, STATE_COLLAPSED, STATE_HIDDEN, STATE_HALF_EXPANDED})
- @Retention(RetentionPolicy.SOURCE)
- public @interface StableState {}
-
- /**
- * Peek at the 16:9 ratio keyline of its parent.
- *
- * This can be used as a parameter for {@link #setPeekHeight(int)}. {@link #getPeekHeight()}
- * will return this when the value is set.
- */
- public static final int PEEK_HEIGHT_AUTO = -1;
-
- /** This flag will preserve the peekHeight int value on configuration change. */
- public static final int SAVE_PEEK_HEIGHT = 0x1;
-
- /** This flag will preserve the fitToContents boolean value on configuration change. */
- public static final int SAVE_FIT_TO_CONTENTS = 1 << 1;
-
- /** This flag will preserve the hideable boolean value on configuration change. */
- public static final int SAVE_HIDEABLE = 1 << 2;
-
- /** This flag will preserve the skipCollapsed boolean value on configuration change. */
- public static final int SAVE_SKIP_COLLAPSED = 1 << 3;
-
- /** This flag will preserve all aforementioned values on configuration change. */
- public static final int SAVE_ALL = -1;
-
- /**
- * This flag will not preserve the aforementioned values set at runtime if the view is destroyed
- * and recreated. The only value preserved will be the positional state, e.g. collapsed, hidden,
- * expanded, etc. This is the default behavior.
- */
- public static final int SAVE_NONE = 0;
-
- /** @hide */
- @RestrictTo(LIBRARY_GROUP)
- @IntDef(
- flag = true,
- value = {
- SAVE_PEEK_HEIGHT,
- SAVE_FIT_TO_CONTENTS,
- SAVE_HIDEABLE,
- SAVE_SKIP_COLLAPSED,
- SAVE_ALL,
- SAVE_NONE,
- })
- @Retention(RetentionPolicy.SOURCE)
- public @interface SaveFlags {}
-
- private static final String TAG = "NeoBottomSheetBehavior";
-
- @SaveFlags private int saveFlags = SAVE_NONE;
-
- @VisibleForTesting static final int DEFAULT_SIGNIFICANT_VEL_THRESHOLD = 500;
-
- private static final float HIDE_THRESHOLD = 0.5f;
-
- private static final float HIDE_FRICTION = 0.1f;
-
- private static final int CORNER_ANIMATION_DURATION = 500;
-
- private static final int NO_MAX_SIZE = -1;
-
- private boolean fitToContents = true;
-
- private boolean updateImportantForAccessibilityOnSiblings = false;
-
- private float maximumVelocity;
-
- private int significantVelocityThreshold;
-
- /** Peek height set by the user. */
- private int peekHeight;
-
- /** Whether or not to use automatic peek height. */
- private boolean peekHeightAuto;
-
- /** Minimum peek height permitted. */
- private int peekHeightMin;
-
- /** Peek height gesture inset buffer to ensure enough swipeable space. */
- private int peekHeightGestureInsetBuffer;
-
- private MaterialShapeDrawable materialShapeDrawable;
-
- @Nullable private ColorStateList backgroundTint;
-
- private int maxWidth = NO_MAX_SIZE;
-
- private int maxHeight = NO_MAX_SIZE;
-
- private int gestureInsetBottom;
- private boolean gestureInsetBottomIgnored;
- private boolean paddingBottomSystemWindowInsets;
- private boolean paddingLeftSystemWindowInsets;
- private boolean paddingRightSystemWindowInsets;
- private boolean paddingTopSystemWindowInsets;
- private boolean marginLeftSystemWindowInsets;
- private boolean marginRightSystemWindowInsets;
- private boolean marginTopSystemWindowInsets;
-
- private int insetBottom;
- private int insetTop;
-
- /** Default Shape Appearance to be used in bottomsheet */
- private ShapeAppearanceModel shapeAppearanceModelDefault;
-
- private boolean isShapeExpanded;
-
- private final StateSettlingTracker stateSettlingTracker = new StateSettlingTracker();
-
- @Nullable private ValueAnimator interpolatorAnimator;
-
- private static final int DEF_STYLE_RES = R.style.Widget_Design_BottomSheet_Modal;
-
- int expandedOffset;
-
- int fitToContentsOffset;
-
- int halfExpandedOffset;
-
- float halfExpandedRatio = 0.5f;
-
- int collapsedOffset;
-
- float elevation = -1;
-
- boolean hideable;
-
- private boolean skipCollapsed;
-
- private boolean draggable = true;
-
- @State int state = STATE_COLLAPSED;
-
- @State int lastStableState = STATE_COLLAPSED;
-
- @Nullable ViewDragHelper viewDragHelper;
-
- private boolean ignoreEvents;
-
- private int lastNestedScrollDy;
-
- private boolean nestedScrolled;
-
- private float hideFriction = HIDE_FRICTION;
-
- private int childHeight;
- int parentWidth;
- int parentHeight;
-
- @Nullable WeakReference viewRef;
-
- @Nullable WeakReference nestedScrollingChildRef;
-
- @NonNull private final ArrayList callbacks = new ArrayList<>();
-
- @Nullable private VelocityTracker velocityTracker;
-
- int activePointerId;
-
- private int initialY;
-
- boolean touchingScrollingChild;
-
- @Nullable private Map importantForAccessibilityMap;
-
- private int expandHalfwayActionId = View.NO_ID;
-
- public NeoBottomSheetBehavior() {}
-
- @SuppressLint("RestrictedApi")
- public NeoBottomSheetBehavior(@NonNull Context context, @Nullable AttributeSet attrs) {
- super(context, attrs);
-
- peekHeightGestureInsetBuffer =
- context.getResources().getDimensionPixelSize(R.dimen.mtrl_min_touch_target_size);
-
- TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.BottomSheetBehavior_Layout);
- if (a.hasValue(R.styleable.BottomSheetBehavior_Layout_backgroundTint)) {
- this.backgroundTint = MaterialResources.getColorStateList(
- context, a, R.styleable.BottomSheetBehavior_Layout_backgroundTint);
- }
- if (a.hasValue(R.styleable.BottomSheetBehavior_Layout_shapeAppearance)) {
- this.shapeAppearanceModelDefault =
- ShapeAppearanceModel.builder(context, attrs, R.attr.bottomSheetStyle, DEF_STYLE_RES)
- .build();
- }
- createMaterialShapeDrawableIfNeeded(context);
- createShapeValueAnimator();
-
- if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
- this.elevation = a.getDimension(R.styleable.BottomSheetBehavior_Layout_android_elevation, -1);
- }
-
- if (a.hasValue(R.styleable.BottomSheetBehavior_Layout_android_maxWidth)) {
- setMaxWidth(
- a.getDimensionPixelSize(
- R.styleable.BottomSheetBehavior_Layout_android_maxWidth, NO_MAX_SIZE));
- }
-
- if (a.hasValue(R.styleable.BottomSheetBehavior_Layout_android_maxHeight)) {
- setMaxHeight(
- a.getDimensionPixelSize(
- R.styleable.BottomSheetBehavior_Layout_android_maxHeight, NO_MAX_SIZE));
- }
-
- TypedValue value = a.peekValue(R.styleable.BottomSheetBehavior_Layout_behavior_peekHeight);
- if (value != null && value.data == PEEK_HEIGHT_AUTO) {
- setPeekHeight(value.data);
- } else {
- setPeekHeight(
- a.getDimensionPixelSize(
- R.styleable.BottomSheetBehavior_Layout_behavior_peekHeight, PEEK_HEIGHT_AUTO));
- }
- setHideable(a.getBoolean(R.styleable.BottomSheetBehavior_Layout_behavior_hideable, false));
- setGestureInsetBottomIgnored(
- a.getBoolean(R.styleable.BottomSheetBehavior_Layout_gestureInsetBottomIgnored, false));
- setFitToContents(
- a.getBoolean(R.styleable.BottomSheetBehavior_Layout_behavior_fitToContents, true));
- setSkipCollapsed(
- a.getBoolean(R.styleable.BottomSheetBehavior_Layout_behavior_skipCollapsed, false));
- setDraggable(a.getBoolean(R.styleable.BottomSheetBehavior_Layout_behavior_draggable, true));
- setSaveFlags(a.getInt(R.styleable.BottomSheetBehavior_Layout_behavior_saveFlags, SAVE_NONE));
- setHalfExpandedRatio(
- a.getFloat(R.styleable.BottomSheetBehavior_Layout_behavior_halfExpandedRatio, 0.5f));
-
- value = a.peekValue(R.styleable.BottomSheetBehavior_Layout_behavior_expandedOffset);
- if (value != null && value.type == TypedValue.TYPE_FIRST_INT) {
- setExpandedOffset(value.data);
- } else {
- setExpandedOffset(
- a.getDimensionPixelOffset(
- R.styleable.BottomSheetBehavior_Layout_behavior_expandedOffset, 0));
- }
-
- setSignificantVelocityThreshold(
- a.getInt(
- R.styleable.BottomSheetBehavior_Layout_behavior_significantVelocityThreshold,
- DEFAULT_SIGNIFICANT_VEL_THRESHOLD));
-
- // Reading out if we are handling padding, so we can apply it to the content.
- paddingBottomSystemWindowInsets =
- a.getBoolean(R.styleable.BottomSheetBehavior_Layout_paddingBottomSystemWindowInsets, false);
- paddingLeftSystemWindowInsets =
- a.getBoolean(R.styleable.BottomSheetBehavior_Layout_paddingLeftSystemWindowInsets, false);
- paddingRightSystemWindowInsets =
- a.getBoolean(R.styleable.BottomSheetBehavior_Layout_paddingRightSystemWindowInsets, false);
- // Setting this to false will prevent the bottomsheet from going below the status bar. Since
- // this is a breaking change from the old behavior the default is true.
- paddingTopSystemWindowInsets =
- a.getBoolean(R.styleable.BottomSheetBehavior_Layout_paddingTopSystemWindowInsets, true);
- marginLeftSystemWindowInsets =
- a.getBoolean(R.styleable.BottomSheetBehavior_Layout_marginLeftSystemWindowInsets, false);
- marginRightSystemWindowInsets =
- a.getBoolean(R.styleable.BottomSheetBehavior_Layout_marginRightSystemWindowInsets, false);
- marginTopSystemWindowInsets =
- a.getBoolean(R.styleable.BottomSheetBehavior_Layout_marginTopSystemWindowInsets, false);
-
- a.recycle();
- ViewConfiguration configuration = ViewConfiguration.get(context);
- maximumVelocity = configuration.getScaledMaximumFlingVelocity();
- }
-
- @NonNull
- @Override
- public Parcelable onSaveInstanceState(@NonNull CoordinatorLayout parent, @NonNull V child) {
- return new SavedState(super.onSaveInstanceState(parent, child), this);
- }
-
- @Override
- public void onRestoreInstanceState(
- @NonNull CoordinatorLayout parent, @NonNull V child, @NonNull Parcelable state) {
- SavedState ss = (SavedState) state;
- super.onRestoreInstanceState(parent, child, ss.getSuperState());
- // Restore Optional State values designated by saveFlags
- restoreOptionalState(ss);
- // Intermediate states are restored as collapsed state
- if (ss.state == STATE_DRAGGING || ss.state == STATE_SETTLING) {
- this.state = STATE_COLLAPSED;
- this.lastStableState = this.state;
- } else {
- this.state = ss.state;
- this.lastStableState = this.state;
- }
- }
-
- @Override
- public void onAttachedToLayoutParams(@NonNull LayoutParams layoutParams) {
- super.onAttachedToLayoutParams(layoutParams);
- // These may already be null, but just be safe, explicitly assign them. This lets us know the
- // first time we layout with this behavior by checking (viewRef == null).
- viewRef = null;
- viewDragHelper = null;
- }
-
- @Override
- public void onDetachedFromLayoutParams() {
- super.onDetachedFromLayoutParams();
- // Release references so we don't run unnecessary codepaths while not attached to a view.
- viewRef = null;
- viewDragHelper = null;
- }
-
- @Override
- public boolean onMeasureChild(
- @NonNull CoordinatorLayout parent,
- @NonNull V child,
- int parentWidthMeasureSpec,
- int widthUsed,
- int parentHeightMeasureSpec,
- int heightUsed) {
- MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
- int childWidthMeasureSpec =
- getChildMeasureSpec(
- parentWidthMeasureSpec,
- parent.getPaddingLeft()
- + parent.getPaddingRight()
- + lp.leftMargin
- + lp.rightMargin
- + widthUsed,
- maxWidth,
- lp.width);
- int childHeightMeasureSpec =
- getChildMeasureSpec(
- parentHeightMeasureSpec,
- parent.getPaddingTop()
- + parent.getPaddingBottom()
- + lp.topMargin
- + lp.bottomMargin
- + heightUsed,
- maxHeight,
- lp.height);
- child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
- return true; // Child was measured
- }
-
- private int getChildMeasureSpec(
- int parentMeasureSpec, int padding, int maxSize, int childDimension) {
- int result = ViewGroup.getChildMeasureSpec(parentMeasureSpec, padding, childDimension);
- if (maxSize == NO_MAX_SIZE) {
- return result;
- } else {
- int mode = MeasureSpec.getMode(result);
- int size = MeasureSpec.getSize(result);
- switch (mode) {
- case MeasureSpec.EXACTLY:
- return MeasureSpec.makeMeasureSpec(min(size, maxSize), MeasureSpec.EXACTLY);
- case MeasureSpec.AT_MOST:
- case MeasureSpec.UNSPECIFIED:
- default:
- return MeasureSpec.makeMeasureSpec(
- size == 0 ? maxSize : min(size, maxSize), MeasureSpec.AT_MOST);
- }
- }
- }
-
- @Override
- public boolean onLayoutChild(
- @NonNull CoordinatorLayout parent, @NonNull final V child, int layoutDirection) {
- if (ViewCompat.getFitsSystemWindows(parent) && !ViewCompat.getFitsSystemWindows(child)) {
- child.setFitsSystemWindows(true);
- }
-
- if (viewRef == null) {
- // First layout with this behavior.
- peekHeightMin =
- parent.getResources().getDimensionPixelSize(R.dimen.design_bottom_sheet_peek_height_min);
- setWindowInsetsListener(child);
- viewRef = new WeakReference<>(child);
- // Only set MaterialShapeDrawable as background if shapeTheming is enabled, otherwise will
- // default to android:background declared in styles or layout.
- if (materialShapeDrawable != null) {
- ViewCompat.setBackground(child, materialShapeDrawable);
- // Use elevation attr if set on bottomsheet; otherwise, use elevation of child view.
- materialShapeDrawable.setElevation(
- elevation == -1 ? ViewCompat.getElevation(child) : elevation);
- // Update the material shape based on initial state.
- isShapeExpanded = state == STATE_EXPANDED;
- materialShapeDrawable.setInterpolation(isShapeExpanded ? 0f : 1f);
- } else if (backgroundTint != null) {
- ViewCompat.setBackgroundTintList(child, backgroundTint);
- }
- updateAccessibilityActions();
- if (ViewCompat.getImportantForAccessibility(child)
- == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_AUTO) {
- ViewCompat.setImportantForAccessibility(child, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES);
- }
- }
- if (viewDragHelper == null) {
- viewDragHelper = ViewDragHelper.create(parent, dragCallback);
- }
-
- int savedTop = child.getTop();
- // First let the parent lay it out
- parent.onLayoutChild(child, layoutDirection);
- // Offset the bottom sheet
- parentWidth = parent.getWidth();
- parentHeight = parent.getHeight();
- childHeight = child.getHeight();
- if (parentHeight - childHeight < insetTop) {
- if (paddingTopSystemWindowInsets) {
- // If the bottomsheet would land in the middle of the status bar when fully expanded add
- // extra space to make sure it goes all the way.
- childHeight = parentHeight;
- } else {
- // If we don't want the bottomsheet to go under the status bar we cap its height
- childHeight = parentHeight - insetTop;
- }
- }
- fitToContentsOffset = max(0, parentHeight - childHeight);
- calculateHalfExpandedOffset();
- calculateCollapsedOffset();
-
- if (state == STATE_EXPANDED) {
- ViewCompat.offsetTopAndBottom(child, getExpandedOffset());
- } else if (state == STATE_HALF_EXPANDED) {
- ViewCompat.offsetTopAndBottom(child, halfExpandedOffset);
- } else if (hideable && state == STATE_HIDDEN) {
- ViewCompat.offsetTopAndBottom(child, parentHeight);
- } else if (state == STATE_COLLAPSED) {
- ViewCompat.offsetTopAndBottom(child, collapsedOffset);
- } else if (state == STATE_DRAGGING || state == STATE_SETTLING) {
- ViewCompat.offsetTopAndBottom(child, savedTop - child.getTop());
- }
-
- nestedScrollingChildRef = new WeakReference<>(findScrollingChild(child));
-
- for (int i = 0; i < callbacks.size(); i++) {
- callbacks.get(i).onLayout(child);
- }
- return true;
- }
-
- @Override
- public boolean onInterceptTouchEvent(
- @NonNull CoordinatorLayout parent, @NonNull V child, @NonNull MotionEvent event) {
- if (!child.isShown() || !draggable) {
- ignoreEvents = true;
- return false;
- }
- int action = event.getActionMasked();
- // Record the velocity
- if (action == MotionEvent.ACTION_DOWN) {
- reset();
- }
- if (velocityTracker == null) {
- velocityTracker = VelocityTracker.obtain();
- }
- velocityTracker.addMovement(event);
- switch (action) {
- case MotionEvent.ACTION_UP:
- case MotionEvent.ACTION_CANCEL:
- touchingScrollingChild = false;
- activePointerId = MotionEvent.INVALID_POINTER_ID;
- // Reset the ignore flag
- if (ignoreEvents) {
- ignoreEvents = false;
- return false;
- }
- break;
- case MotionEvent.ACTION_DOWN:
- int initialX = (int) event.getX();
- initialY = (int) event.getY();
- // Only intercept nested scrolling events here if the view not being moved by the
- // ViewDragHelper.
- if (state != STATE_SETTLING) {
- View scroll = nestedScrollingChildRef != null ? nestedScrollingChildRef.get() : null;
- if (scroll != null && parent.isPointInChildBounds(scroll, initialX, initialY)) {
- activePointerId = event.getPointerId(event.getActionIndex());
- touchingScrollingChild = true;
- }
- }
- ignoreEvents =
- activePointerId == MotionEvent.INVALID_POINTER_ID
- && !parent.isPointInChildBounds(child, initialX, initialY);
- break;
- default: // fall out
- }
- if (!ignoreEvents
- && viewDragHelper != null
- && viewDragHelper.shouldInterceptTouchEvent(event)) {
- return true;
- }
- // We have to handle cases that the ViewDragHelper does not capture the bottom sheet because
- // it is not the top most view of its parent. This is not necessary when the touch event is
- // happening over the scrolling content as nested scrolling logic handles that case.
- View scroll = nestedScrollingChildRef != null ? nestedScrollingChildRef.get() : null;
- return action == MotionEvent.ACTION_MOVE
- && scroll != null
- && !ignoreEvents
- && state != STATE_DRAGGING
- && !parent.isPointInChildBounds(scroll, (int) event.getX(), (int) event.getY())
- && viewDragHelper != null
- && Math.abs(initialY - event.getY()) > viewDragHelper.getTouchSlop();
- }
-
- @Override
- public boolean onTouchEvent(
- @NonNull CoordinatorLayout parent, @NonNull V child, @NonNull MotionEvent event) {
- if (!child.isShown()) {
- return false;
- }
- int action = event.getActionMasked();
- if (state == STATE_DRAGGING && action == MotionEvent.ACTION_DOWN) {
- return true;
- }
- if (shouldHandleDraggingWithHelper()) {
- viewDragHelper.processTouchEvent(event);
- }
- // Record the velocity
- if (action == MotionEvent.ACTION_DOWN) {
- reset();
- }
- if (velocityTracker == null) {
- velocityTracker = VelocityTracker.obtain();
- }
- velocityTracker.addMovement(event);
- // The ViewDragHelper tries to capture only the top-most View. We have to explicitly tell it
- // to capture the bottom sheet in case it is not captured and the touch slop is passed.
- if (shouldHandleDraggingWithHelper() && action == MotionEvent.ACTION_MOVE && !ignoreEvents) {
- if (Math.abs(initialY - event.getY()) > viewDragHelper.getTouchSlop()) {
- viewDragHelper.captureChildView(child, event.getPointerId(event.getActionIndex()));
- }
- }
- return !ignoreEvents;
- }
-
- @Override
- public boolean onStartNestedScroll(
- @NonNull CoordinatorLayout coordinatorLayout,
- @NonNull V child,
- @NonNull View directTargetChild,
- @NonNull View target,
- int axes,
- int type) {
- lastNestedScrollDy = 0;
- nestedScrolled = false;
- return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
- }
-
- @Override
- public void onNestedPreScroll(
- @NonNull CoordinatorLayout coordinatorLayout,
- @NonNull V child,
- @NonNull View target,
- int dx,
- int dy,
- @NonNull int[] consumed,
- int type) {
- if (type == ViewCompat.TYPE_NON_TOUCH) {
- // Ignore fling here. The ViewDragHelper handles it.
- return;
- }
- View scrollingChild = nestedScrollingChildRef != null ? nestedScrollingChildRef.get() : null;
- if (isNestedScrollingCheckEnabled() && target != scrollingChild) {
- return;
- }
- int currentTop = child.getTop();
- int newTop = currentTop - dy;
- if (dy > 0) { // Upward
- if (newTop < getExpandedOffset()) {
- consumed[1] = currentTop - getExpandedOffset();
- ViewCompat.offsetTopAndBottom(child, -consumed[1]);
- setStateInternal(STATE_EXPANDED);
- } else {
- if (!draggable) {
- // Prevent dragging
- return;
- }
-
- consumed[1] = dy;
- ViewCompat.offsetTopAndBottom(child, -dy);
- setStateInternal(STATE_DRAGGING);
- }
- } else if (dy < 0) { // Downward
- if (!target.canScrollVertically(-1)) {
- if (newTop <= collapsedOffset || hideable) {
- if (!draggable) {
- // Prevent dragging
- return;
- }
-
- consumed[1] = dy;
- ViewCompat.offsetTopAndBottom(child, -dy);
- setStateInternal(STATE_DRAGGING);
- } else {
- consumed[1] = currentTop - collapsedOffset;
- ViewCompat.offsetTopAndBottom(child, -consumed[1]);
- setStateInternal(STATE_COLLAPSED);
- }
- }
- }
- dispatchOnSlide(child.getTop());
- lastNestedScrollDy = dy;
- nestedScrolled = true;
- }
-
- @Override
- public void onStopNestedScroll(
- @NonNull CoordinatorLayout coordinatorLayout,
- @NonNull V child,
- @NonNull View target,
- int type) {
- if (child.getTop() == getExpandedOffset()) {
- setStateInternal(STATE_EXPANDED);
- return;
- }
- if (isNestedScrollingCheckEnabled()
- && (nestedScrollingChildRef == null
- || target != nestedScrollingChildRef.get()
- || !nestedScrolled)) {
- return;
- }
- @StableState int targetState;
- if (lastNestedScrollDy > 0) {
- if (fitToContents) {
- targetState = STATE_EXPANDED;
- } else {
- int currentTop = child.getTop();
- if (currentTop > halfExpandedOffset) {
- targetState = STATE_HALF_EXPANDED;
- } else {
- targetState = STATE_EXPANDED;
- }
- }
- } else if (hideable && shouldHide(child, getYVelocity())) {
- targetState = STATE_HIDDEN;
- } else if (lastNestedScrollDy == 0) {
- int currentTop = child.getTop();
- if (fitToContents) {
- if (Math.abs(currentTop - fitToContentsOffset) < Math.abs(currentTop - collapsedOffset)) {
- targetState = STATE_EXPANDED;
- } else {
- targetState = STATE_COLLAPSED;
- }
- } else {
- if (currentTop < halfExpandedOffset) {
- if (currentTop < Math.abs(currentTop - collapsedOffset)) {
- targetState = STATE_EXPANDED;
- } else {
- if (shouldSkipHalfExpandedStateWhenDragging()) {
- targetState = STATE_COLLAPSED;
- } else {
- targetState = STATE_HALF_EXPANDED;
- }
- }
- } else {
- if (Math.abs(currentTop - halfExpandedOffset) < Math.abs(currentTop - collapsedOffset)) {
- targetState = STATE_HALF_EXPANDED;
- } else {
- targetState = STATE_COLLAPSED;
- }
- }
- }
- } else {
- if (fitToContents) {
- targetState = STATE_COLLAPSED;
- } else {
- // Settle to nearest height.
- int currentTop = child.getTop();
- if (Math.abs(currentTop - halfExpandedOffset) < Math.abs(currentTop - collapsedOffset)) {
- targetState = STATE_HALF_EXPANDED;
- } else {
- targetState = STATE_COLLAPSED;
- }
- }
- }
- startSettling(child, targetState, false);
- nestedScrolled = false;
- }
-
- @Override
- public void onNestedScroll(
- @NonNull CoordinatorLayout coordinatorLayout,
- @NonNull V child,
- @NonNull View target,
- int dxConsumed,
- int dyConsumed,
- int dxUnconsumed,
- int dyUnconsumed,
- int type,
- @NonNull int[] consumed) {
- // Overridden to prevent the default consumption of the entire scroll distance.
- }
-
- @Override
- public boolean onNestedPreFling(
- @NonNull CoordinatorLayout coordinatorLayout,
- @NonNull V child,
- @NonNull View target,
- float velocityX,
- float velocityY) {
-
- if (isNestedScrollingCheckEnabled() && nestedScrollingChildRef != null) {
- return target == nestedScrollingChildRef.get()
- && (state != STATE_EXPANDED
- || super.onNestedPreFling(coordinatorLayout, child, target, velocityX, velocityY));
- } else {
- return false;
- }
- }
-
- /**
- * @return whether the height of the expanded sheet is determined by the height of its contents,
- * or if it is expanded in two stages (half the height of the parent container, full height of
- * parent container).
- */
- public boolean isFitToContents() {
- return fitToContents;
- }
-
- /**
- * Sets whether the height of the expanded sheet is determined by the height of its contents, or
- * if it is expanded in two stages (half the height of the parent container, full height of parent
- * container). Default value is true.
- *
- * @param fitToContents whether or not to fit the expanded sheet to its contents.
- */
- public void setFitToContents(boolean fitToContents) {
- if (this.fitToContents == fitToContents) {
- return;
- }
- this.fitToContents = fitToContents;
-
- // If sheet is already laid out, recalculate the collapsed offset based on new setting.
- // Otherwise, let onLayoutChild handle this later.
- if (viewRef != null) {
- calculateCollapsedOffset();
- }
- // Fix incorrect expanded settings depending on whether or not we are fitting sheet to contents.
- setStateInternal((this.fitToContents && state == STATE_HALF_EXPANDED) ? STATE_EXPANDED : state);
-
- updateAccessibilityActions();
- }
-
- /**
- * Sets the maximum width of the bottom sheet. The layout will be at most this dimension wide.
- * This method should be called before {@link BottomSheetDialog#show()} in order for the width to
- * be adjusted as expected.
- *
- * @param maxWidth The maximum width in pixels to be set
- * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_android_maxWidth
- * @see #getMaxWidth()
- */
- public void setMaxWidth(@Px int maxWidth) {
- this.maxWidth = maxWidth;
- }
-
- /**
- * Returns the bottom sheet's maximum width, or -1 if no maximum width is set.
- *
- * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_android_maxWidth
- * @see #setMaxWidth(int)
- */
- @Px
- public int getMaxWidth() {
- return maxWidth;
- }
-
- /**
- * Sets the maximum height of the bottom sheet. This method should be called before {@link
- * BottomSheetDialog#show()} in order for the height to be adjusted as expected.
- *
- * @param maxHeight The maximum height in pixels to be set
- * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_android_maxHeight
- * @see #getMaxHeight()
- */
- public void setMaxHeight(@Px int maxHeight) {
- this.maxHeight = maxHeight;
- }
-
- /**
- * Returns the bottom sheet's maximum height, or -1 if no maximum height is set.
- *
- * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_android_maxHeight
- * @see #setMaxHeight(int)
- */
- @Px
- public int getMaxHeight() {
- return maxHeight;
- }
-
- /**
- * Sets the height of the bottom sheet when it is collapsed.
- *
- * @param peekHeight The height of the collapsed bottom sheet in pixels, or {@link
- * #PEEK_HEIGHT_AUTO} to configure the sheet to peek automatically at 16:9 ratio keyline.
- * @attr ref
- * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_peekHeight
- */
- public void setPeekHeight(int peekHeight) {
- setPeekHeight(peekHeight, false);
- }
-
- /**
- * Sets the height of the bottom sheet when it is collapsed while optionally animating between the
- * old height and the new height.
- *
- * @param peekHeight The height of the collapsed bottom sheet in pixels, or {@link
- * #PEEK_HEIGHT_AUTO} to configure the sheet to peek automatically at 16:9 ratio keyline.
- * @param animate Whether to animate between the old height and the new height.
- * @attr ref
- * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_peekHeight
- */
- public final void setPeekHeight(int peekHeight, boolean animate) {
- boolean layout = false;
- if (peekHeight == PEEK_HEIGHT_AUTO) {
- if (!peekHeightAuto) {
- peekHeightAuto = true;
- layout = true;
- }
- } else if (peekHeightAuto || this.peekHeight != peekHeight) {
- peekHeightAuto = false;
- this.peekHeight = max(0, peekHeight);
- layout = true;
- }
- // If sheet is already laid out, recalculate the collapsed offset based on new setting.
- // Otherwise, let onLayoutChild handle this later.
- if (layout) {
- updatePeekHeight(animate);
- }
- }
-
- private void updatePeekHeight(boolean animate) {
- if (viewRef != null) {
- calculateCollapsedOffset();
- if (state == STATE_COLLAPSED) {
- V view = viewRef.get();
- if (view != null) {
- if (animate) {
- setState(STATE_COLLAPSED);
- } else {
- view.requestLayout();
- }
- }
- }
- }
- }
-
- /**
- * Gets the height of the bottom sheet when it is collapsed.
- *
- * @return The height of the collapsed bottom sheet in pixels, or {@link #PEEK_HEIGHT_AUTO} if the
- * sheet is configured to peek automatically at 16:9 ratio keyline
- * @attr ref
- * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_peekHeight
- */
- public int getPeekHeight() {
- return peekHeightAuto ? PEEK_HEIGHT_AUTO : peekHeight;
- }
-
- /**
- * Determines the height of the BottomSheet in the {@link #STATE_HALF_EXPANDED} state. The
- * material guidelines recommended a value of 0.5, which results in the sheet filling half of the
- * parent. The height of the BottomSheet will be smaller as this ratio is decreased and taller as
- * it is increased. The default value is 0.5.
- *
- * @param ratio a float between 0 and 1, representing the {@link #STATE_HALF_EXPANDED} ratio.
- * @attr ref
- * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_halfExpandedRatio
- */
- public void setHalfExpandedRatio(
- @FloatRange(from = 0.0f, to = 1.0f, fromInclusive = false, toInclusive = false) float ratio) {
-
- if ((ratio <= 0) || (ratio >= 1)) {
- throw new IllegalArgumentException("ratio must be a float value between 0 and 1");
- }
- this.halfExpandedRatio = ratio;
- // If sheet is already laid out, recalculate the half expanded offset based on new setting.
- // Otherwise, let onLayoutChild handle this later.
- if (viewRef != null) {
- calculateHalfExpandedOffset();
- }
- }
-
- /**
- * Gets the ratio for the height of the BottomSheet in the {@link #STATE_HALF_EXPANDED} state.
- *
- * @attr ref
- * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_halfExpandedRatio
- */
- @FloatRange(from = 0.0f, to = 1.0f)
- public float getHalfExpandedRatio() {
- return halfExpandedRatio;
- }
-
- /**
- * Determines the top offset of the BottomSheet in the {@link #STATE_EXPANDED} state when
- * fitsToContent is false. The default value is 0, which results in the sheet matching the
- * parent's top.
- *
- * @param offset an integer value greater than equal to 0, representing the {@link
- * #STATE_EXPANDED} offset. Value must not exceed the offset in the half expanded state.
- * @attr ref
- * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_expandedOffset
- */
- public void setExpandedOffset(int offset) {
- if (offset < 0) {
- throw new IllegalArgumentException("offset must be greater than or equal to 0");
- }
- this.expandedOffset = offset;
- }
-
- /**
- * Returns the current expanded offset. If {@code fitToContents} is true, it will automatically
- * pick the offset depending on the height of the content.
- *
- * @attr ref
- * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_expandedOffset
- */
- public int getExpandedOffset() {
- return fitToContents
- ? fitToContentsOffset
- : Math.max(expandedOffset, paddingTopSystemWindowInsets ? 0 : insetTop);
- }
-
- /**
- * Calculates the current offset of the bottom sheet.
- *
- * This method should be called when the child view is laid out.
- *
- * @return The offset of this bottom sheet within [-1,1] range. Offset increases
- * as this bottom sheet is moving upward. From 0 to 1 the sheet is between collapsed and
- * expanded states and from -1 to 0 it is between hidden and collapsed states. Returns
- * -1 if the bottom sheet is not laid out (therefore it's hidden).
- */
- public float calculateSlideOffset() {
- if (viewRef == null || viewRef.get() == null) {
- return -1;
- }
-
- return calculateSlideOffsetWithTop(viewRef.get().getTop());
- }
-
- /**
- * Sets whether this bottom sheet can hide.
- *
- * @param hideable {@code true} to make this bottom sheet hideable.
- * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_hideable
- */
- public void setHideable(boolean hideable) {
- if (this.hideable != hideable) {
- this.hideable = hideable;
- if (!hideable && state == STATE_HIDDEN) {
- // Lift up to collapsed state
- setState(STATE_COLLAPSED);
- }
- updateAccessibilityActions();
- }
- }
-
- /**
- * Gets whether this bottom sheet can hide when it is swiped down.
- *
- * @return {@code true} if this bottom sheet can hide.
- * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_hideable
- */
- public boolean isHideable() {
- return hideable;
- }
-
- /**
- * Sets whether this bottom sheet should skip the collapsed state when it is being hidden after it
- * is expanded once. Setting this to true has no effect unless the sheet is hideable.
- *
- * @param skipCollapsed True if the bottom sheet should skip the collapsed state.
- * @attr ref
- * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_skipCollapsed
- */
- public void setSkipCollapsed(boolean skipCollapsed) {
- this.skipCollapsed = skipCollapsed;
- }
-
- /**
- * Sets whether this bottom sheet should skip the collapsed state when it is being hidden after it
- * is expanded once.
- *
- * @return Whether the bottom sheet should skip the collapsed state.
- * @attr ref
- * com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_skipCollapsed
- */
- public boolean getSkipCollapsed() {
- return skipCollapsed;
- }
-
- /**
- * Sets whether this bottom sheet is can be collapsed/expanded by dragging. Note: When disabling
- * dragging, an app will require to implement a custom way to expand/collapse the bottom sheet
- *
- * @param draggable {@code false} to prevent dragging the sheet to collapse and expand
- * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_draggable
- */
- public void setDraggable(boolean draggable) {
- this.draggable = draggable;
- }
-
- public boolean isDraggable() {
- return draggable;
- }
-
- /*
- * Sets the velocity threshold considered significant enough to trigger a slide
- * to the next stable state.
- *
- * @param significantVelocityThreshold The velocity threshold that warrants a vertical swipe.
- * @see #getSignificantVelocityThreshold()
- * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_significantVelocityThreshold
- */
- public void setSignificantVelocityThreshold(int significantVelocityThreshold) {
- this.significantVelocityThreshold = significantVelocityThreshold;
- }
-
- /*
- * Returns the significant velocity threshold.
- *
- * @see #setSignificantVelocityThreshold(int)
- * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_significantVelocityThreshold
- */
- public int getSignificantVelocityThreshold() {
- return this.significantVelocityThreshold;
- }
-
- /**
- * Sets save flags to be preserved in bottomsheet on configuration change.
- *
- * @param flags bitwise int of {@link #SAVE_PEEK_HEIGHT}, {@link #SAVE_FIT_TO_CONTENTS}, {@link
- * #SAVE_HIDEABLE}, {@link #SAVE_SKIP_COLLAPSED}, {@link #SAVE_ALL} and {@link #SAVE_NONE}.
- * @see #getSaveFlags()
- * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_saveFlags
- */
- public void setSaveFlags(@SaveFlags int flags) {
- this.saveFlags = flags;
- }
- /**
- * Returns the save flags.
- *
- * @see #setSaveFlags(int)
- * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_saveFlags
- */
- @SaveFlags
- public int getSaveFlags() {
- return this.saveFlags;
- }
-
- /**
- * Sets the friction coefficient to hide the bottom sheet, or set it to the next closest
- * expanded state.
- *
- * @param hideFriction The friction coefficient that determines the swipe velocity needed to
- * hide or set the bottom sheet to the closest expanded state.
- */
- public void setHideFriction(float hideFriction) {
- this.hideFriction = hideFriction;
- }
-
- /**
- * Gets the friction coefficient to hide the bottom sheet, or set it to the next closest
- * expanded state.
- *
- * @return The friction coefficient that determines the swipe velocity needed to hide or set the
- * bottom sheet to the closest expanded state.
- */
- public float getHideFriction() {
- return this.hideFriction;
- }
-
- /**
- * Sets a callback to be notified of bottom sheet events.
- *
- * @param callback The callback to notify when bottom sheet events occur.
- * @deprecated use {@link #addBottomSheetCallback(BottomSheetCallback)} and {@link
- * #removeBottomSheetCallback(BottomSheetCallback)} instead
- */
- @Deprecated
- public void setBottomSheetCallback(BottomSheetCallback callback) {
- Log.w(
- TAG,
- "BottomSheetBehavior now supports multiple callbacks. `setBottomSheetCallback()` removes"
- + " all existing callbacks, including ones set internally by library authors, which"
- + " may result in unintended behavior. This may change in the future. Please use"
- + " `addBottomSheetCallback()` and `removeBottomSheetCallback()` instead to set your"
- + " own callbacks.");
- callbacks.clear();
- if (callback != null) {
- callbacks.add(callback);
- }
- }
-
- /**
- * Adds a callback to be notified of bottom sheet events.
- *
- * @param callback The callback to notify when bottom sheet events occur.
- */
- public void addBottomSheetCallback(@NonNull BottomSheetCallback callback) {
- if (!callbacks.contains(callback)) {
- callbacks.add(callback);
- }
- }
-
- /**
- * Removes a previously added callback.
- *
- * @param callback The callback to remove.
- */
- public void removeBottomSheetCallback(@NonNull BottomSheetCallback callback) {
- callbacks.remove(callback);
- }
-
- /**
- * Sets the state of the bottom sheet. The bottom sheet will transition to that state with
- * animation.
- *
- * @param state One of {@link #STATE_COLLAPSED}, {@link #STATE_EXPANDED}, {@link #STATE_HIDDEN},
- * or {@link #STATE_HALF_EXPANDED}.
- */
- public void setState(@StableState int state) {
- if (state == STATE_DRAGGING || state == STATE_SETTLING) {
- throw new IllegalArgumentException(
- "STATE_"
- + (state == STATE_DRAGGING ? "DRAGGING" : "SETTLING")
- + " should not be set externally.");
- }
- if (!hideable && state == STATE_HIDDEN) {
- Log.w(TAG, "Cannot set state: " + state);
- return;
- }
- final int finalState;
- if (state == STATE_HALF_EXPANDED
- && fitToContents
- && getTopOffsetForState(state) <= fitToContentsOffset) {
- // Skip to the expanded state if we would scroll past the height of the contents.
- finalState = STATE_EXPANDED;
- } else {
- finalState = state;
- }
- if (viewRef == null || viewRef.get() == null) {
- // The view is not laid out yet; modify mState and let onLayoutChild handle it later
- setStateInternal(state);
- } else {
- final V child = viewRef.get();
- runAfterLayout(
- child,
- new Runnable() {
- @Override
- public void run() {
- startSettling(child, finalState, false);
- }
- });
- }
- }
-
- private void runAfterLayout(V child, Runnable runnable) {
- if (isLayouting(child)) {
- child.post(runnable);
- } else {
- runnable.run();
- }
- }
-
- private boolean isLayouting(V child) {
- ViewParent parent = child.getParent();
- return parent != null && parent.isLayoutRequested() && ViewCompat.isAttachedToWindow(child);
- }
-
- /**
- * Sets whether this bottom sheet should adjust it's position based on the system gesture area on
- * Android Q and above.
- *
- * Note: the bottom sheet will only adjust it's position if it would be unable to be scrolled
- * upwards because the peekHeight is less than the gesture inset margins,(because that would cause
- * a gesture conflict), gesture navigation is enabled, and this {@code ignoreGestureInsetBottom}
- * flag is false.
- */
- public void setGestureInsetBottomIgnored(boolean gestureInsetBottomIgnored) {
- this.gestureInsetBottomIgnored = gestureInsetBottomIgnored;
- }
-
- /**
- * Returns whether this bottom sheet should adjust it's position based on the system gesture area.
- */
- public boolean isGestureInsetBottomIgnored() {
- return gestureInsetBottomIgnored;
- }
-
- /**
- * Gets the current state of the bottom sheet.
- *
- * @return One of {@link #STATE_EXPANDED}, {@link #STATE_HALF_EXPANDED}, {@link #STATE_COLLAPSED},
- * {@link #STATE_DRAGGING}, or {@link #STATE_SETTLING}.
- */
- @State
- public int getState() {
- return state;
- }
-
- void setStateInternal(@State int state) {
- if (this.state == state) {
- return;
- }
- this.state = state;
- if (state == STATE_COLLAPSED
- || state == STATE_EXPANDED
- || state == STATE_HALF_EXPANDED
- || (hideable && state == STATE_HIDDEN)) {
- this.lastStableState = state;
- }
-
- if (viewRef == null) {
- return;
- }
-
- View bottomSheet = viewRef.get();
- if (bottomSheet == null) {
- return;
- }
-
- if (state == STATE_EXPANDED) {
- updateImportantForAccessibility(true);
- } else if (state == STATE_HALF_EXPANDED || state == STATE_HIDDEN || state == STATE_COLLAPSED) {
- updateImportantForAccessibility(false);
- }
-
- updateDrawableForTargetState(state);
- for (int i = 0; i < callbacks.size(); i++) {
- callbacks.get(i).onStateChanged(bottomSheet, state);
- }
- updateAccessibilityActions();
- }
-
- private void updateDrawableForTargetState(@State int state) {
- if (state == STATE_SETTLING) {
- // Special case: we want to know which state we're settling to, so wait for another call.
- return;
- }
-
- boolean expand = state == STATE_EXPANDED;
- if (isShapeExpanded != expand) {
- isShapeExpanded = expand;
- if (materialShapeDrawable != null && interpolatorAnimator != null) {
- if (interpolatorAnimator.isRunning()) {
- interpolatorAnimator.reverse();
- } else {
- float to = expand ? 0f : 1f;
- float from = 1f - to;
- interpolatorAnimator.setFloatValues(from, to);
- interpolatorAnimator.start();
- }
- }
- }
- }
-
- private int calculatePeekHeight() {
- if (peekHeightAuto) {
- int desiredHeight = max(peekHeightMin, parentHeight - parentWidth * 9 / 16);
- return min(desiredHeight, childHeight) + insetBottom;
- }
- // Only make sure the peek height is above the gesture insets if we're not applying system
- // insets.
-
- // MODIFICATION: always add insetBottom for peekHeight
-// if (!gestureInsetBottomIgnored && !paddingBottomSystemWindowInsets && gestureInsetBottom > 0) {
-// return max(peekHeight, gestureInsetBottom + peekHeightGestureInsetBuffer);
-// }
- return peekHeight + insetBottom;
- }
-
- private void calculateCollapsedOffset() {
- int peek = calculatePeekHeight();
-
- if (fitToContents) {
- collapsedOffset = max(parentHeight - peek, fitToContentsOffset);
- } else {
- collapsedOffset = parentHeight - peek;
- }
- }
-
- private void calculateHalfExpandedOffset() {
- this.halfExpandedOffset = (int) (parentHeight * (1 - halfExpandedRatio));
- }
-
- private float calculateSlideOffsetWithTop(int top) {
- return
- (top > collapsedOffset || collapsedOffset == getExpandedOffset())
- ? (float) (collapsedOffset - top) / (parentHeight - collapsedOffset)
- : (float) (collapsedOffset - top) / (collapsedOffset - getExpandedOffset());
- }
-
- private void reset() {
- activePointerId = ViewDragHelper.INVALID_POINTER;
- if (velocityTracker != null) {
- velocityTracker.recycle();
- velocityTracker = null;
- }
- }
-
- private void restoreOptionalState(@NonNull SavedState ss) {
- if (this.saveFlags == SAVE_NONE) {
- return;
- }
- if (this.saveFlags == SAVE_ALL || (this.saveFlags & SAVE_PEEK_HEIGHT) == SAVE_PEEK_HEIGHT) {
- this.peekHeight = ss.peekHeight;
- }
- if (this.saveFlags == SAVE_ALL
- || (this.saveFlags & SAVE_FIT_TO_CONTENTS) == SAVE_FIT_TO_CONTENTS) {
- this.fitToContents = ss.fitToContents;
- }
- if (this.saveFlags == SAVE_ALL || (this.saveFlags & SAVE_HIDEABLE) == SAVE_HIDEABLE) {
- this.hideable = ss.hideable;
- }
- if (this.saveFlags == SAVE_ALL
- || (this.saveFlags & SAVE_SKIP_COLLAPSED) == SAVE_SKIP_COLLAPSED) {
- this.skipCollapsed = ss.skipCollapsed;
- }
- }
-
- boolean shouldHide(@NonNull View child, float yvel) {
- if (skipCollapsed) {
- return true;
- }
- if (child.getTop() < collapsedOffset) {
- // It should not hide, but collapse.
- return false;
- }
- int peek = calculatePeekHeight();
- final float newTop = child.getTop() + yvel * hideFriction;
- return Math.abs(newTop - collapsedOffset) / (float) peek > HIDE_THRESHOLD;
- }
-
- @Nullable
- @VisibleForTesting
- View findScrollingChild(View view) {
- if (view.getVisibility() != View.VISIBLE) {
- return null;
- }
- if (ViewCompat.isNestedScrollingEnabled(view)) {
- return view;
- }
- if (view instanceof ViewGroup) {
- ViewGroup group = (ViewGroup) view;
- for (int i = 0, count = group.getChildCount(); i < count; i++) {
- View scrollingChild = findScrollingChild(group.getChildAt(i));
- if (scrollingChild != null) {
- return scrollingChild;
- }
- }
- }
- return null;
- }
-
- private boolean shouldHandleDraggingWithHelper() {
- // If it's not draggable, do not forward events to viewDragHelper; however, if it's already
- // dragging, let it finish.
- return viewDragHelper != null && (draggable || state == STATE_DRAGGING);
- }
-
- private void createMaterialShapeDrawableIfNeeded(@NonNull Context context) {
- if (shapeAppearanceModelDefault == null) {
- return;
- }
-
- this.materialShapeDrawable = new MaterialShapeDrawable(shapeAppearanceModelDefault);
- this.materialShapeDrawable.initializeElevationOverlay(context);
-
- if (backgroundTint != null) {
- materialShapeDrawable.setFillColor(backgroundTint);
- } else {
- // If the tint isn't set, use the theme default background color.
- TypedValue defaultColor = new TypedValue();
- context.getTheme().resolveAttribute(android.R.attr.colorBackground, defaultColor, true);
- materialShapeDrawable.setTint(defaultColor.data);
- }
- }
-
- MaterialShapeDrawable getMaterialShapeDrawable() {
- return materialShapeDrawable;
- }
-
- private void createShapeValueAnimator() {
- interpolatorAnimator = ValueAnimator.ofFloat(0f, 1f);
- interpolatorAnimator.setDuration(CORNER_ANIMATION_DURATION);
- interpolatorAnimator.addUpdateListener(
- new AnimatorUpdateListener() {
- @Override
- public void onAnimationUpdate(@NonNull ValueAnimator animation) {
- float value = (float) animation.getAnimatedValue();
- if (materialShapeDrawable != null) {
- materialShapeDrawable.setInterpolation(value);
- }
- }
- });
- }
-
- @SuppressLint("RestrictedApi")
- private void setWindowInsetsListener(@NonNull View child) {
- // Ensure the peek height is at least as large as the bottom gesture inset size so that
- // the sheet can always be dragged, but only when the inset is required by the system.
- final boolean shouldHandleGestureInsets =
- VERSION.SDK_INT >= VERSION_CODES.Q && !isGestureInsetBottomIgnored() && !peekHeightAuto;
-
- // If were not handling insets at all, don't apply the listener.
- if (!paddingBottomSystemWindowInsets
- && !paddingLeftSystemWindowInsets
- && !paddingRightSystemWindowInsets
- && !marginLeftSystemWindowInsets
- && !marginRightSystemWindowInsets
- && !marginTopSystemWindowInsets
- && !shouldHandleGestureInsets) {
- return;
- }
- ViewUtils.doOnApplyWindowInsets(
- child,
- new ViewUtils.OnApplyWindowInsetsListener() {
- @Override
- @SuppressWarnings("deprecation") // getSystemWindowInsetBottom is used for adjustResize.
- public WindowInsetsCompat onApplyWindowInsets(
- View view, WindowInsetsCompat insets, RelativePadding initialPadding) {
- Insets systemBarInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars());
- Insets mandatoryGestureInsets =
- insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures());
-
- insetTop = systemBarInsets.top;
-
- boolean isRtl = ViewUtils.isLayoutRtl(view);
-
- int bottomPadding = view.getPaddingBottom();
- int leftPadding = view.getPaddingLeft();
- int rightPadding = view.getPaddingRight();
-
- // MODIFICATION: ignore paddingBottomSystemWindowInsets
-// if (paddingBottomSystemWindowInsets) {
- // Intentionally uses getSystemWindowInsetBottom to apply padding properly when
- // adjustResize is used as the windowSoftInputMode.
- insetBottom = insets.getSystemWindowInsetBottom();
-// bottomPadding = initialPadding.bottom + insetBottom;
-// }
-
- if (paddingLeftSystemWindowInsets) {
- leftPadding = isRtl ? initialPadding.end : initialPadding.start;
- leftPadding += systemBarInsets.left;
- }
-
- if (paddingRightSystemWindowInsets) {
- rightPadding = isRtl ? initialPadding.start : initialPadding.end;
- rightPadding += systemBarInsets.right;
- }
-
- MarginLayoutParams mlp = (MarginLayoutParams) view.getLayoutParams();
- boolean marginUpdated = false;
-
- // MODIFIED: Don't change left and right margin to let landscape mode look normal
- if (marginLeftSystemWindowInsets && mlp.leftMargin != systemBarInsets.left) {
-// mlp.leftMargin = systemBarInsets.left;
- marginUpdated = true;
- }
-
- if (marginRightSystemWindowInsets && mlp.rightMargin != systemBarInsets.right) {
-// mlp.rightMargin = systemBarInsets.right;
- marginUpdated = true;
- }
-
- if (marginTopSystemWindowInsets && mlp.topMargin != systemBarInsets.top) {
- mlp.topMargin = systemBarInsets.top;
- marginUpdated = true;
- }
-
- if (marginUpdated) {
- view.setLayoutParams(mlp);
- }
- view.setPadding(leftPadding, view.getPaddingTop(), rightPadding, bottomPadding);
-
- if (shouldHandleGestureInsets) {
- gestureInsetBottom = mandatoryGestureInsets.bottom;
- }
-
- // Don't update the peek height to be above the navigation bar or gestures if these
- // flags are off. It means the client is already handling it.
-
- // MODIFICATION: always update peek height
-// if (paddingBottomSystemWindowInsets || shouldHandleGestureInsets) {
- updatePeekHeight(/* animate= */ false);
-// }
- return insets;
- }
- });
- }
-
- private float getYVelocity() {
- if (velocityTracker == null) {
- return 0;
- }
- velocityTracker.computeCurrentVelocity(1000, maximumVelocity);
- return velocityTracker.getYVelocity(activePointerId);
- }
-
- private void startSettling(View child, @StableState int state, boolean isReleasingView) {
- int top = getTopOffsetForState(state);
- boolean settling =
- viewDragHelper != null
- && (isReleasingView
- ? viewDragHelper.settleCapturedViewAt(child.getLeft(), top)
- : viewDragHelper.smoothSlideViewTo(child, child.getLeft(), top));
- if (settling) {
- setStateInternal(STATE_SETTLING);
- // STATE_SETTLING won't animate the material shape, so do that here with the target state.
- updateDrawableForTargetState(state);
- stateSettlingTracker.continueSettlingToState(state);
- } else {
- setStateInternal(state);
- }
- }
-
- private int getTopOffsetForState(@StableState int state) {
- switch (state) {
- case STATE_COLLAPSED:
- return collapsedOffset;
- case STATE_EXPANDED:
- return getExpandedOffset();
- case STATE_HALF_EXPANDED:
- return halfExpandedOffset;
- case STATE_HIDDEN:
- return parentHeight;
- default:
- // Fall through
- }
- throw new IllegalArgumentException("Invalid state to get top offset: " + state);
- }
-
- private final ViewDragHelper.Callback dragCallback =
- new ViewDragHelper.Callback() {
-
- private long viewCapturedMillis;
-
- @Override
- public boolean tryCaptureView(@NonNull View child, int pointerId) {
- if (state == STATE_DRAGGING) {
- return false;
- }
- if (touchingScrollingChild) {
- return false;
- }
- if (state == STATE_EXPANDED && activePointerId == pointerId) {
- View scroll = nestedScrollingChildRef != null ? nestedScrollingChildRef.get() : null;
- if (scroll != null && scroll.canScrollVertically(-1)) {
- // Let the content scroll up
- return false;
- }
- }
- viewCapturedMillis = System.currentTimeMillis();
- return viewRef != null && viewRef.get() == child;
- }
-
- @Override
- public void onViewPositionChanged(
- @NonNull View changedView, int left, int top, int dx, int dy) {
- dispatchOnSlide(top);
- }
-
- @Override
- public void onViewDragStateChanged(@State int state) {
- if (state == ViewDragHelper.STATE_DRAGGING && draggable) {
- setStateInternal(STATE_DRAGGING);
- }
- }
-
- private boolean releasedLow(@NonNull View child) {
- // Needs to be at least half way to the bottom.
- return child.getTop() > (parentHeight + getExpandedOffset()) / 2;
- }
-
- @Override
- public void onViewReleased(@NonNull View releasedChild, float xvel, float yvel) {
- @State int targetState;
- if (yvel < 0) { // Moving up
- if (fitToContents) {
- targetState = STATE_EXPANDED;
- } else {
- int currentTop = releasedChild.getTop();
- long dragDurationMillis = System.currentTimeMillis() - viewCapturedMillis;
-
- if (shouldSkipHalfExpandedStateWhenDragging()) {
- float yPositionPercentage = currentTop * 100f / parentHeight;
-
- if (shouldExpandOnUpwardDrag(dragDurationMillis, yPositionPercentage)) {
- targetState = STATE_EXPANDED;
- } else {
- targetState = STATE_COLLAPSED;
- }
- } else {
- if (currentTop > halfExpandedOffset) {
- targetState = STATE_HALF_EXPANDED;
- } else {
- targetState = STATE_EXPANDED;
- }
- }
- }
- } else if (hideable && shouldHide(releasedChild, yvel)) {
- // Hide if the view was either released low or it was a significant vertical swipe
- // otherwise settle to closest expanded state.
- if ((Math.abs(xvel) < Math.abs(yvel) && yvel > significantVelocityThreshold)
- || releasedLow(releasedChild)) {
- targetState = STATE_HIDDEN;
- } else if (fitToContents) {
- targetState = STATE_EXPANDED;
- } else if (Math.abs(releasedChild.getTop() - getExpandedOffset())
- < Math.abs(releasedChild.getTop() - halfExpandedOffset)) {
- targetState = STATE_EXPANDED;
- } else {
- targetState = STATE_HALF_EXPANDED;
- }
- } else if (yvel == 0.f || Math.abs(xvel) > Math.abs(yvel)) {
- // If the Y velocity is 0 or the swipe was mostly horizontal indicated by the X velocity
- // being greater than the Y velocity, settle to the nearest correct height.
- int currentTop = releasedChild.getTop();
- if (fitToContents) {
- if (Math.abs(currentTop - fitToContentsOffset)
- < Math.abs(currentTop - collapsedOffset)) {
- targetState = STATE_EXPANDED;
- } else {
- targetState = STATE_COLLAPSED;
- }
- } else {
- if (currentTop < halfExpandedOffset) {
- if (currentTop < Math.abs(currentTop - collapsedOffset)) {
- targetState = STATE_EXPANDED;
- } else {
- if (shouldSkipHalfExpandedStateWhenDragging()) {
- targetState = STATE_COLLAPSED;
- } else {
- targetState = STATE_HALF_EXPANDED;
- }
- }
- } else {
- if (Math.abs(currentTop - halfExpandedOffset)
- < Math.abs(currentTop - collapsedOffset)) {
- if (shouldSkipHalfExpandedStateWhenDragging()) {
- targetState = STATE_COLLAPSED;
- } else {
- targetState = STATE_HALF_EXPANDED;
- }
- } else {
- targetState = STATE_COLLAPSED;
- }
- }
- }
- } else { // Moving Down
- if (fitToContents) {
- targetState = STATE_COLLAPSED;
- } else {
- // Settle to the nearest correct height.
- int currentTop = releasedChild.getTop();
- if (Math.abs(currentTop - halfExpandedOffset)
- < Math.abs(currentTop - collapsedOffset)) {
- if (shouldSkipHalfExpandedStateWhenDragging()) {
- targetState = STATE_COLLAPSED;
- } else {
- targetState = STATE_HALF_EXPANDED;
- }
- } else {
- targetState = STATE_COLLAPSED;
- }
- }
- }
- startSettling(releasedChild, targetState, shouldSkipSmoothAnimation());
- }
-
- @Override
- public int clampViewPositionVertical(@NonNull View child, int top, int dy) {
- return MathUtils.clamp(
- top, getExpandedOffset(), hideable ? parentHeight : collapsedOffset);
- }
-
- @Override
- public int clampViewPositionHorizontal(@NonNull View child, int left, int dx) {
- return child.getLeft();
- }
-
- @Override
- public int getViewVerticalDragRange(@NonNull View child) {
- if (hideable) {
- return parentHeight;
- } else {
- return collapsedOffset;
- }
- }
- };
-
- void dispatchOnSlide(int top) {
- View bottomSheet = viewRef.get();
- if (bottomSheet != null && !callbacks.isEmpty()) {
- float slideOffset = calculateSlideOffsetWithTop(top);
- for (int i = 0; i < callbacks.size(); i++) {
- callbacks.get(i).onSlide(bottomSheet, slideOffset);
- }
- }
- }
-
- @VisibleForTesting
- int getPeekHeightMin() {
- return peekHeightMin;
- }
-
- /**
- * Disables the shaped corner {@link ShapeAppearanceModel} interpolation transition animations.
- * Will have no effect unless the sheet utilizes a {@link MaterialShapeDrawable} with set shape
- * theming properties. Only For use in UI testing.
- *
- * @hide
- */
- @RestrictTo(LIBRARY_GROUP)
- @VisibleForTesting
- public void disableShapeAnimations() {
- // Sets the shape value animator to null, prevents animations from occurring during testing.
- interpolatorAnimator = null;
- }
-
- /**
- * Checks weather a nested scroll should be enabled. If {@code false} all nested scrolls will be
- * consumed by the bottomSheet.
- *
- * @hide
- */
- @RestrictTo(LIBRARY_GROUP)
- public boolean isNestedScrollingCheckEnabled() {
- return true;
- }
-
- /**
- * Checks weather half expended state should be skipped when drag is ended. If {@code true}, the
- * bottomSheet will go to the next closest state.
- *
- * @hide
- */
- @RestrictTo(LIBRARY_GROUP)
- public boolean shouldSkipHalfExpandedStateWhenDragging() {
- return false;
- }
-
- /**
- * Checks whether an animation should be smooth after the bottomSheet is released after dragging.
- *
- * @hide
- */
- @RestrictTo(LIBRARY_GROUP)
- public boolean shouldSkipSmoothAnimation() {
- return true;
- }
-
- /**
- * Checks whether the bottom sheet should be expanded after it has been released after dragging.
- *
- * @param dragDurationMillis how long the bottom sheet was dragged.
- * @param yPositionPercentage position of the bottom sheet when released after dragging. Lower
- * values mean that view was released closer to the top of the screen.
- * @hide
- */
- @RestrictTo(LIBRARY_GROUP)
- public boolean shouldExpandOnUpwardDrag(
- long dragDurationMillis, @FloatRange(from = 0.0f, to = 100.0f) float yPositionPercentage) {
- return false;
- }
-
- /**
- * Sets whether this bottom sheet can hide when it is swiped down.
- *
- * @param hideable {@code true} to make this bottom sheet hideable.
- * @hide
- */
- @RestrictTo(LIBRARY_GROUP)
- public void setHideableInternal(boolean hideable) {
- this.hideable = hideable;
- }
-
- /**
- * Gets the last stable state of the bottom sheet.
- *
- * @return One of {@link #STATE_EXPANDED}, {@link #STATE_HALF_EXPANDED}, {@link #STATE_COLLAPSED},
- * {@link #STATE_HIDDEN}.
- * @hide
- */
- @State
- @RestrictTo(LIBRARY_GROUP)
- public int getLastStableState() {
- return lastStableState;
- }
-
- private class StateSettlingTracker {
- @State private int targetState;
- private boolean isContinueSettlingRunnablePosted;
-
- private final Runnable continueSettlingRunnable =
- new Runnable() {
- @Override
- public void run() {
- isContinueSettlingRunnablePosted = false;
- if (viewDragHelper != null && viewDragHelper.continueSettling(true)) {
- continueSettlingToState(targetState);
- } else if (state == STATE_SETTLING) {
- setStateInternal(targetState);
- }
- // In other cases, settling has been interrupted by certain UX interactions. Do nothing.
- }
- };
-
- void continueSettlingToState(@State int targetState) {
- if (viewRef == null || viewRef.get() == null) {
- return;
- }
- this.targetState = targetState;
- if (!isContinueSettlingRunnablePosted) {
- ViewCompat.postOnAnimation(viewRef.get(), continueSettlingRunnable);
- isContinueSettlingRunnablePosted = true;
- }
- }
- }
-
- /** State persisted across instances */
- protected static class SavedState extends AbsSavedState {
- @State final int state;
- int peekHeight;
- boolean fitToContents;
- boolean hideable;
- boolean skipCollapsed;
-
- public SavedState(@NonNull Parcel source) {
- this(source, null);
- }
-
- public SavedState(@NonNull Parcel source, ClassLoader loader) {
- super(source, loader);
- //noinspection ResourceType
- state = source.readInt();
- peekHeight = source.readInt();
- fitToContents = source.readInt() == 1;
- hideable = source.readInt() == 1;
- skipCollapsed = source.readInt() == 1;
- }
-
- public SavedState(Parcelable superState, @NonNull NeoBottomSheetBehavior> behavior) {
- super(superState);
- this.state = behavior.state;
- this.peekHeight = behavior.peekHeight;
- this.fitToContents = behavior.fitToContents;
- this.hideable = behavior.hideable;
- this.skipCollapsed = behavior.skipCollapsed;
- }
-
- /**
- * This constructor does not respect flags: {@link NeoBottomSheetBehavior#SAVE_PEEK_HEIGHT}, {@link
- * NeoBottomSheetBehavior#SAVE_FIT_TO_CONTENTS}, {@link NeoBottomSheetBehavior#SAVE_HIDEABLE}, {@link
- * NeoBottomSheetBehavior#SAVE_SKIP_COLLAPSED}. It is as if {@link NeoBottomSheetBehavior#SAVE_NONE}
- * were set.
- *
- * @deprecated Use {@link #SavedState(Parcelable, NeoBottomSheetBehavior)} instead.
- */
- @Deprecated
- public SavedState(Parcelable superstate, @State int state) {
- super(superstate);
- this.state = state;
- }
-
- @Override
- public void writeToParcel(@NonNull Parcel out, int flags) {
- super.writeToParcel(out, flags);
- out.writeInt(state);
- out.writeInt(peekHeight);
- out.writeInt(fitToContents ? 1 : 0);
- out.writeInt(hideable ? 1 : 0);
- out.writeInt(skipCollapsed ? 1 : 0);
- }
-
- public static final Creator CREATOR =
- new ClassLoaderCreator() {
- @NonNull
- @Override
- public SavedState createFromParcel(@NonNull Parcel in, ClassLoader loader) {
- return new SavedState(in, loader);
- }
-
- @Nullable
- @Override
- public SavedState createFromParcel(@NonNull Parcel in) {
- return new SavedState(in, null);
- }
-
- @NonNull
- @Override
- public SavedState[] newArray(int size) {
- return new SavedState[size];
- }
- };
- }
-
- /**
- * A utility function to get the {@link NeoBottomSheetBehavior} associated with the {@code view}.
- *
- * @param view The {@link View} with {@link NeoBottomSheetBehavior}.
- * @return The {@link NeoBottomSheetBehavior} associated with the {@code view}.
- */
- @NonNull
- @SuppressWarnings("unchecked")
- public static NeoBottomSheetBehavior from(@NonNull V view) {
- ViewGroup.LayoutParams params = view.getLayoutParams();
- if (!(params instanceof CoordinatorLayout.LayoutParams)) {
- throw new IllegalArgumentException("The view is not a child of CoordinatorLayout");
- }
- CoordinatorLayout.Behavior> behavior =
- ((CoordinatorLayout.LayoutParams) params).getBehavior();
- if (!(behavior instanceof NeoBottomSheetBehavior)) {
- throw new IllegalArgumentException("The view is not associated with NeoBottomSheetBehavior");
- }
- return (NeoBottomSheetBehavior) behavior;
- }
-
- /**
- * Sets whether the BottomSheet should update the accessibility status of its {@link
- * CoordinatorLayout} siblings when expanded.
- *
- * Set this to true if the expanded state of the sheet blocks access to siblings (e.g., when
- * the sheet expands over the full screen).
- */
- public void setUpdateImportantForAccessibilityOnSiblings(
- boolean updateImportantForAccessibilityOnSiblings) {
- this.updateImportantForAccessibilityOnSiblings = updateImportantForAccessibilityOnSiblings;
- }
-
- private void updateImportantForAccessibility(boolean expanded) {
- if (viewRef == null) {
- return;
- }
-
- ViewParent viewParent = viewRef.get().getParent();
- if (!(viewParent instanceof CoordinatorLayout)) {
- return;
- }
-
- CoordinatorLayout parent = (CoordinatorLayout) viewParent;
- final int childCount = parent.getChildCount();
- if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) && expanded) {
- if (importantForAccessibilityMap == null) {
- importantForAccessibilityMap = new HashMap<>(childCount);
- } else {
- // The important for accessibility values of the child views have been saved already.
- return;
- }
- }
-
- for (int i = 0; i < childCount; i++) {
- final View child = parent.getChildAt(i);
- if (child == viewRef.get()) {
- continue;
- }
-
- if (expanded) {
- // Saves the important for accessibility value of the child view.
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
- importantForAccessibilityMap.put(child, child.getImportantForAccessibility());
- }
- if (updateImportantForAccessibilityOnSiblings) {
- ViewCompat.setImportantForAccessibility(
- child, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
- }
- } else {
- if (updateImportantForAccessibilityOnSiblings
- && importantForAccessibilityMap != null
- && importantForAccessibilityMap.containsKey(child)) {
- // Restores the original important for accessibility value of the child view.
- ViewCompat.setImportantForAccessibility(child, importantForAccessibilityMap.get(child));
- }
- }
- }
-
- if (!expanded) {
- importantForAccessibilityMap = null;
- } else if (updateImportantForAccessibilityOnSiblings) {
- // If the siblings of the bottom sheet have been set to not important for a11y, move the focus
- // to the bottom sheet when expanded.
- viewRef.get().sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED);
- }
- }
-
- private void updateAccessibilityActions() {
- if (viewRef == null) {
- return;
- }
- V child = viewRef.get();
- if (child == null) {
- return;
- }
- ViewCompat.removeAccessibilityAction(child, AccessibilityNodeInfoCompat.ACTION_COLLAPSE);
- ViewCompat.removeAccessibilityAction(child, AccessibilityNodeInfoCompat.ACTION_EXPAND);
- ViewCompat.removeAccessibilityAction(child, AccessibilityNodeInfoCompat.ACTION_DISMISS);
-
- if (expandHalfwayActionId != View.NO_ID) {
- ViewCompat.removeAccessibilityAction(child, expandHalfwayActionId);
- }
- if (!fitToContents && state != STATE_HALF_EXPANDED) {
- expandHalfwayActionId =
- addAccessibilityActionForState(
- child, R.string.bottomsheet_action_expand_halfway, STATE_HALF_EXPANDED);
- }
-
- if (hideable && state != STATE_HIDDEN) {
- replaceAccessibilityActionForState(
- child, AccessibilityActionCompat.ACTION_DISMISS, STATE_HIDDEN);
- }
-
- switch (state) {
- case STATE_EXPANDED:
- {
- int nextState = fitToContents ? STATE_COLLAPSED : STATE_HALF_EXPANDED;
- replaceAccessibilityActionForState(
- child, AccessibilityActionCompat.ACTION_COLLAPSE, nextState);
- break;
- }
- case STATE_HALF_EXPANDED:
- {
- replaceAccessibilityActionForState(
- child, AccessibilityActionCompat.ACTION_COLLAPSE, STATE_COLLAPSED);
- replaceAccessibilityActionForState(
- child, AccessibilityActionCompat.ACTION_EXPAND, STATE_EXPANDED);
- break;
- }
- case STATE_COLLAPSED:
- {
- int nextState = fitToContents ? STATE_EXPANDED : STATE_HALF_EXPANDED;
- replaceAccessibilityActionForState(
- child, AccessibilityActionCompat.ACTION_EXPAND, nextState);
- break;
- }
- default: // fall out
- }
- }
-
- private void replaceAccessibilityActionForState(
- V child, AccessibilityActionCompat action, @State int state) {
- ViewCompat.replaceAccessibilityAction(
- child, action, null, createAccessibilityViewCommandForState(state));
- }
-
- private int addAccessibilityActionForState(
- V child, @StringRes int stringResId, @State int state) {
- return ViewCompat.addAccessibilityAction(
- child,
- child.getResources().getString(stringResId),
- createAccessibilityViewCommandForState(state));
- }
-
- private AccessibilityViewCommand createAccessibilityViewCommandForState(@State final int state) {
- return new AccessibilityViewCommand() {
- @Override
- public boolean perform(@NonNull View view, @Nullable CommandArguments arguments) {
- setState(state);
- return true;
- }
- };
- }
-}
diff --git a/app/src/main/java/com/zionhuang/music/App.kt b/app/src/main/java/com/zionhuang/music/App.kt
index dc9985c00..e8113327e 100644
--- a/app/src/main/java/com/zionhuang/music/App.kt
+++ b/app/src/main/java/com/zionhuang/music/App.kt
@@ -1,61 +1,57 @@
package com.zionhuang.music
import android.app.Application
-import android.content.SharedPreferences
import android.os.Build
-import android.util.Log
import android.widget.Toast
import android.widget.Toast.LENGTH_SHORT
-import androidx.core.content.edit
+import androidx.datastore.preferences.core.edit
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import com.zionhuang.innertube.YouTube
import com.zionhuang.innertube.models.YouTubeLocale
import com.zionhuang.kugou.KuGou
-import com.zionhuang.music.constants.Constants.INNERTUBE_COOKIE
-import com.zionhuang.music.constants.Constants.VISITOR_DATA
-import com.zionhuang.music.extensions.getEnum
-import com.zionhuang.music.extensions.sharedPreferences
-import com.zionhuang.music.extensions.toInetSocketAddress
-import com.zionhuang.music.ui.fragments.settings.StorageSettingsFragment.Companion.VALUE_TO_MB
+import com.zionhuang.music.constants.*
+import com.zionhuang.music.extensions.*
+import com.zionhuang.music.utils.dataStore
+import com.zionhuang.music.utils.get
+import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
+import timber.log.Timber
import java.net.Proxy
import java.util.*
-class App : Application(), ImageLoaderFactory, SharedPreferences.OnSharedPreferenceChangeListener {
+@HiltAndroidApp
+class App : Application(), ImageLoaderFactory {
@OptIn(DelicateCoroutinesApi::class)
override fun onCreate() {
super.onCreate()
- INSTANCE = this
+ Timber.plant(Timber.DebugTree())
- val systemDefault = getString(R.string.default_localization_key)
val locale = Locale.getDefault()
val languageTag = locale.toLanguageTag().replace("-Hant", "") // replace zh-Hant-* to zh-*
- val languageCodes = resources.getStringArray(R.array.language_codes)
- val countryCodes = resources.getStringArray(R.array.country_codes)
YouTube.locale = YouTubeLocale(
- gl = sharedPreferences.getString(getString(R.string.pref_content_country), systemDefault).takeIf { it != systemDefault }
- ?: locale.country.takeIf { it in countryCodes }
+ gl = dataStore[ContentCountryKey]?.takeIf { it != SYSTEM_DEFAULT }
+ ?: locale.country.takeIf { it in CountryCodeToName }
?: "US",
- hl = sharedPreferences.getString(getString(R.string.pref_content_language), systemDefault).takeIf { it != systemDefault }
- ?: locale.language.takeIf { it in languageCodes }
- ?: languageTag.takeIf { it in languageCodes }
+ hl = dataStore[ContentLanguageKey]?.takeIf { it != SYSTEM_DEFAULT }
+ ?: locale.language.takeIf { it in LanguageCodeToName }
+ ?: languageTag.takeIf { it in LanguageCodeToName }
?: "en"
)
if (languageTag == "zh-TW") {
KuGou.useTraditionalChinese = true
}
- Log.d("App", "${YouTube.locale}")
- if (sharedPreferences.getBoolean(getString(R.string.pref_proxy_enabled), false)) {
+ if (dataStore[ProxyEnabledKey] == true) {
try {
- val socketAddress = sharedPreferences.getString(getString(R.string.pref_proxy_url), "")!!.toInetSocketAddress()
YouTube.proxy = Proxy(
- sharedPreferences.getEnum(getString(R.string.pref_proxy_type), Proxy.Type.HTTP),
- socketAddress
+ dataStore[ProxyTypeKey].toEnum(defaultValue = Proxy.Type.HTTP),
+ dataStore[ProxyUrlKey]!!.toInetSocketAddress()
)
} catch (e: Exception) {
Toast.makeText(this, "Failed to parse proxy url.", LENGTH_SHORT).show()
@@ -64,20 +60,26 @@ class App : Application(), ImageLoaderFactory, SharedPreferences.OnSharedPrefere
}
GlobalScope.launch {
- YouTube.visitorData = sharedPreferences.getString(VISITOR_DATA, null) ?: YouTube.generateVisitorData().getOrNull()?.also {
- sharedPreferences.edit {
- putString(VISITOR_DATA, it)
+ dataStore.data
+ .map { it[VisitorDataKey] }
+ .distinctUntilChanged()
+ .collect { visitorData ->
+ YouTube.visitorData = visitorData
+ ?.takeIf { it != "null" } // Previously visitorData was sometimes saved as "null" due to a bug
+ ?: YouTube.visitorData().getOrNull()?.also { newVisitorData ->
+ dataStore.edit { settings ->
+ settings[VisitorDataKey] = newVisitorData
+ }
+ } ?: YouTube.DEFAULT_VISITOR_DATA
}
- } ?: YouTube.DEFAULT_VISITOR_DATA
}
- YouTube.cookie = sharedPreferences.getString(INNERTUBE_COOKIE, null)
- sharedPreferences.registerOnSharedPreferenceChangeListener(this)
- }
-
- override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
- when (key) {
- VISITOR_DATA -> YouTube.visitorData = sharedPreferences.getString(VISITOR_DATA, null) ?: YouTube.DEFAULT_VISITOR_DATA
- INNERTUBE_COOKIE -> YouTube.cookie = sharedPreferences.getString(INNERTUBE_COOKIE, null)
+ GlobalScope.launch {
+ dataStore.data
+ .map { it[InnerTubeCookieKey] }
+ .distinctUntilChanged()
+ .collect { cookie ->
+ YouTube.cookie = cookie
+ }
}
}
@@ -88,15 +90,8 @@ class App : Application(), ImageLoaderFactory, SharedPreferences.OnSharedPrefere
.diskCache(
DiskCache.Builder()
.directory(cacheDir.resolve("coil"))
- .maxSizeBytes(
- size = (VALUE_TO_MB.getOrNull(
- sharedPreferences.getInt(getString(R.string.pref_image_max_cache_size), 0)
- ) ?: 1024) * 1024 * 1024L)
+ .maxSizeBytes((dataStore[MaxImageCacheSizeKey] ?: 512) * 1024 * 1024L)
.build()
)
.build()
-
- companion object {
- lateinit var INSTANCE: App
- }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt
new file mode 100644
index 000000000..06de681b4
--- /dev/null
+++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt
@@ -0,0 +1,751 @@
+package com.zionhuang.music
+
+import android.annotation.SuppressLint
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.graphics.drawable.BitmapDrawable
+import android.os.Build
+import android.os.Bundle
+import android.os.IBinder
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.Crossfade
+import androidx.compose.animation.core.*
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.fastAny
+import androidx.compose.ui.util.fastForEach
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.core.net.toUri
+import androidx.core.util.Consumer
+import androidx.core.view.WindowCompat
+import androidx.lifecycle.lifecycleScope
+import androidx.media3.common.MediaItem
+import androidx.media3.common.Player
+import androidx.media3.session.MediaController
+import androidx.media3.session.SessionToken
+import androidx.navigation.NavDestination.Companion.hierarchy
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import coil.imageLoader
+import coil.request.ImageRequest
+import com.google.common.util.concurrent.MoreExecutors
+import com.valentinilk.shimmer.LocalShimmerTheme
+import com.zionhuang.innertube.YouTube
+import com.zionhuang.innertube.models.SongItem
+import com.zionhuang.music.constants.*
+import com.zionhuang.music.db.MusicDatabase
+import com.zionhuang.music.db.entities.PlaylistEntity.Companion.DOWNLOADED_PLAYLIST_ID
+import com.zionhuang.music.db.entities.PlaylistEntity.Companion.LIKED_PLAYLIST_ID
+import com.zionhuang.music.db.entities.SearchHistory
+import com.zionhuang.music.extensions.*
+import com.zionhuang.music.playback.DownloadUtil
+import com.zionhuang.music.playback.MusicService
+import com.zionhuang.music.playback.MusicService.MusicBinder
+import com.zionhuang.music.playback.PlayerConnection
+import com.zionhuang.music.ui.component.*
+import com.zionhuang.music.ui.component.shimmer.ShimmerTheme
+import com.zionhuang.music.ui.menu.YouTubeSongMenu
+import com.zionhuang.music.ui.player.BottomSheetPlayer
+import com.zionhuang.music.ui.screens.*
+import com.zionhuang.music.ui.screens.artist.ArtistItemsScreen
+import com.zionhuang.music.ui.screens.artist.ArtistScreen
+import com.zionhuang.music.ui.screens.artist.ArtistSongsScreen
+import com.zionhuang.music.ui.screens.library.LibraryAlbumsScreen
+import com.zionhuang.music.ui.screens.library.LibraryArtistsScreen
+import com.zionhuang.music.ui.screens.library.LibraryPlaylistsScreen
+import com.zionhuang.music.ui.screens.library.LibrarySongsScreen
+import com.zionhuang.music.ui.screens.playlist.BuiltInPlaylistScreen
+import com.zionhuang.music.ui.screens.playlist.LocalPlaylistScreen
+import com.zionhuang.music.ui.screens.playlist.OnlinePlaylistScreen
+import com.zionhuang.music.ui.screens.search.LocalSearchScreen
+import com.zionhuang.music.ui.screens.search.OnlineSearchResult
+import com.zionhuang.music.ui.screens.search.OnlineSearchScreen
+import com.zionhuang.music.ui.screens.settings.*
+import com.zionhuang.music.ui.theme.*
+import com.zionhuang.music.ui.utils.appBarScrollBehavior
+import com.zionhuang.music.ui.utils.canNavigateUp
+import com.zionhuang.music.ui.utils.resetHeightOffset
+import com.zionhuang.music.utils.dataStore
+import com.zionhuang.music.utils.get
+import com.zionhuang.music.utils.rememberEnumPreference
+import com.zionhuang.music.utils.rememberPreference
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+ @Inject
+ lateinit var database: MusicDatabase
+
+ @Inject
+ lateinit var downloadUtil: DownloadUtil
+
+ private var playerConnection by mutableStateOf(null)
+ private var mediaController: MediaController? = null
+ private val serviceConnection = object : ServiceConnection {
+ override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
+ if (service is MusicBinder) {
+ playerConnection = PlayerConnection(service, database, lifecycleScope)
+ }
+ }
+
+ override fun onServiceDisconnected(name: ComponentName?) {
+ playerConnection?.dispose()
+ playerConnection = null
+ }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ bindService(Intent(this, MusicService::class.java), serviceConnection, Context.BIND_AUTO_CREATE)
+ }
+
+ override fun onStop() {
+ super.onStop()
+ unbindService(serviceConnection)
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ mediaController?.release()
+ }
+
+ @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
+ @OptIn(ExperimentalMaterial3Api::class)
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+
+ // Connect to service so that notification and background playing will work
+ val sessionToken = SessionToken(this, ComponentName(this, MusicService::class.java))
+ val controllerFuture = MediaController.Builder(this, sessionToken).buildAsync()
+ controllerFuture.addListener(
+ { mediaController = controllerFuture.get() },
+ MoreExecutors.directExecutor()
+ )
+
+
+ setContent {
+ val enableDynamicTheme by rememberPreference(DynamicThemeKey, defaultValue = true)
+ val darkTheme by rememberEnumPreference(DarkModeKey, defaultValue = DarkMode.AUTO)
+ val pureBlack by rememberPreference(PureBlackKey, defaultValue = false)
+ val isSystemInDarkTheme = isSystemInDarkTheme()
+ val useDarkTheme = remember(darkTheme, isSystemInDarkTheme) {
+ if (darkTheme == DarkMode.AUTO) isSystemInDarkTheme else darkTheme == DarkMode.ON
+ }
+ LaunchedEffect(useDarkTheme) {
+ setSystemBarAppearance(useDarkTheme)
+ }
+ var themeColor by rememberSaveable(stateSaver = ColorSaver) {
+ mutableStateOf(DefaultThemeColor)
+ }
+
+ LaunchedEffect(playerConnection, enableDynamicTheme, isSystemInDarkTheme) {
+ val playerConnection = playerConnection
+ if (!enableDynamicTheme || playerConnection == null) {
+ themeColor = DefaultThemeColor
+ return@LaunchedEffect
+ }
+ playerConnection.service.currentMediaMetadata.collectLatest { song ->
+ themeColor = if (song != null) {
+ withContext(Dispatchers.IO) {
+ val result = imageLoader.execute(
+ ImageRequest.Builder(this@MainActivity)
+ .data(song.thumbnailUrl)
+ .allowHardware(false) // pixel access is not supported on Config#HARDWARE bitmaps
+ .build()
+ )
+ (result.drawable as? BitmapDrawable)?.bitmap?.extractThemeColor() ?: DefaultThemeColor
+ }
+ } else DefaultThemeColor
+ }
+ }
+
+ InnerTuneTheme(
+ darkTheme = useDarkTheme,
+ pureBlack = pureBlack,
+ themeColor = themeColor
+ ) {
+ BoxWithConstraints(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ ) {
+ val focusManager = LocalFocusManager.current
+ val density = LocalDensity.current
+ val windowsInsets = WindowInsets.systemBars
+ val bottomInset = with(density) { windowsInsets.getBottom(density).toDp() }
+
+ val navController = rememberNavController()
+ val navBackStackEntry by navController.currentBackStackEntryAsState()
+
+ val navigationItems = remember {
+ listOf(Screens.Home, Screens.Songs, Screens.Artists, Screens.Albums, Screens.Playlists)
+ }
+ val defaultOpenTab = remember {
+ dataStore[DefaultOpenTabKey].toEnum(defaultValue = NavigationTab.HOME)
+ }
+
+ val (query, onQueryChange) = rememberSaveable(stateSaver = TextFieldValue.Saver) {
+ mutableStateOf(TextFieldValue())
+ }
+ var active by rememberSaveable {
+ mutableStateOf(false)
+ }
+ val onActiveChange: (Boolean) -> Unit = { newActive ->
+ active = newActive
+ if (!newActive) {
+ focusManager.clearFocus()
+ if (navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route }) {
+ onQueryChange(TextFieldValue())
+ }
+ }
+ }
+ var searchSource by rememberEnumPreference(SearchSourceKey, SearchSource.ONLINE)
+
+ val onSearch: (String) -> Unit = {
+ if (it.isNotEmpty()) {
+ onActiveChange(false)
+ navController.navigate("search/$it")
+ if (dataStore[PauseSearchHistoryKey] != true) {
+ database.query {
+ insert(SearchHistory(query = it))
+ }
+ }
+ }
+ }
+
+ val shouldShowSearchBar = remember(active, navBackStackEntry) {
+ active || navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route } ||
+ navBackStackEntry?.destination?.route?.startsWith("search/") == true
+ }
+ val shouldShowNavigationBar = remember(navBackStackEntry, active) {
+ navBackStackEntry?.destination?.route == null ||
+ navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route } && !active
+ }
+ val navigationBarHeight by animateDpAsState(
+ targetValue = if (shouldShowNavigationBar) NavigationBarHeight else 0.dp,
+ animationSpec = NavigationBarAnimationSpec,
+ label = ""
+ )
+
+ val playerBottomSheetState = rememberBottomSheetState(
+ dismissedBound = 0.dp,
+ collapsedBound = bottomInset + (if (shouldShowNavigationBar) NavigationBarHeight else 0.dp) + MiniPlayerHeight,
+ expandedBound = maxHeight,
+ )
+
+ val playerAwareWindowInsets = remember(bottomInset, shouldShowNavigationBar, playerBottomSheetState.isDismissed) {
+ var bottom = bottomInset
+ if (shouldShowNavigationBar) bottom += NavigationBarHeight
+ if (!playerBottomSheetState.isDismissed) bottom += MiniPlayerHeight
+ windowsInsets
+ .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top)
+ .add(WindowInsets(top = AppBarHeight, bottom = bottom))
+ }
+
+ val scrollBehavior = appBarScrollBehavior(
+ canScroll = {
+ navBackStackEntry?.destination?.route?.startsWith("search/") == false &&
+ (playerBottomSheetState.isCollapsed || playerBottomSheetState.isDismissed)
+ }
+ )
+
+ LaunchedEffect(navBackStackEntry) {
+ if (navBackStackEntry?.destination?.route?.startsWith("search/") == true) {
+ val searchQuery = navBackStackEntry?.arguments?.getString("query")!!
+ onQueryChange(TextFieldValue(searchQuery, TextRange(searchQuery.length)))
+ } else if (navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route }) {
+ onQueryChange(TextFieldValue())
+ }
+ scrollBehavior.state.resetHeightOffset()
+ }
+ LaunchedEffect(active) {
+ if (active) {
+ scrollBehavior.state.resetHeightOffset()
+ }
+ }
+
+ LaunchedEffect(playerConnection) {
+ val player = playerConnection?.player ?: return@LaunchedEffect
+ if (player.currentMediaItem == null) {
+ if (!playerBottomSheetState.isDismissed) {
+ playerBottomSheetState.dismiss()
+ }
+ } else {
+ if (playerBottomSheetState.isDismissed) {
+ playerBottomSheetState.collapseSoft()
+ }
+ }
+ }
+
+ DisposableEffect(playerConnection, playerBottomSheetState) {
+ val player = playerConnection?.player ?: return@DisposableEffect onDispose { }
+ val listener = object : Player.Listener {
+ override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
+ if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED && mediaItem != null && playerBottomSheetState.isDismissed) {
+ playerBottomSheetState.collapseSoft()
+ }
+ }
+ }
+ player.addListener(listener)
+ onDispose {
+ player.removeListener(listener)
+ }
+ }
+
+ val coroutineScope = rememberCoroutineScope()
+ var sharedSong: SongItem? by remember {
+ mutableStateOf(null)
+ }
+ DisposableEffect(Unit) {
+ val listener = Consumer { intent ->
+ val uri = intent.data ?: intent.extras?.getString(Intent.EXTRA_TEXT)?.toUri() ?: return@Consumer
+ when (val path = uri.pathSegments.firstOrNull()) {
+ "playlist" -> uri.getQueryParameter("list")?.let { playlistId ->
+ if (playlistId.startsWith("OLAK5uy_")) {
+ coroutineScope.launch {
+ YouTube.albumSongs(playlistId).onSuccess { songs ->
+ songs.firstOrNull()?.album?.id?.let { browseId ->
+ navController.navigate("album/$browseId")
+ }
+ }
+ }
+ } else {
+ navController.navigate("online_playlist/$playlistId")
+ }
+ }
+
+ "channel", "c" -> uri.lastPathSegment?.let { artistId ->
+ navController.navigate("artist/$artistId")
+ }
+
+ else -> when {
+ path == "watch" -> uri.getQueryParameter("v")
+ uri.host == "youtu.be" -> path
+ else -> null
+ }?.let { videoId ->
+ coroutineScope.launch {
+ withContext(Dispatchers.IO) {
+ YouTube.queue(listOf(videoId))
+ }.onSuccess {
+ sharedSong = it.firstOrNull()
+ }
+ }
+ }
+ }
+ }
+
+ addOnNewIntentListener(listener)
+ onDispose { removeOnNewIntentListener(listener) }
+ }
+
+ CompositionLocalProvider(
+ LocalDatabase provides database,
+ LocalContentColor provides contentColorFor(MaterialTheme.colorScheme.background),
+ LocalPlayerConnection provides playerConnection,
+ LocalPlayerAwareWindowInsets provides playerAwareWindowInsets,
+ LocalDownloadUtil provides downloadUtil,
+ LocalShimmerTheme provides ShimmerTheme
+ ) {
+ NavHost(
+ navController = navController,
+ startDestination = when (defaultOpenTab) {
+ NavigationTab.HOME -> Screens.Home
+ NavigationTab.SONG -> Screens.Songs
+ NavigationTab.ARTIST -> Screens.Artists
+ NavigationTab.ALBUM -> Screens.Albums
+ NavigationTab.PLAYLIST -> Screens.Playlists
+ }.route,
+ modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
+ ) {
+ composable(Screens.Home.route) {
+ HomeScreen(navController)
+ }
+ composable(Screens.Songs.route) {
+ LibrarySongsScreen(navController)
+ }
+ composable(Screens.Artists.route) {
+ LibraryArtistsScreen(navController)
+ }
+ composable(Screens.Albums.route) {
+ LibraryAlbumsScreen(navController)
+ }
+ composable(Screens.Playlists.route) {
+ LibraryPlaylistsScreen(navController)
+ }
+ composable("history") {
+ HistoryScreen(navController)
+ }
+ composable("stats") {
+ StatsScreen(navController)
+ }
+ composable("new_release") {
+ NewReleaseScreen(navController, scrollBehavior)
+ }
+ composable(
+ route = "search/{query}",
+ arguments = listOf(
+ navArgument("query") {
+ type = NavType.StringType
+ }
+ )
+ ) {
+ OnlineSearchResult(navController)
+ }
+ composable(
+ route = "album/{albumId}",
+ arguments = listOf(
+ navArgument("albumId") {
+ type = NavType.StringType
+ },
+ )
+ ) {
+ AlbumScreen(navController, scrollBehavior)
+ }
+ composable(
+ route = "artist/{artistId}",
+ arguments = listOf(
+ navArgument("artistId") {
+ type = NavType.StringType
+ }
+ )
+ ) { backStackEntry ->
+ val artistId = backStackEntry.arguments?.getString("artistId")!!
+ if (artistId.startsWith("LA")) {
+ ArtistSongsScreen(navController, scrollBehavior)
+ } else {
+ ArtistScreen(navController, scrollBehavior)
+ }
+ }
+ composable(
+ route = "artist/{artistId}/songs",
+ arguments = listOf(
+ navArgument("artistId") {
+ type = NavType.StringType
+ }
+ )
+ ) {
+ ArtistSongsScreen(navController, scrollBehavior)
+ }
+ composable(
+ route = "artist/{artistId}/items?browseId={browseId}?params={params}",
+ arguments = listOf(
+ navArgument("artistId") {
+ type = NavType.StringType
+ },
+ navArgument("browseId") {
+ type = NavType.StringType
+ nullable = true
+ },
+ navArgument("params") {
+ type = NavType.StringType
+ nullable = true
+ }
+ )
+ ) {
+ ArtistItemsScreen(navController, scrollBehavior)
+ }
+ composable(
+ route = "online_playlist/{playlistId}",
+ arguments = listOf(
+ navArgument("playlistId") {
+ type = NavType.StringType
+ }
+ )
+ ) {
+ OnlinePlaylistScreen(navController, scrollBehavior)
+ }
+ composable(
+ route = "local_playlist/{playlistId}",
+ arguments = listOf(
+ navArgument("playlistId") {
+ type = NavType.StringType
+ }
+ )
+ ) { backStackEntry ->
+ val playlistId = backStackEntry.arguments?.getString("playlistId")!!
+ if (playlistId == LIKED_PLAYLIST_ID || playlistId == DOWNLOADED_PLAYLIST_ID) {
+ BuiltInPlaylistScreen(navController, scrollBehavior)
+ } else {
+ LocalPlaylistScreen(navController, scrollBehavior)
+ }
+ }
+ composable("settings") {
+ SettingsScreen(navController, scrollBehavior)
+ }
+ composable("settings/appearance") {
+ AppearanceSettings(navController, scrollBehavior)
+ }
+ composable("settings/content") {
+ ContentSettings(navController, scrollBehavior)
+ }
+ composable("settings/player") {
+ PlayerSettings(navController, scrollBehavior)
+ }
+ composable("settings/storage") {
+ StorageSettings(navController, scrollBehavior)
+ }
+ composable("settings/privacy") {
+ PrivacySettings(navController, scrollBehavior)
+ }
+ composable("settings/backup_restore") {
+ BackupAndRestore(navController, scrollBehavior)
+ }
+ composable("settings/about") {
+ AboutScreen(navController, scrollBehavior)
+ }
+ }
+
+ AnimatedVisibility(
+ visible = shouldShowSearchBar,
+ enter = fadeIn(),
+ exit = fadeOut()
+ ) {
+ SearchBar(
+ query = query,
+ onQueryChange = onQueryChange,
+ onSearch = onSearch,
+ active = active,
+ onActiveChange = onActiveChange,
+ scrollBehavior = scrollBehavior,
+ placeholder = {
+ Text(
+ text = stringResource(
+ if (!active) R.string.search
+ else when (searchSource) {
+ SearchSource.LOCAL -> R.string.search_library
+ SearchSource.ONLINE -> R.string.search_yt_music
+ }
+ )
+ )
+ },
+ leadingIcon = {
+ IconButton(onClick = {
+ when {
+ active -> onActiveChange(false)
+ navController.canNavigateUp && !navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route } -> {
+ navController.navigateUp()
+ }
+
+ else -> onActiveChange(true)
+ }
+ }) {
+ Icon(
+ painterResource(
+ if (active || (navController.canNavigateUp && !navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route })) {
+ R.drawable.arrow_back
+ } else {
+ R.drawable.search
+ }
+ ),
+ contentDescription = null
+ )
+ }
+ },
+ trailingIcon = {
+ if (active) {
+ if (query.text.isNotEmpty()) {
+ IconButton(
+ onClick = { onQueryChange(TextFieldValue("")) }
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.close),
+ contentDescription = null
+ )
+ }
+ }
+ IconButton(
+ onClick = {
+ searchSource = if (searchSource == SearchSource.ONLINE) SearchSource.LOCAL else SearchSource.ONLINE
+ }
+ ) {
+ Icon(
+ painter = painterResource(
+ when (searchSource) {
+ SearchSource.LOCAL -> R.drawable.library_music
+ SearchSource.ONLINE -> R.drawable.language
+ }
+ ),
+ contentDescription = null
+ )
+ }
+ }
+ },
+ modifier = Modifier.align(Alignment.TopCenter)
+ ) {
+ Crossfade(
+ targetState = searchSource,
+ label = "",
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(bottom = if (!playerBottomSheetState.isDismissed) MiniPlayerHeight else 0.dp)
+ .navigationBarsPadding()
+ ) { searchSource ->
+ when (searchSource) {
+ SearchSource.LOCAL -> LocalSearchScreen(
+ query = query.text,
+ navController = navController,
+ onDismiss = { onActiveChange(false) }
+ )
+
+ SearchSource.ONLINE -> OnlineSearchScreen(
+ query = query.text,
+ onQueryChange = onQueryChange,
+ navController = navController,
+ onSearch = {
+ navController.navigate("search/$it")
+ if (dataStore[PauseSearchHistoryKey] != true) {
+ database.query {
+ insert(SearchHistory(query = it))
+ }
+ }
+ },
+ onDismiss = { onActiveChange(false) }
+ )
+ }
+ }
+ }
+ }
+
+ BottomSheetPlayer(
+ state = playerBottomSheetState,
+ navController = navController
+ )
+
+ NavigationBar(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .offset {
+ if (navigationBarHeight == 0.dp) {
+ IntOffset(x = 0, y = (bottomInset + NavigationBarHeight).roundToPx())
+ } else {
+ val slideOffset = (bottomInset + NavigationBarHeight) * playerBottomSheetState.progress.coerceIn(0f, 1f)
+ val hideOffset = (bottomInset + NavigationBarHeight) * (1 - navigationBarHeight / NavigationBarHeight)
+ IntOffset(
+ x = 0,
+ y = (slideOffset + hideOffset).roundToPx()
+ )
+ }
+ }
+ ) {
+ navigationItems.fastForEach { screen ->
+ NavigationBarItem(
+ selected = navBackStackEntry?.destination?.hierarchy?.any { it.route == screen.route } == true,
+ icon = {
+ Icon(
+ painter = painterResource(screen.iconId),
+ contentDescription = null
+ )
+ },
+ label = {
+ Text(
+ text = stringResource(screen.titleId),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ onClick = {
+ navController.navigate(screen.route) {
+ popUpTo(navController.graph.startDestinationId) {
+ saveState = true
+ }
+ launchSingleTop = true
+ restoreState = true
+ }
+ }
+ )
+ }
+ }
+
+ BottomSheetMenu(
+ state = LocalMenuState.current,
+ modifier = Modifier.align(Alignment.BottomCenter)
+ )
+
+ sharedSong?.let { song ->
+ playerConnection?.let { playerConnection ->
+ Dialog(
+ onDismissRequest = { sharedSong = null },
+ properties = DialogProperties(usePlatformDefaultWidth = false)
+ ) {
+ Surface(
+ modifier = Modifier.padding(24.dp),
+ shape = RoundedCornerShape(16.dp),
+ color = AlertDialogDefaults.containerColor,
+ tonalElevation = AlertDialogDefaults.TonalElevation
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ YouTubeSongMenu(
+ song = song,
+ navController = navController,
+ playerConnection = playerConnection,
+ onDismiss = { sharedSong = null }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @SuppressLint("ObsoleteSdkInt")
+ private fun setSystemBarAppearance(isDark: Boolean) {
+ WindowCompat.getInsetsController(window, window.decorView.rootView).apply {
+ isAppearanceLightStatusBars = !isDark
+ isAppearanceLightNavigationBars = !isDark
+ }
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ window.statusBarColor = (if (isDark) Color.Transparent else Color.Black.copy(alpha = 0.2f)).toArgb()
+ }
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ window.navigationBarColor = (if (isDark) Color.Transparent else Color.Black.copy(alpha = 0.2f)).toArgb()
+ }
+ }
+}
+
+val LocalDatabase = staticCompositionLocalOf { error("No database provided") }
+val LocalPlayerConnection = staticCompositionLocalOf { error("No PlayerConnection provided") }
+val LocalPlayerAwareWindowInsets = compositionLocalOf { error("No WindowInsets provided") }
+val LocalDownloadUtil = staticCompositionLocalOf { error("No DownloadUtil provided") }
diff --git a/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt b/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt
new file mode 100644
index 000000000..8c0426ae9
--- /dev/null
+++ b/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt
@@ -0,0 +1,29 @@
+package com.zionhuang.music.constants
+
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+const val CONTENT_TYPE_HEADER = 0
+const val CONTENT_TYPE_LIST = 1
+const val CONTENT_TYPE_SONG = 2
+const val CONTENT_TYPE_ARTIST = 3
+const val CONTENT_TYPE_ALBUM = 4
+const val CONTENT_TYPE_PLAYLIST = 5
+
+val NavigationBarHeight = 80.dp
+val MiniPlayerHeight = 64.dp
+val QueuePeekHeight = 64.dp
+val AppBarHeight = 64.dp
+
+val ListItemHeight = 64.dp
+val SuggestionItemHeight = 56.dp
+val SearchFilterHeight = 48.dp
+val ListThumbnailSize = 48.dp
+val GridThumbnailHeight = 128.dp
+val AlbumThumbnailSize = 144.dp
+
+val ThumbnailCornerRadius = 6.dp
+
+val NavigationBarAnimationSpec = spring(stiffness = Spring.StiffnessMediumLow)
diff --git a/app/src/main/java/com/zionhuang/music/constants/Constants.kt b/app/src/main/java/com/zionhuang/music/constants/Constants.kt
deleted file mode 100644
index 9bff59d51..000000000
--- a/app/src/main/java/com/zionhuang/music/constants/Constants.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-package com.zionhuang.music.constants
-
-object Constants {
- const val ACTION_SHOW_BOTTOM_SHEET = "show_bottom_sheet"
-
- const val BOTTOM_SHEET_STATE = "bottom_sheet_state"
- const val QUEUE_SHEET_STATE = "queue_sheet_state"
-
- const val SONG_HEADER_ID = "song_header"
- const val ARTIST_HEADER_ID = "artist_header"
- const val ALBUM_HEADER_ID = "album_header"
- const val PLAYLIST_HEADER_ID = "playlist_header"
- const val PLAYLIST_SONG_HEADER_ID = "playlist_song_header"
- const val TEXT_HEADER_ID = "text_header"
-
- const val LIKED_PLAYLIST_ID = "LP_LIKED"
- const val DOWNLOADED_PLAYLIST_ID = "LP_DOWNLOADED"
-
- const val GITHUB_URL = "https://github.com/z-huang/InnerTune"
- const val GITHUB_ISSUE_URL = "https://github.com/z-huang/InnerTune/issues"
-
- const val ERROR_INFO = "error_info"
-
- const val VISITOR_DATA = "visitor_data"
- const val INNERTUBE_COOKIE = "innertube_cookie"
- const val ACCOUNT_NAME = "account_name"
- const val ACCOUNT_EMAIL = "account_email"
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/constants/MediaConstants.kt b/app/src/main/java/com/zionhuang/music/constants/MediaConstants.kt
deleted file mode 100644
index a1b56d9a4..000000000
--- a/app/src/main/java/com/zionhuang/music/constants/MediaConstants.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-package com.zionhuang.music.constants
-
-object MediaConstants {
- const val EXTRA_MEDIA_METADATA = "media_metadata"
- const val EXTRA_MEDIA_METADATA_ITEMS = "media_metadata_items"
- const val EXTRA_SONG = "song"
- const val EXTRA_ARTIST = "artist"
- const val EXTRA_PLAYLIST = "playlist"
- const val EXTRA_BLOCK = "block"
-
- const val STATE_NOT_DOWNLOADED = 0
- const val STATE_PREPARING = 1
- const val STATE_DOWNLOADING = 2
- const val STATE_DOWNLOADED = 3
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/constants/MediaSessionConstants.kt b/app/src/main/java/com/zionhuang/music/constants/MediaSessionConstants.kt
index e11505ed4..0f95dbf60 100644
--- a/app/src/main/java/com/zionhuang/music/constants/MediaSessionConstants.kt
+++ b/app/src/main/java/com/zionhuang/music/constants/MediaSessionConstants.kt
@@ -1,15 +1,11 @@
package com.zionhuang.music.constants
+import android.os.Bundle
+import androidx.media3.session.SessionCommand
+
object MediaSessionConstants {
- const val ACTION_TOGGLE_LIBRARY = "action_toggle_library"
- const val ACTION_ADD_TO_LIBRARY = "action_add_to_library"
- const val ACTION_REMOVE_FROM_LIBRARY = "action_remove_from_library"
- const val ACTION_TOGGLE_LIKE = "action_toggle_like"
- const val ACTION_LIKE = "action_like"
- const val ACTION_UNLIKE = "action_unlike"
- const val ACTION_TOGGLE_SHUFFLE = "action_shuffle"
- const val COMMAND_SEEK_TO_QUEUE_ITEM = "seek_to_queue_item"
- const val COMMAND_PLAY_NEXT = "action_play_next"
- const val COMMAND_ADD_TO_QUEUE = "action_add_to_queue"
- const val EXTRA_QUEUE_INDEX = "index"
-}
\ No newline at end of file
+ const val ACTION_TOGGLE_LIBRARY = "TOGGLE_LIBRARY"
+ const val ACTION_TOGGLE_LIKE = "TOGGLE_LIKE"
+ val CommandToggleLibrary = SessionCommand(ACTION_TOGGLE_LIBRARY, Bundle.EMPTY)
+ val CommandToggleLike = SessionCommand(ACTION_TOGGLE_LIKE, Bundle.EMPTY)
+}
diff --git a/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt b/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt
new file mode 100644
index 000000000..ac29554e1
--- /dev/null
+++ b/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt
@@ -0,0 +1,291 @@
+package com.zionhuang.music.constants
+
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.floatPreferencesKey
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+
+val DynamicThemeKey = booleanPreferencesKey("dynamicTheme")
+val DarkModeKey = stringPreferencesKey("darkMode")
+val PureBlackKey = booleanPreferencesKey("pureBlack")
+val DefaultOpenTabKey = stringPreferencesKey("defaultOpenTab")
+
+const val SYSTEM_DEFAULT = "SYSTEM_DEFAULT"
+val ContentLanguageKey = stringPreferencesKey("contentLanguage")
+val ContentCountryKey = stringPreferencesKey("contentCountry")
+val ProxyEnabledKey = booleanPreferencesKey("proxyEnabled")
+val ProxyUrlKey = stringPreferencesKey("proxyUrl")
+val ProxyTypeKey = stringPreferencesKey("proxyType")
+
+val AudioQualityKey = stringPreferencesKey("audioQuality")
+
+enum class AudioQuality {
+ AUTO, HIGH, LOW
+}
+
+val PersistentQueueKey = booleanPreferencesKey("persistentQueue")
+val SkipSilenceKey = booleanPreferencesKey("skipSilence")
+val AudioNormalizationKey = booleanPreferencesKey("audioNormalization")
+
+val MaxImageCacheSizeKey = intPreferencesKey("maxImageCacheSize")
+val MaxSongCacheSizeKey = intPreferencesKey("maxSongCacheSize")
+
+val PauseListenHistoryKey = booleanPreferencesKey("pauseListenHistory")
+val PauseSearchHistoryKey = booleanPreferencesKey("pauseSearchHistory")
+val EnableKugouKey = booleanPreferencesKey("enableKugou")
+
+val SongSortTypeKey = stringPreferencesKey("songSortType")
+val SongSortDescendingKey = booleanPreferencesKey("songSortDescending")
+val DownloadedSongSortTypeKey = stringPreferencesKey("songSortType")
+val DownloadedSongSortDescendingKey = booleanPreferencesKey("songSortDescending")
+val PlaylistSongSortTypeKey = stringPreferencesKey("songSortType")
+val PlaylistSongSortDescendingKey = booleanPreferencesKey("songSortDescending")
+val ArtistSortTypeKey = stringPreferencesKey("artistSortType")
+val ArtistSortDescendingKey = booleanPreferencesKey("artistSortDescending")
+val AlbumSortTypeKey = stringPreferencesKey("albumSortType")
+val AlbumSortDescendingKey = booleanPreferencesKey("albumSortDescending")
+val PlaylistSortTypeKey = stringPreferencesKey("playlistSortType")
+val PlaylistSortDescendingKey = booleanPreferencesKey("playlistSortDescending")
+val ArtistSongSortTypeKey = stringPreferencesKey("artistSongSortType")
+val ArtistSongSortDescendingKey = booleanPreferencesKey("artistSongSortDescending")
+
+val PlaylistEditLockKey = booleanPreferencesKey("playlistEditLock")
+
+enum class SongSortType {
+ CREATE_DATE, NAME, ARTIST
+}
+
+enum class DownloadedSongSortType {
+ CREATE_DATE, NAME, ARTIST
+}
+
+enum class PlaylistSongSortType {
+ CUSTOM, CREATE_DATE, NAME, ARTIST
+}
+
+enum class ArtistSortType {
+ CREATE_DATE, NAME, SONG_COUNT
+}
+
+enum class ArtistSongSortType {
+ CREATE_DATE, NAME
+}
+
+enum class AlbumSortType {
+ CREATE_DATE, NAME, ARTIST, YEAR, SONG_COUNT, LENGTH
+}
+
+enum class PlaylistSortType {
+ CREATE_DATE, NAME, SONG_COUNT
+}
+
+val ShowLyricsKey = booleanPreferencesKey("showLyrics")
+val LyricsTextPositionKey = stringPreferencesKey("lyricsTextPosition")
+
+val NavTabConfigKey = stringPreferencesKey("navTabConfig")
+
+val PlayerVolumeKey = floatPreferencesKey("playerVolume")
+val RepeatModeKey = intPreferencesKey("repeatMode")
+
+val SearchSourceKey = stringPreferencesKey("searchSource")
+
+enum class SearchSource {
+ LOCAL, ONLINE
+}
+
+val VisitorDataKey = stringPreferencesKey("visitorData")
+val InnerTubeCookieKey = stringPreferencesKey("innerTubeCookie")
+val AccountNameKey = stringPreferencesKey("accountName")
+val AccountEmailKey = stringPreferencesKey("accountEmail")
+
+val LanguageCodeToName = mapOf(
+ "af" to "Afrikaans",
+ "az" to "Azərbaycan",
+ "id" to "Bahasa Indonesia",
+ "ms" to "Bahasa Malaysia",
+ "ca" to "Català",
+ "cs" to "Čeština",
+ "da" to "Dansk",
+ "de" to "Deutsch",
+ "et" to "Eesti",
+ "en-GB" to "English (UK)",
+ "en" to "English (US)",
+ "es" to "Español (España)",
+ "es-419" to "Español (Latinoamérica)",
+ "eu" to "Euskara",
+ "fil" to "Filipino",
+ "fr" to "Français",
+ "fr-CA" to "Français (Canada)",
+ "gl" to "Galego",
+ "hr" to "Hrvatski",
+ "zu" to "IsiZulu",
+ "is" to "Íslenska",
+ "it" to "Italiano",
+ "sw" to "Kiswahili",
+ "lt" to "Lietuvių",
+ "hu" to "Magyar",
+ "nl" to "Nederlands",
+ "no" to "Norsk",
+ "or" to "Odia",
+ "uz" to "O‘zbe",
+ "pl" to "Polski",
+ "pt-PT" to "Português",
+ "pt" to "Português (Brasil)",
+ "ro" to "Română",
+ "sq" to "Shqip",
+ "sk" to "Slovenčina",
+ "sl" to "Slovenščina",
+ "fi" to "Suomi",
+ "sv" to "Svenska",
+ "bo" to "Tibetan བོད་སྐད།",
+ "vi" to "Tiếng Việt",
+ "tr" to "Türkçe",
+ "bg" to "Български",
+ "ky" to "Кыргызча",
+ "kk" to "Қазақ Тілі",
+ "mk" to "Македонски",
+ "mn" to "Монгол",
+ "ru" to "Русский",
+ "sr" to "Српски",
+ "uk" to "Українська",
+ "el" to "Ελληνικά",
+ "hy" to "Հայերեն",
+ "iw" to "עברית",
+ "ur" to "اردو",
+ "ar" to "العربية",
+ "fa" to "فارسی",
+ "ne" to "नेपाली",
+ "mr" to "मराठी",
+ "hi" to "हिन्दी",
+ "bn" to "বাংলা",
+ "pa" to "ਪੰਜਾਬੀ",
+ "gu" to "ગુજરાતી",
+ "ta" to "தமிழ்",
+ "te" to "తెలుగు",
+ "kn" to "ಕನ್ನಡ",
+ "ml" to "മലയാളം",
+ "si" to "සිංහල",
+ "th" to "ภาษาไทย",
+ "lo" to "ລາວ",
+ "my" to "ဗမာ",
+ "ka" to "ქართული",
+ "am" to "አማርኛ",
+ "km" to "ខ្មែរ",
+ "zh-CN" to "中文 (简体)",
+ "zh-TW" to "中文 (繁體)",
+ "zh-HK" to "中文 (香港)",
+ "ja" to "日本語",
+ "ko" to "한국어",
+)
+
+val CountryCodeToName = mapOf(
+ "DZ" to "Algeria",
+ "AR" to "Argentina",
+ "AU" to "Australia",
+ "AT" to "Austria",
+ "AZ" to "Azerbaijan",
+ "BH" to "Bahrain",
+ "BD" to "Bangladesh",
+ "BY" to "Belarus",
+ "BE" to "Belgium",
+ "BO" to "Bolivia",
+ "BA" to "Bosnia and Herzegovina",
+ "BR" to "Brazil",
+ "BG" to "Bulgaria",
+ "KH" to "Cambodia",
+ "CA" to "Canada",
+ "CL" to "Chile",
+ "HK" to "Hong Kong",
+ "CO" to "Colombia",
+ "CR" to "Costa Rica",
+ "HR" to "Croatia",
+ "CY" to "Cyprus",
+ "CZ" to "Czech Republic",
+ "DK" to "Denmark",
+ "DO" to "Dominican Republic",
+ "EC" to "Ecuador",
+ "EG" to "Egypt",
+ "SV" to "El Salvador",
+ "EE" to "Estonia",
+ "FI" to "Finland",
+ "FR" to "France",
+ "GE" to "Georgia",
+ "DE" to "Germany",
+ "GH" to "Ghana",
+ "GR" to "Greece",
+ "GT" to "Guatemala",
+ "HN" to "Honduras",
+ "HU" to "Hungary",
+ "IS" to "Iceland",
+ "IN" to "India",
+ "ID" to "Indonesia",
+ "IQ" to "Iraq",
+ "IE" to "Ireland",
+ "IL" to "Israel",
+ "IT" to "Italy",
+ "JM" to "Jamaica",
+ "JP" to "Japan",
+ "JO" to "Jordan",
+ "KZ" to "Kazakhstan",
+ "KE" to "Kenya",
+ "KR" to "South Korea",
+ "KW" to "Kuwait",
+ "LA" to "Lao",
+ "LV" to "Latvia",
+ "LB" to "Lebanon",
+ "LY" to "Libya",
+ "LI" to "Liechtenstein",
+ "LT" to "Lithuania",
+ "LU" to "Luxembourg",
+ "MK" to "Macedonia",
+ "MY" to "Malaysia",
+ "MT" to "Malta",
+ "MX" to "Mexico",
+ "ME" to "Montenegro",
+ "MA" to "Morocco",
+ "NP" to "Nepal",
+ "NL" to "Netherlands",
+ "NZ" to "New Zealand",
+ "NI" to "Nicaragua",
+ "NG" to "Nigeria",
+ "NO" to "Norway",
+ "OM" to "Oman",
+ "PK" to "Pakistan",
+ "PA" to "Panama",
+ "PG" to "Papua New Guinea",
+ "PY" to "Paraguay",
+ "PE" to "Peru",
+ "PH" to "Philippines",
+ "PL" to "Poland",
+ "PT" to "Portugal",
+ "PR" to "Puerto Rico",
+ "QA" to "Qatar",
+ "RO" to "Romania",
+ "RU" to "Russian Federation",
+ "SA" to "Saudi Arabia",
+ "SN" to "Senegal",
+ "RS" to "Serbia",
+ "SG" to "Singapore",
+ "SK" to "Slovakia",
+ "SI" to "Slovenia",
+ "ZA" to "South Africa",
+ "ES" to "Spain",
+ "LK" to "Sri Lanka",
+ "SE" to "Sweden",
+ "CH" to "Switzerland",
+ "TW" to "Taiwan",
+ "TZ" to "Tanzania",
+ "TH" to "Thailand",
+ "TN" to "Tunisia",
+ "TR" to "Turkey",
+ "UG" to "Uganda",
+ "UA" to "Ukraine",
+ "AE" to "United Arab Emirates",
+ "GB" to "United Kingdom",
+ "US" to "United States",
+ "UY" to "Uruguay",
+ "VE" to "Venezuela (Bolivarian Republic)",
+ "VN" to "Vietnam",
+ "YE" to "Yemen",
+ "ZW" to "Zimbabwe",
+)
diff --git a/app/src/main/java/com/zionhuang/music/db/Converters.kt b/app/src/main/java/com/zionhuang/music/db/Converters.kt
index ad9470910..7b133bca6 100644
--- a/app/src/main/java/com/zionhuang/music/db/Converters.kt
+++ b/app/src/main/java/com/zionhuang/music/db/Converters.kt
@@ -7,10 +7,11 @@ import java.time.ZoneOffset
class Converters {
@TypeConverter
- fun fromTimestamp(value: Long): LocalDateTime =
- LocalDateTime.ofInstant(Instant.ofEpochMilli(value), ZoneOffset.UTC)
+ fun fromTimestamp(value: Long?): LocalDateTime? =
+ if (value != null) LocalDateTime.ofInstant(Instant.ofEpochMilli(value), ZoneOffset.UTC)
+ else null
@TypeConverter
- fun dateToTimestamp(date: LocalDateTime): Long =
- date.atZone(ZoneOffset.UTC).toInstant().toEpochMilli()
-}
\ No newline at end of file
+ fun dateToTimestamp(date: LocalDateTime?): Long? =
+ date?.atZone(ZoneOffset.UTC)?.toInstant()?.toEpochMilli()
+}
diff --git a/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt
new file mode 100644
index 000000000..e387ae520
--- /dev/null
+++ b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt
@@ -0,0 +1,498 @@
+package com.zionhuang.music.db
+
+import androidx.room.*
+import androidx.sqlite.db.SupportSQLiteQuery
+import com.zionhuang.innertube.models.SongItem
+import com.zionhuang.innertube.pages.AlbumPage
+import com.zionhuang.innertube.pages.ArtistPage
+import com.zionhuang.music.constants.*
+import com.zionhuang.music.db.entities.*
+import com.zionhuang.music.extensions.reversed
+import com.zionhuang.music.extensions.toSQLiteQuery
+import com.zionhuang.music.models.MediaMetadata
+import com.zionhuang.music.models.toMediaMetadata
+import com.zionhuang.music.ui.utils.resize
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import java.time.LocalDateTime
+
+@Dao
+interface DatabaseDao {
+ @Transaction
+ @Query("SELECT * FROM song WHERE inLibrary IS NOT NULL ORDER BY rowId")
+ fun songsByRowIdAsc(): Flow>
+
+ @Transaction
+ @Query("SELECT * FROM song WHERE inLibrary IS NOT NULL ORDER BY inLibrary")
+ fun songsByCreateDateAsc(): Flow>
+
+ @Transaction
+ @Query("SELECT * FROM song WHERE inLibrary IS NOT NULL ORDER BY title")
+ fun songsByNameAsc(): Flow>
+
+ fun songs(sortType: SongSortType, descending: Boolean) =
+ when (sortType) {
+ SongSortType.CREATE_DATE -> songsByCreateDateAsc()
+ SongSortType.NAME -> songsByNameAsc()
+ SongSortType.ARTIST -> songsByRowIdAsc().map { songs ->
+ songs.sortedBy { song ->
+ song.artists.joinToString(separator = "") { it.name }
+ }
+ }
+ }.map { it.reversed(descending) }
+
+ @Transaction
+ @Query("SELECT * FROM song WHERE liked ORDER BY rowId")
+ fun likedSongsByRowIdAsc(): Flow>
+
+ @Transaction
+ @Query("SELECT * FROM song WHERE liked ORDER BY inLibrary")
+ fun likedSongsByCreateDateAsc(): Flow>
+
+ @Transaction
+ @Query("SELECT * FROM song WHERE liked ORDER BY title")
+ fun likedSongsByNameAsc(): Flow>
+
+ fun likedSongs(sortType: SongSortType, descending: Boolean) =
+ when (sortType) {
+ SongSortType.CREATE_DATE -> likedSongsByCreateDateAsc()
+ SongSortType.NAME -> likedSongsByNameAsc()
+ SongSortType.ARTIST -> likedSongsByRowIdAsc().map { songs ->
+ songs.sortedBy { song ->
+ song.artists.joinToString(separator = "") { it.name }
+ }
+ }
+ }.map { it.reversed(descending) }
+
+ @Query("SELECT COUNT(1) FROM song WHERE liked")
+ fun likedSongsCount(): Flow
+
+ @Transaction
+ @Query("SELECT song.* FROM song JOIN song_album_map ON song.id = song_album_map.songId WHERE song_album_map.albumId = :albumId")
+ fun albumSongs(albumId: String): Flow>
+
+ @Transaction
+ @Query("SELECT * FROM playlist_song_map WHERE playlistId = :playlistId ORDER BY position")
+ fun playlistSongs(playlistId: String): Flow>
+
+ @Transaction
+ @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId AND inLibrary IS NOT NULL ORDER BY inLibrary")
+ fun artistSongsByCreateDateAsc(artistId: String): Flow>
+
+ @Transaction
+ @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId AND inLibrary IS NOT NULL ORDER BY title")
+ fun artistSongsByNameAsc(artistId: String): Flow>
+
+ fun artistSongs(artistId: String, sortType: ArtistSongSortType, descending: Boolean) =
+ when (sortType) {
+ ArtistSongSortType.CREATE_DATE -> artistSongsByCreateDateAsc(artistId)
+ ArtistSongSortType.NAME -> artistSongsByNameAsc(artistId)
+ }.map { it.reversed(descending) }
+
+ @Transaction
+ @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId AND inLibrary IS NOT NULL LIMIT :previewSize")
+ fun artistSongsPreview(artistId: String, previewSize: Int = 3): Flow>
+
+ @Transaction
+ @Query(
+ """
+ SELECT song.*
+ FROM (SELECT *, COUNT(1) AS referredCount
+ FROM related_song_map
+ GROUP BY relatedSongId) map
+ JOIN song ON song.id = map.relatedSongId
+ WHERE songId IN (SELECT songId
+ FROM (SELECT songId
+ FROM event
+ ORDER BY ROWID DESC
+ LIMIT 5)
+ UNION
+ SELECT songId
+ FROM (SELECT songId
+ FROM event
+ WHERE timestamp > :now - 86400000 * 7
+ GROUP BY songId
+ ORDER BY SUM(playTime) DESC
+ LIMIT 5)
+ UNION
+ SELECT id
+ FROM (SELECT id
+ FROM song
+ ORDER BY totalPlayTime DESC
+ LIMIT 10))
+ ORDER BY referredCount DESC
+ LIMIT 100
+ """
+ )
+ fun quickPicks(now: Long = System.currentTimeMillis()): Flow>
+
+ @Transaction
+ @Query("SELECT * FROM song ORDER BY totalPlayTime DESC LIMIT :limit")
+ fun mostPlayedSongs(limit: Int = 6): Flow>
+
+ @Transaction
+ @Query(
+ """
+ SELECT artist.*,
+ (SELECT COUNT(1)
+ FROM song_artist_map
+ JOIN song ON song_artist_map.songId = song.id
+ WHERE artistId = artist.id
+ AND song.inLibrary IS NOT NULL) AS songCount
+ FROM (SELECT artistId, SUM(playtime) AS totalPlaytime
+ FROM (SELECT *, (SELECT totalPlayTime FROM song WHERE id = songId) AS playtime
+ FROM song_artist_map)
+ GROUP BY artistId)
+ JOIN artist
+ ON artist.id = artistId
+ ORDER BY totalPlaytime DESC
+ LIMIT :limit
+ """
+ )
+ fun mostPlayedArtists(limit: Int = 6): Flow>
+
+ @Transaction
+ @Query("SELECT * FROM song WHERE id = :songId")
+ fun song(songId: String?): Flow
+
+ @Transaction
+ @Query("SELECT * FROM song WHERE id IN (:songIds)")
+ fun songs(songIds: List): Flow>
+
+ @Query("SELECT * FROM format WHERE id = :id")
+ fun format(id: String?): Flow
+
+ @Query("SELECT * FROM lyrics WHERE id = :id")
+ fun lyrics(id: String?): Flow
+
+ @Transaction
+ @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE songCount > 0 ORDER BY rowId")
+ fun artistsByCreateDateAsc(): Flow>
+
+ @Transaction
+ @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE songCount > 0 ORDER BY name")
+ fun artistsByNameAsc(): Flow>
+
+ @Transaction
+ @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE songCount > 0 ORDER BY songCount")
+ fun artistsBySongCountAsc(): Flow>
+
+ fun artists(sortType: ArtistSortType, descending: Boolean) =
+ when (sortType) {
+ ArtistSortType.CREATE_DATE -> artistsByCreateDateAsc()
+ ArtistSortType.NAME -> artistsByNameAsc()
+ ArtistSortType.SONG_COUNT -> artistsBySongCountAsc()
+ }.map { it.reversed(descending) }
+
+ @Query("SELECT * FROM artist WHERE id = :id")
+ fun artist(id: String): Flow
+
+ @Transaction
+ @Query("SELECT * FROM album ORDER BY rowId")
+ fun albumsByRowIdAsc(): Flow>
+
+ @Transaction
+ @Query("SELECT * FROM album ORDER BY createDate")
+ fun albumsByCreateDateAsc(): Flow>
+
+ @Transaction
+ @Query("SELECT * FROM album ORDER BY title")
+ fun albumsByNameAsc(): Flow>
+
+ @Transaction
+ @Query("SELECT * FROM album ORDER BY year")
+ fun albumsByYearAsc(): Flow>
+
+ @Transaction
+ @Query("SELECT * FROM album ORDER BY songCount")
+ fun albumsBySongCountAsc(): Flow>
+
+ @Transaction
+ @Query("SELECT * FROM album ORDER BY duration")
+ fun albumsByLengthAsc(): Flow>
+
+ fun albums(sortType: AlbumSortType, descending: Boolean) =
+ when (sortType) {
+ AlbumSortType.CREATE_DATE -> albumsByCreateDateAsc()
+ AlbumSortType.NAME -> albumsByNameAsc()
+ AlbumSortType.ARTIST -> albumsByRowIdAsc().map { albums ->
+ albums.sortedBy { album ->
+ album.artists.joinToString(separator = "") { it.name }
+ }
+ }
+
+ AlbumSortType.YEAR -> albumsByYearAsc()
+ AlbumSortType.SONG_COUNT -> albumsBySongCountAsc()
+ AlbumSortType.LENGTH -> albumsByLengthAsc()
+ }.map { it.reversed(descending) }
+
+ @Transaction
+ @Query("SELECT * FROM album WHERE id = :id")
+ fun album(id: String): Flow
+
+ @Transaction
+ @Query("SELECT * FROM album WHERE id = :albumId")
+ fun albumWithSongs(albumId: String): Flow
+
+ @Transaction
+ @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist ORDER BY rowId")
+ fun playlistsByCreateDateAsc(): Flow>
+
+ @Transaction
+ @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist ORDER BY name")
+ fun playlistsByNameAsc(): Flow>
+
+ @Transaction
+ @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist ORDER BY songCount")
+ fun playlistsBySongCountAsc(): Flow>
+
+ fun playlists(sortType: PlaylistSortType, descending: Boolean) =
+ when (sortType) {
+ PlaylistSortType.CREATE_DATE -> playlistsByCreateDateAsc()
+ PlaylistSortType.NAME -> playlistsByNameAsc()
+ PlaylistSortType.SONG_COUNT -> playlistsBySongCountAsc()
+ }.map { it.reversed(descending) }
+
+ @Transaction
+ @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist WHERE id = :playlistId")
+ fun playlist(playlistId: String): Flow
+
+ @Transaction
+ @Query("SELECT * FROM song WHERE title LIKE '%' || :query || '%' AND inLibrary IS NOT NULL LIMIT :previewSize")
+ fun searchSongs(query: String, previewSize: Int = Int.MAX_VALUE): Flow>
+
+ @Transaction
+ @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE name LIKE '%' || :query || '%' AND songCount > 0 LIMIT :previewSize")
+ fun searchArtists(query: String, previewSize: Int = Int.MAX_VALUE): Flow>
+
+ @Transaction
+ @Query("SELECT * FROM album WHERE title LIKE '%' || :query || '%' LIMIT :previewSize")
+ fun searchAlbums(query: String, previewSize: Int = Int.MAX_VALUE): Flow>
+
+ @Transaction
+ @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist WHERE name LIKE '%' || :query || '%' LIMIT :previewSize")
+ fun searchPlaylists(query: String, previewSize: Int = Int.MAX_VALUE): Flow>
+
+ @Transaction
+ @Query("SELECT * FROM event ORDER BY rowId DESC")
+ fun events(): Flow>
+
+ @Query("DELETE FROM event")
+ fun clearListenHistory()
+
+ @Query("SELECT * FROM search_history WHERE `query` LIKE :query || '%' ORDER BY id DESC")
+ fun searchHistory(query: String = ""): Flow>
+
+ @Query("DELETE FROM search_history")
+ fun clearSearchHistory()
+
+ @Query("UPDATE song SET totalPlayTime = totalPlayTime + :playTime WHERE id = :songId")
+ fun incrementTotalPlayTime(songId: String, playTime: Long)
+
+ @Query("UPDATE song SET inLibrary = :inLibrary WHERE id = :songId")
+ fun inLibrary(songId: String, inLibrary: LocalDateTime?)
+
+ @Query("SELECT COUNT(1) FROM related_song_map WHERE songId = :songId LIMIT 1")
+ fun hasRelatedSongs(songId: String): Boolean
+
+ @Query(
+ """
+ UPDATE playlist_song_map SET position =
+ CASE
+ WHEN position < :fromPosition THEN position + 1
+ WHEN position > :fromPosition THEN position - 1
+ ELSE :toPosition
+ END
+ WHERE playlistId = :playlistId AND position BETWEEN MIN(:fromPosition, :toPosition) AND MAX(:fromPosition, :toPosition)
+ """
+ )
+ fun move(playlistId: String, fromPosition: Int, toPosition: Int)
+
+ @Query("DELETE FROM playlist_song_map WHERE playlistId = :playlistId")
+ fun clearPlaylist(playlistId: String)
+
+ @Query("SELECT * FROM artist WHERE name = :name")
+ fun artistByName(name: String): ArtistEntity?
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ fun insert(song: SongEntity): Long
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ fun insert(artist: ArtistEntity)
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ fun insert(album: AlbumEntity): Long
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ fun insert(playlist: PlaylistEntity)
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ fun insert(map: SongArtistMap)
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ fun insert(map: SongAlbumMap)
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ fun insert(map: AlbumArtistMap)
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ fun insert(map: PlaylistSongMap)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ fun insert(searchHistory: SearchHistory)
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ fun insert(event: Event)
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ fun insert(map: RelatedSongMap)
+
+ @Transaction
+ fun insert(mediaMetadata: MediaMetadata, block: (SongEntity) -> SongEntity = { it }) {
+ if (insert(mediaMetadata.toSongEntity().let(block)) == -1L) return
+ mediaMetadata.artists.forEachIndexed { index, artist ->
+ val artistId = artist.id ?: artistByName(artist.name)?.id ?: ArtistEntity.generateArtistId()
+ insert(
+ ArtistEntity(
+ id = artistId,
+ name = artist.name
+ )
+ )
+ insert(
+ SongArtistMap(
+ songId = mediaMetadata.id,
+ artistId = artistId,
+ position = index
+ )
+ )
+ }
+ }
+
+ @Transaction
+ fun insert(albumPage: AlbumPage) {
+ if (insert(AlbumEntity(
+ id = albumPage.album.browseId,
+ title = albumPage.album.title,
+ year = albumPage.album.year,
+ thumbnailUrl = albumPage.album.thumbnail,
+ songCount = albumPage.songs.size,
+ duration = albumPage.songs.sumOf { it.duration ?: 0 }
+ )) == -1L
+ ) return
+ albumPage.songs.map(SongItem::toMediaMetadata)
+ .onEach(::insert)
+ .mapIndexed { index, song ->
+ SongAlbumMap(
+ songId = song.id,
+ albumId = albumPage.album.browseId,
+ index = index
+ )
+ }
+ .forEach(::upsert)
+ albumPage.album.artists
+ ?.map { artist ->
+ ArtistEntity(
+ id = artist.id ?: artistByName(artist.name)?.id ?: ArtistEntity.generateArtistId(),
+ name = artist.name
+ )
+ }
+ ?.onEach(::insert)
+ ?.mapIndexed { index, artist ->
+ AlbumArtistMap(
+ albumId = albumPage.album.browseId,
+ artistId = artist.id,
+ order = index
+ )
+ }
+ ?.forEach(::insert)
+ }
+
+ @Transaction
+ fun insert(albumWithSongs: AlbumWithSongs) {
+ if (insert(albumWithSongs.album) == -1L) return
+ albumWithSongs.songs.map(Song::toMediaMetadata).forEach(::insert)
+ albumWithSongs.songs.mapIndexed { index, song ->
+ SongAlbumMap(
+ songId = song.id,
+ albumId = albumWithSongs.album.id,
+ index = index
+ )
+ }.forEach(::upsert)
+ albumWithSongs.artists.forEach(::insert)
+ albumWithSongs.artists.mapIndexed { index, artist ->
+ AlbumArtistMap(
+ albumId = albumWithSongs.album.id,
+ artistId = artist.id,
+ order = index
+ )
+ }.forEach(::insert)
+ }
+
+ @Update
+ fun update(song: SongEntity)
+
+ @Update
+ fun update(artist: ArtistEntity)
+
+ @Update
+ fun update(playlist: PlaylistEntity)
+
+ @Update
+ fun update(map: PlaylistSongMap)
+
+ fun update(artist: ArtistEntity, artistPage: ArtistPage) {
+ update(
+ artist.copy(
+ name = artistPage.artist.title,
+ thumbnailUrl = artistPage.artist.thumbnail.resize(544, 544),
+ lastUpdateTime = LocalDateTime.now()
+ )
+ )
+ }
+
+ @Upsert
+ fun upsert(map: SongAlbumMap)
+
+ @Upsert
+ fun upsert(lyrics: LyricsEntity)
+
+ @Upsert
+ fun upsert(format: FormatEntity)
+
+ @Delete
+ fun delete(song: SongEntity)
+
+ @Delete
+ fun delete(artist: ArtistEntity)
+
+ @Delete
+ fun delete(album: AlbumEntity)
+
+ @Delete
+ fun delete(playlist: PlaylistEntity)
+
+ @Delete
+ fun delete(playlistSongMap: PlaylistSongMap)
+
+ @Delete
+ fun delete(lyrics: LyricsEntity)
+
+ @Delete
+ fun delete(searchHistory: SearchHistory)
+
+ @Delete
+ fun delete(event: Event)
+
+ @Query("SELECT * FROM playlist_song_map WHERE songId = :songId")
+ fun playlistSongMaps(songId: String): List
+
+ @Query("SELECT * FROM playlist_song_map WHERE playlistId = :playlistId AND position >= :from ORDER BY position")
+ fun playlistSongMaps(playlistId: String, from: Int): List
+
+ @RawQuery
+ fun raw(supportSQLiteQuery: SupportSQLiteQuery): Int
+
+ fun checkpoint() {
+ raw("PRAGMA wal_checkpoint(FULL)".toSQLiteQuery())
+ }
+}
diff --git a/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt b/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt
index 8049b9e21..42597f518 100644
--- a/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt
+++ b/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt
@@ -1,21 +1,43 @@
package com.zionhuang.music.db
import android.content.Context
-import android.database.Cursor
-import android.database.sqlite.SQLiteDatabase.CONFLICT_ABORT
+import android.database.sqlite.SQLiteDatabase
import androidx.core.content.contentValuesOf
import androidx.room.*
+import androidx.room.migration.AutoMigrationSpec
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
-import com.zionhuang.music.db.daos.*
+import androidx.sqlite.db.SupportSQLiteOpenHelper
import com.zionhuang.music.db.entities.*
-import com.zionhuang.music.db.entities.ArtistEntity.Companion.generateArtistId
-import com.zionhuang.music.db.entities.PlaylistEntity.Companion.generatePlaylistId
import com.zionhuang.music.extensions.toSQLiteQuery
import java.time.Instant
+import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.*
+class MusicDatabase(
+ private val delegate: InternalDatabase,
+) : DatabaseDao by delegate.dao {
+ val openHelper: SupportSQLiteOpenHelper
+ get() = delegate.openHelper
+
+ fun query(block: MusicDatabase.() -> Unit) = with(delegate) {
+ queryExecutor.execute {
+ block(this@MusicDatabase)
+ }
+ }
+
+ fun transaction(block: MusicDatabase.() -> Unit) = with(delegate) {
+ transactionExecutor.execute {
+ runInTransaction {
+ block(this@MusicDatabase)
+ }
+ }
+ }
+
+ fun close() = delegate.close()
+}
+
@Database(
entities = [
SongEntity::class,
@@ -26,68 +48,76 @@ import java.util.*
SongAlbumMap::class,
AlbumArtistMap::class,
PlaylistSongMap::class,
- DownloadEntity::class,
SearchHistory::class,
FormatEntity::class,
- LyricsEntity::class
+ LyricsEntity::class,
+ Event::class,
+ RelatedSongMap::class
],
views = [
SortedSongArtistMap::class,
+ SortedSongAlbumMap::class,
PlaylistSongMapPreview::class
],
- version = 4,
+ version = 10,
exportSchema = true,
autoMigrations = [
AutoMigration(from = 2, to = 3),
- AutoMigration(from = 3, to = 4)
+ AutoMigration(from = 3, to = 4),
+ AutoMigration(from = 4, to = 5),
+ AutoMigration(from = 5, to = 6, spec = Migration5To6::class),
+ AutoMigration(from = 6, to = 7, spec = Migration6To7::class),
+ AutoMigration(from = 7, to = 8, spec = Migration7To8::class),
+ AutoMigration(from = 8, to = 9),
+ AutoMigration(from = 9, to = 10, spec = Migration9To10::class)
]
)
@TypeConverters(Converters::class)
-abstract class MusicDatabase : RoomDatabase() {
- abstract val songDao: SongDao
- abstract val artistDao: ArtistDao
- abstract val albumDao: AlbumDao
- abstract val playlistDao: PlaylistDao
- abstract val downloadDao: DownloadDao
- abstract val searchHistoryDao: SearchHistoryDao
- abstract val formatDao: FormatDao
- abstract val lyricsDao: LyricsDao
+abstract class InternalDatabase : RoomDatabase() {
+ abstract val dao: DatabaseDao
companion object {
const val DB_NAME = "song.db"
- @Volatile
- var INSTANCE: MusicDatabase? = null
-
- fun getInstance(context: Context): MusicDatabase {
- if (INSTANCE == null) {
- synchronized(MusicDatabase::class.java) {
- if (INSTANCE == null) {
- INSTANCE = Room.databaseBuilder(context, MusicDatabase::class.java, DB_NAME)
- .addMigrations(MIGRATION_1_2)
- .build()
- }
- }
- }
- return INSTANCE!!
- }
+ fun newInstance(context: Context): MusicDatabase =
+ MusicDatabase(
+ delegate = Room.databaseBuilder(context, InternalDatabase::class.java, DB_NAME)
+ .addMigrations(MIGRATION_1_2)
+ .build()
+ )
}
}
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
+ data class OldSongEntity(
+ val id: String,
+ val title: String,
+ val duration: Int = -1, // in seconds
+ val thumbnailUrl: String? = null,
+ val albumId: String? = null,
+ val albumName: String? = null,
+ val liked: Boolean = false,
+ val totalPlayTime: Long = 0, // in milliseconds
+ val downloadState: Int = SongEntity.STATE_NOT_DOWNLOADED,
+ val createDate: LocalDateTime = LocalDateTime.now(),
+ val modifyDate: LocalDateTime = LocalDateTime.now(),
+ )
+
val converters = Converters()
val artistMap = mutableMapOf()
val artists = mutableListOf()
database.query("SELECT * FROM artist".toSQLiteQuery()).use { cursor ->
while (cursor.moveToNext()) {
val oldId = cursor.getInt(0)
- val newId = generateArtistId()
+ val newId = ArtistEntity.generateArtistId()
artistMap[oldId] = newId
- artists.add(ArtistEntity(
- id = newId,
- name = cursor.getString(1)
- ))
+ artists.add(
+ ArtistEntity(
+ id = newId,
+ name = cursor.getString(1)
+ )
+ )
}
}
@@ -96,22 +126,26 @@ val MIGRATION_1_2 = object : Migration(1, 2) {
database.query("SELECT * FROM playlist".toSQLiteQuery()).use { cursor ->
while (cursor.moveToNext()) {
val oldId = cursor.getInt(0)
- val newId = generatePlaylistId()
+ val newId = PlaylistEntity.generatePlaylistId()
playlistMap[oldId] = newId
- playlists.add(PlaylistEntity(
- id = newId,
- name = cursor.getString(1)
- ))
+ playlists.add(
+ PlaylistEntity(
+ id = newId,
+ name = cursor.getString(1)
+ )
+ )
}
}
val playlistSongMaps = mutableListOf()
database.query("SELECT * FROM playlist_song".toSQLiteQuery()).use { cursor ->
while (cursor.moveToNext()) {
- playlistSongMaps.add(PlaylistSongMap(
- playlistId = playlistMap[cursor.getInt(1)]!!,
- songId = cursor.getString(2),
- position = cursor.getInt(3)
- ))
+ playlistSongMaps.add(
+ PlaylistSongMap(
+ playlistId = playlistMap[cursor.getInt(1)]!!,
+ songId = cursor.getString(2),
+ position = cursor.getInt(3)
+ )
+ )
}
}
// ensure we have continuous playlist song position
@@ -123,24 +157,28 @@ val MIGRATION_1_2 = object : Migration(1, 2) {
playlistSongCount[map.playlistId] = playlistSongCount[map.playlistId]!! + 1
}
}
- val songs = mutableListOf()
+ val songs = mutableListOf()
val songArtistMaps = mutableListOf()
database.query("SELECT * FROM song".toSQLiteQuery()).use { cursor ->
while (cursor.moveToNext()) {
val songId = cursor.getString(0)
- songs.add(SongEntity(
- id = songId,
- title = cursor.getString(1),
- duration = cursor.getInt(3),
- liked = cursor.getInt(4) == 1,
- createDate = Instant.ofEpochMilli(Date(cursor.getLong(8)).time).atZone(ZoneOffset.UTC).toLocalDateTime(),
- modifyDate = Instant.ofEpochMilli(Date(cursor.getLong(9)).time).atZone(ZoneOffset.UTC).toLocalDateTime()
- ))
- songArtistMaps.add(SongArtistMap(
- songId = songId,
- artistId = artistMap[cursor.getInt(2)]!!,
- position = 0
- ))
+ songs.add(
+ OldSongEntity(
+ id = songId,
+ title = cursor.getString(1),
+ duration = cursor.getInt(3),
+ liked = cursor.getInt(4) == 1,
+ createDate = Instant.ofEpochMilli(Date(cursor.getLong(8)).time).atZone(ZoneOffset.UTC).toLocalDateTime(),
+ modifyDate = Instant.ofEpochMilli(Date(cursor.getLong(9)).time).atZone(ZoneOffset.UTC).toLocalDateTime()
+ )
+ )
+ songArtistMaps.add(
+ SongArtistMap(
+ songId = songId,
+ artistId = artistMap[cursor.getInt(2)]!!,
+ position = 0
+ )
+ )
}
}
database.execSQL("DROP TABLE IF EXISTS song")
@@ -169,63 +207,102 @@ val MIGRATION_1_2 = object : Migration(1, 2) {
database.execSQL("CREATE VIEW `sorted_song_artist_map` AS SELECT * FROM song_artist_map ORDER BY position")
database.execSQL("CREATE VIEW `playlist_song_map_preview` AS SELECT * FROM playlist_song_map WHERE position <= 3 ORDER BY position")
artists.forEach { artist ->
- database.insert("artist", CONFLICT_ABORT, contentValuesOf(
- "id" to artist.id,
- "name" to artist.name,
- "createDate" to converters.dateToTimestamp(artist.createDate),
- "lastUpdateTime" to converters.dateToTimestamp(artist.lastUpdateTime)
- ))
+ database.insert(
+ "artist", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf(
+ "id" to artist.id,
+ "name" to artist.name,
+ "createDate" to converters.dateToTimestamp(artist.createDate),
+ "lastUpdateTime" to converters.dateToTimestamp(artist.lastUpdateTime)
+ )
+ )
}
songs.forEach { song ->
- database.insert("song", CONFLICT_ABORT, contentValuesOf(
- "id" to song.id,
- "title" to song.title,
- "duration" to song.duration,
- "liked" to song.liked,
- "totalPlayTime" to song.totalPlayTime,
- "isTrash" to song.isTrash,
- "download_state" to song.downloadState,
- "create_date" to converters.dateToTimestamp(song.createDate),
- "modify_date" to converters.dateToTimestamp(song.modifyDate)
- ))
+ database.insert(
+ "song", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf(
+ "id" to song.id,
+ "title" to song.title,
+ "duration" to song.duration,
+ "liked" to song.liked,
+ "totalPlayTime" to song.totalPlayTime,
+ "isTrash" to false,
+ "download_state" to song.downloadState,
+ "create_date" to converters.dateToTimestamp(song.createDate),
+ "modify_date" to converters.dateToTimestamp(song.modifyDate)
+ )
+ )
}
songArtistMaps.forEach { songArtistMap ->
- database.insert("song_artist_map", CONFLICT_ABORT, contentValuesOf(
- "songId" to songArtistMap.songId,
- "artistId" to songArtistMap.artistId,
- "position" to songArtistMap.position
- ))
+ database.insert(
+ "song_artist_map", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf(
+ "songId" to songArtistMap.songId,
+ "artistId" to songArtistMap.artistId,
+ "position" to songArtistMap.position
+ )
+ )
}
playlists.forEach { playlist ->
- database.insert("playlist", CONFLICT_ABORT, contentValuesOf(
- "id" to playlist.id,
- "name" to playlist.name,
- "createDate" to converters.dateToTimestamp(playlist.createDate),
- "lastUpdateTime" to converters.dateToTimestamp(playlist.lastUpdateTime)
- ))
+ database.insert(
+ "playlist", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf(
+ "id" to playlist.id,
+ "name" to playlist.name,
+ "createDate" to converters.dateToTimestamp(LocalDateTime.now()),
+ "lastUpdateTime" to converters.dateToTimestamp(LocalDateTime.now())
+ )
+ )
}
playlistSongMaps.forEach { playlistSongMap ->
- database.insert("playlist_song_map", CONFLICT_ABORT, contentValuesOf(
- "playlistId" to playlistSongMap.playlistId,
- "songId" to playlistSongMap.songId,
- "position" to playlistSongMap.position
- ))
+ database.insert(
+ "playlist_song_map", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf(
+ "playlistId" to playlistSongMap.playlistId,
+ "songId" to playlistSongMap.songId,
+ "position" to playlistSongMap.position
+ )
+ )
}
}
}
-fun RoomDatabase.checkpoint() {
- openHelper.writableDatabase.run {
- query("PRAGMA journal_mode").use { cursor ->
- if (cursor.moveToFirst()) {
- when (cursor.getString(0).lowercase()) {
- "wal" -> {
- query("PRAGMA wal_checkpoint").use(Cursor::moveToFirst)
- query("PRAGMA wal_checkpoint(TRUNCATE)").use(Cursor::moveToFirst)
- query("PRAGMA wal_checkpoint").use(Cursor::moveToFirst)
- }
- }
+@DeleteColumn.Entries(
+ DeleteColumn(tableName = "song", columnName = "isTrash"),
+ DeleteColumn(tableName = "playlist", columnName = "author"),
+ DeleteColumn(tableName = "playlist", columnName = "authorId"),
+ DeleteColumn(tableName = "playlist", columnName = "year"),
+ DeleteColumn(tableName = "playlist", columnName = "thumbnailUrl"),
+ DeleteColumn(tableName = "playlist", columnName = "createDate"),
+ DeleteColumn(tableName = "playlist", columnName = "lastUpdateTime")
+)
+@RenameColumn.Entries(
+ RenameColumn(tableName = "song", fromColumnName = "download_state", toColumnName = "downloadState"),
+ RenameColumn(tableName = "song", fromColumnName = "create_date", toColumnName = "createDate"),
+ RenameColumn(tableName = "song", fromColumnName = "modify_date", toColumnName = "modifyDate")
+)
+class Migration5To6 : AutoMigrationSpec {
+ override fun onPostMigrate(db: SupportSQLiteDatabase) {
+ db.query("SELECT id FROM playlist WHERE id NOT LIKE 'LP%'").use { cursor ->
+ while (cursor.moveToNext()) {
+ db.execSQL("UPDATE playlist SET browseID = '${cursor.getString(0)}' WHERE id = '${cursor.getString(0)}'")
}
}
}
}
+
+class Migration6To7 : AutoMigrationSpec {
+ override fun onPostMigrate(db: SupportSQLiteDatabase) {
+ db.query("SELECT id, createDate FROM song").use { cursor ->
+ while (cursor.moveToNext()) {
+ db.execSQL("UPDATE song SET inLibrary = ${cursor.getLong(1)} WHERE id = '${cursor.getString(0)}'")
+ }
+ }
+ }
+}
+
+@DeleteColumn.Entries(
+ DeleteColumn(tableName = "song", columnName = "createDate"),
+ DeleteColumn(tableName = "song", columnName = "modifyDate")
+)
+class Migration7To8 : AutoMigrationSpec
+
+@DeleteTable.Entries(
+ DeleteTable(tableName = "download")
+)
+class Migration9To10 : AutoMigrationSpec
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/db/daos/AlbumDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/AlbumDao.kt
deleted file mode 100644
index 99ae49cc3..000000000
--- a/app/src/main/java/com/zionhuang/music/db/daos/AlbumDao.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-package com.zionhuang.music.db.daos
-
-import androidx.room.*
-import androidx.sqlite.db.SupportSQLiteQuery
-import com.zionhuang.music.db.entities.Album
-import com.zionhuang.music.db.entities.AlbumArtistMap
-import com.zionhuang.music.db.entities.AlbumEntity
-import com.zionhuang.music.db.entities.SongAlbumMap
-import com.zionhuang.music.extensions.toSQLiteQuery
-import com.zionhuang.music.models.sortInfo.AlbumSortType
-import com.zionhuang.music.models.sortInfo.ISortInfo
-import kotlinx.coroutines.flow.Flow
-
-@Dao
-interface AlbumDao {
- @Transaction
- @RawQuery(observedEntities = [AlbumEntity::class, AlbumArtistMap::class])
- fun getAlbumsAsFlow(query: SupportSQLiteQuery): Flow>
-
- fun getAllAlbumsAsFlow(sortInfo: ISortInfo) = getAlbumsAsFlow((QUERY_ALL_ALBUM + getSortQuery(sortInfo)).toSQLiteQuery())
-
- @Query("SELECT COUNT(*) FROM album")
- suspend fun getAlbumCount(): Int
-
- @Transaction
- @Query("SELECT * FROM album WHERE title LIKE '%' || :query || '%'")
- fun searchAlbums(query: String): Flow>
-
- @Transaction
- @Query("SELECT * FROM album WHERE title LIKE '%' || :query || '%' LIMIT :previewSize")
- fun searchAlbumsPreview(query: String, previewSize: Int): Flow>
-
- @Query("SELECT * FROM album WHERE id = :id")
- suspend fun getAlbumById(id: String): AlbumEntity?
-
- @Insert(onConflict = OnConflictStrategy.IGNORE)
- suspend fun insert(album: AlbumEntity): Long
-
- @Insert(onConflict = OnConflictStrategy.IGNORE)
- suspend fun insert(albumArtistMap: AlbumArtistMap): Long
-
- @Insert(onConflict = OnConflictStrategy.IGNORE)
- suspend fun insert(songAlbumMaps: List): List
-
- @Update
- suspend fun update(album: AlbumEntity)
-
- @Update
- suspend fun update(songAlbumMaps: List)
-
- suspend fun upsert(songAlbumMaps: List) {
- insert(songAlbumMaps)
- .withIndex()
- .mapNotNull { if (it.value == -1L) songAlbumMaps[it.index] else null }
- .let { update(it) }
- }
-
- @Delete
- suspend fun delete(album: AlbumEntity)
-
- fun getSortQuery(sortInfo: ISortInfo) = QUERY_ORDER.format(
- when (sortInfo.type) {
- AlbumSortType.CREATE_DATE -> "rowid"
- AlbumSortType.NAME -> "album.title"
- AlbumSortType.ARTIST -> throw IllegalArgumentException("Unexpected album sort type.")
- AlbumSortType.YEAR -> "album.year"
- AlbumSortType.SONG_COUNT -> "album.songCount"
- AlbumSortType.LENGTH -> "album.duration"
- },
- if (sortInfo.isDescending) "DESC" else "ASC"
- )
-
- companion object {
- private const val QUERY_ALL_ALBUM = "SELECT * FROM album"
- private const val QUERY_ORDER = " ORDER BY %s %s"
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/db/daos/ArtistDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/ArtistDao.kt
deleted file mode 100644
index 399423f8f..000000000
--- a/app/src/main/java/com/zionhuang/music/db/daos/ArtistDao.kt
+++ /dev/null
@@ -1,79 +0,0 @@
-package com.zionhuang.music.db.daos
-
-import androidx.room.*
-import androidx.sqlite.db.SupportSQLiteQuery
-import com.zionhuang.music.db.entities.Artist
-import com.zionhuang.music.db.entities.ArtistEntity
-import com.zionhuang.music.db.entities.SongArtistMap
-import com.zionhuang.music.extensions.toSQLiteQuery
-import com.zionhuang.music.models.sortInfo.ArtistSortType
-import com.zionhuang.music.models.sortInfo.ISortInfo
-import kotlinx.coroutines.flow.Flow
-
-@Dao
-interface ArtistDao {
- @Transaction
- @RawQuery(observedEntities = [ArtistEntity::class, SongArtistMap::class])
- fun getArtistsAsFlow(query: SupportSQLiteQuery): Flow>
-
- fun getAllArtistsAsFlow(sortInfo: ISortInfo) = getArtistsAsFlow((QUERY_ALL_ARTIST + getSortQuery(sortInfo)).toSQLiteQuery())
-
- @Query("SELECT COUNT(*) FROM artist")
- suspend fun getArtistCount(): Int
-
- @Query("SELECT * FROM artist WHERE id = :id")
- suspend fun getArtistById(id: String): ArtistEntity?
-
- @Query("SELECT * FROM artist WHERE name = :name")
- suspend fun getArtistByName(name: String): ArtistEntity?
-
- @Query("SELECT COUNT(*) FROM song_artist_map WHERE artistId = :id")
- suspend fun getArtistSongCount(id: String): Int
-
- @Transaction
- @Query("SELECT *, (SELECT COUNT(*) FROM song_artist_map WHERE artistId = artist.id) AS songCount FROM artist WHERE name LIKE '%' || :query || '%'")
- fun searchArtists(query: String): Flow>
-
- @Transaction
- @Query("SELECT *, (SELECT COUNT(*) FROM song_artist_map WHERE artistId = artist.id) AS songCount FROM artist WHERE name LIKE '%' || :query || '%' LIMIT :previewSize")
- fun searchArtistsPreview(query: String, previewSize: Int): Flow>
-
- @Query("SELECT EXISTS(SELECT * FROM artist WHERE id = :id)")
- suspend fun hasArtist(id: String): Boolean
-
- @Query("DELETE FROM song_artist_map WHERE songId = :songId")
- suspend fun deleteSongArtists(songId: String)
-
- suspend fun deleteSongArtists(songIds: List) = songIds.forEach {
- deleteSongArtists(it)
- }
-
- @Insert(onConflict = OnConflictStrategy.IGNORE)
- suspend fun insert(artist: ArtistEntity): Long
-
- @Insert(onConflict = OnConflictStrategy.IGNORE)
- suspend fun insert(songArtistMap: SongArtistMap): Long
-
- @Insert(onConflict = OnConflictStrategy.IGNORE)
- suspend fun insert(songArtistMaps: List)
-
- @Update
- suspend fun update(artist: ArtistEntity)
-
- @Delete
- suspend fun delete(artists: List)
-
- fun getSortQuery(sortInfo: ISortInfo) = QUERY_ORDER.format(
- when (sortInfo.type) {
- ArtistSortType.CREATE_DATE -> "rowid"
- ArtistSortType.NAME -> "artist.name"
- else -> throw IllegalArgumentException("Unexpected artist sort type.")
- },
- if (sortInfo.isDescending) "DESC" else "ASC"
- )
-
- companion object {
- private const val QUERY_ALL_ARTIST = "SELECT *, (SELECT COUNT(*) FROM song_artist_map WHERE artistId = artist.id) AS songCount FROM artist"
- private const val QUERY_ORDER = " ORDER BY %s %s"
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/db/daos/DownloadDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/DownloadDao.kt
deleted file mode 100644
index 33f542433..000000000
--- a/app/src/main/java/com/zionhuang/music/db/daos/DownloadDao.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package com.zionhuang.music.db.daos
-
-import androidx.room.Dao
-import androidx.room.Insert
-import androidx.room.Query
-import com.zionhuang.music.db.entities.DownloadEntity
-
-@Dao
-interface DownloadDao {
- @Query("SELECT * FROM download WHERE id = :downloadId")
- suspend fun getDownloadEntity(downloadId: Long): DownloadEntity?
-
- @Insert
- suspend fun insert(entity: DownloadEntity)
-
- @Query("DELETE FROM download WHERE id = :downloadId")
- suspend fun delete(downloadId: Long)
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/db/daos/FormatDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/FormatDao.kt
deleted file mode 100644
index cd1e37699..000000000
--- a/app/src/main/java/com/zionhuang/music/db/daos/FormatDao.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package com.zionhuang.music.db.daos
-
-import androidx.room.*
-import com.zionhuang.music.db.entities.FormatEntity
-import kotlinx.coroutines.flow.Flow
-
-@Dao
-interface FormatDao {
- @Query("SELECT * FROM format WHERE id = :id")
- suspend fun getSongFormat(id: String?): FormatEntity?
-
- @Query("SELECT * FROM format WHERE id = :id")
- fun getSongFormatAsFlow(id: String?): Flow
-
- @Insert(onConflict = OnConflictStrategy.IGNORE)
- suspend fun insert(format: FormatEntity): Long
-
- @Update
- suspend fun update(format: FormatEntity)
-
- suspend fun upsert(format: FormatEntity) {
- if (insert(format) == -1L) {
- update(format)
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/db/daos/LyricsDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/LyricsDao.kt
deleted file mode 100644
index f8702ef6d..000000000
--- a/app/src/main/java/com/zionhuang/music/db/daos/LyricsDao.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-package com.zionhuang.music.db.daos
-
-import androidx.room.*
-import com.zionhuang.music.db.entities.LyricsEntity
-import kotlinx.coroutines.flow.Flow
-
-@Dao
-interface LyricsDao {
- @Query("SELECT * FROM lyrics WHERE id = :id")
- suspend fun getLyrics(id: String?): LyricsEntity?
-
- @Query("SELECT * FROM lyrics WHERE id = :id")
- fun getLyricsAsFlow(id: String?): Flow
-
- @Query("SELECT EXISTS (SELECT 1 FROM lyrics WHERE id = :id)")
- suspend fun hasLyrics(id: String): Boolean
-
- @Insert(onConflict = OnConflictStrategy.IGNORE)
- suspend fun insert(lyrics: LyricsEntity): Long
-
- @Update
- suspend fun update(lyrics: LyricsEntity)
-
- suspend fun upsert(lyrics: LyricsEntity) {
- if (insert(lyrics) == -1L) {
- update(lyrics)
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/db/daos/PlaylistDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/PlaylistDao.kt
deleted file mode 100644
index 4211c7485..000000000
--- a/app/src/main/java/com/zionhuang/music/db/daos/PlaylistDao.kt
+++ /dev/null
@@ -1,105 +0,0 @@
-package com.zionhuang.music.db.daos
-
-import androidx.room.*
-import androidx.sqlite.db.SupportSQLiteQuery
-import com.zionhuang.music.db.entities.Playlist
-import com.zionhuang.music.db.entities.PlaylistEntity
-import com.zionhuang.music.db.entities.PlaylistSongMap
-import com.zionhuang.music.extensions.toSQLiteQuery
-import com.zionhuang.music.models.sortInfo.ISortInfo
-import com.zionhuang.music.models.sortInfo.PlaylistSortType
-import kotlinx.coroutines.flow.Flow
-
-@Dao
-interface PlaylistDao {
- @Transaction
- @Query(QUERY_ALL_PLAYLIST)
- suspend fun getAllPlaylistsAsList(): List
-
- @Transaction
- @RawQuery(observedEntities = [PlaylistEntity::class, PlaylistSongMap::class])
- fun getPlaylistsAsFlow(query: SupportSQLiteQuery): Flow>
-
- fun getAllPlaylistsAsFlow(sortInfo: ISortInfo): Flow> = getPlaylistsAsFlow((QUERY_ALL_PLAYLIST + getSortQuery(sortInfo)).toSQLiteQuery())
-
- @Query("SELECT COUNT(*) FROM playlist")
- suspend fun getPlaylistCount(): Int
-
- @Transaction
- @Query("$QUERY_ALL_PLAYLIST WHERE id = :playlistId")
- suspend fun getPlaylistById(playlistId: String): Playlist
-
- @Transaction
- @Query("$QUERY_ALL_PLAYLIST WHERE name LIKE '%' || :query || '%'")
- fun searchPlaylists(query: String): Flow>
-
- @Transaction
- @Query("$QUERY_ALL_PLAYLIST WHERE name LIKE '%' || :query || '%' LIMIT :previewSize")
- fun searchPlaylistsPreview(query: String, previewSize: Int): Flow>
-
- @Query("SELECT * FROM playlist_song_map WHERE playlistId = :playlistId AND position = :position")
- suspend fun getPlaylistSongMap(playlistId: String, position: Int): PlaylistSongMap?
-
- @Query("SELECT * FROM playlist_song_map WHERE songId IN (:songIds)")
- suspend fun getPlaylistSongMaps(songIds: List): List
-
- @Query("SELECT * FROM playlist_song_map WHERE playlistId = :playlistId AND position >= :from ORDER BY position")
- suspend fun getPlaylistSongMaps(playlistId: String, from: Int): List
-
- @Query("UPDATE playlist_song_map SET position = position - 1 WHERE playlistId = :playlistId AND :from <= position")
- suspend fun decrementSongPositions(playlistId: String, from: Int)
-
- @Query("UPDATE playlist_song_map SET position = position - 1 WHERE playlistId = :playlistId AND :from <= position AND position <= :to")
- suspend fun decrementSongPositions(playlistId: String, from: Int, to: Int)
-
- @Query("UPDATE playlist_song_map SET position = position + 1 WHERE playlistId = :playlistId AND :from <= position AND position <= :to")
- suspend fun incrementSongPositions(playlistId: String, from: Int, to: Int)
-
- suspend fun renewSongPositions(playlistId: String, from: Int) {
- val maps = getPlaylistSongMaps(playlistId, from)
- if (maps.isEmpty()) return
- var position = if (from <= 0) 0 else maps[0].position
- update(maps.map { it.copy(position = position++) })
- }
-
- @Insert(onConflict = OnConflictStrategy.IGNORE)
- suspend fun insert(playlist: PlaylistEntity): Long
-
- @Insert
- suspend fun insert(playlistSongMaps: List)
-
- @Update
- suspend fun update(playlist: PlaylistEntity)
-
- @Update
- suspend fun update(playlistSongMap: PlaylistSongMap)
-
- @Update
- suspend fun update(playlistSongMaps: List)
-
- @Delete
- suspend fun delete(playlists: List)
-
-
- suspend fun deletePlaylistSong(playlistId: String, position: Int) = deletePlaylistSong(playlistId, listOf(position))
-
- @Query("DELETE FROM playlist_song_map WHERE playlistId = :playlistId AND position IN (:position)")
- suspend fun deletePlaylistSong(playlistId: String, position: List)
-
- @Query("SELECT max(position) FROM playlist_song_map WHERE playlistId = :playlistId")
- suspend fun getPlaylistMaxId(playlistId: String): Int?
-
- fun getSortQuery(sortInfo: ISortInfo) = QUERY_ORDER.format(
- when (sortInfo.type) {
- PlaylistSortType.CREATE_DATE -> "rowid"
- PlaylistSortType.NAME -> "playlist.name"
- PlaylistSortType.SONG_COUNT -> throw IllegalArgumentException("Unexpected playlist sort type.")
- },
- if (sortInfo.isDescending) "DESC" else "ASC"
- )
-
- companion object {
- private const val QUERY_ALL_PLAYLIST = "SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist"
- private const val QUERY_ORDER = " ORDER BY %s %s"
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/db/daos/SearchHistoryDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/SearchHistoryDao.kt
deleted file mode 100644
index c30f1046d..000000000
--- a/app/src/main/java/com/zionhuang/music/db/daos/SearchHistoryDao.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.zionhuang.music.db.daos
-
-import androidx.room.Dao
-import androidx.room.Insert
-import androidx.room.OnConflictStrategy
-import androidx.room.Query
-import com.zionhuang.music.db.entities.SearchHistory
-
-@Dao
-interface SearchHistoryDao {
- @Query("SELECT * FROM search_history ORDER BY id DESC")
- suspend fun getAllHistory(): List
-
- @Query("SELECT * FROM search_history WHERE `query` LIKE :query || '%' ORDER BY id DESC")
- suspend fun getHistory(query: String): List
-
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insert(searchHistory: SearchHistory)
-
- @Query("DELETE FROM search_history WHERE `query` = :query")
- suspend fun delete(query: String)
-
- @Query("DELETE FROM search_history")
- suspend fun clearHistory()
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/db/daos/SongDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/SongDao.kt
deleted file mode 100644
index 8824e99d7..000000000
--- a/app/src/main/java/com/zionhuang/music/db/daos/SongDao.kt
+++ /dev/null
@@ -1,149 +0,0 @@
-package com.zionhuang.music.db.daos
-
-import androidx.lifecycle.LiveData
-import androidx.room.*
-import androidx.sqlite.db.SupportSQLiteQuery
-import com.zionhuang.music.constants.MediaConstants.STATE_DOWNLOADED
-import com.zionhuang.music.db.entities.*
-import com.zionhuang.music.extensions.toSQLiteQuery
-import com.zionhuang.music.models.sortInfo.ISortInfo
-import com.zionhuang.music.models.sortInfo.SongSortType
-import kotlinx.coroutines.flow.Flow
-
-@Dao
-interface SongDao {
- @Transaction
- @RawQuery(observedEntities = [SongEntity::class, ArtistEntity::class, AlbumEntity::class, SongArtistMap::class, SongAlbumMap::class])
- suspend fun getSongsAsList(query: SupportSQLiteQuery): List
-
- @Transaction
- @RawQuery(observedEntities = [SongEntity::class, ArtistEntity::class, AlbumEntity::class, SongArtistMap::class, SongAlbumMap::class])
- fun getSongsAsFlow(query: SupportSQLiteQuery): Flow>
-
- fun getAllSongsAsFlow(sortInfo: ISortInfo): Flow> = getSongsAsFlow((QUERY_ALL_SONG + getSortQuery(sortInfo)).toSQLiteQuery())
-
- @Query("SELECT COUNT(*) FROM song WHERE NOT isTrash")
- suspend fun getSongCount(): Int
-
- suspend fun getArtistSongsAsList(artistId: String, sortInfo: ISortInfo): List = getSongsAsList((QUERY_ARTIST_SONG.format(artistId) + getSortQuery(sortInfo)).toSQLiteQuery())
- fun getArtistSongsAsFlow(artistId: String, sortInfo: ISortInfo) = getSongsAsFlow((QUERY_ARTIST_SONG.format(artistId) + getSortQuery(sortInfo)).toSQLiteQuery())
-
- @Query("SELECT COUNT(*) FROM song_artist_map WHERE artistId = :artistId")
- suspend fun getArtistSongCount(artistId: String): Int
-
- @Query("SELECT song.id FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId AND NOT song.isTrash LIMIT 5")
- suspend fun getArtistSongsPreview(artistId: String): List
-
- @Transaction
- @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH)
- @Query("SELECT song.* FROM song JOIN song_album_map ON song.id = song_album_map.songId WHERE song_album_map.albumId = :albumId")
- suspend fun getAlbumSongs(albumId: String): List
-
- @Transaction
- @Query(QUERY_PLAYLIST_SONGS)
- suspend fun getPlaylistSongsAsList(playlistId: String): List
-
- @Transaction
- @Query(QUERY_PLAYLIST_SONGS)
- fun getPlaylistSongsAsFlow(playlistId: String): Flow>
-
- fun getLikedSongs(sortInfo: ISortInfo) = getSongsAsFlow((QUERY_LIKED_SONG + getSortQuery(sortInfo)).toSQLiteQuery())
-
- @Query("SELECT COUNT(*) FROM song WHERE liked")
- fun getLikedSongCount(): Flow
-
- fun getDownloadedSongsAsFlow(sortInfo: ISortInfo) = getSongsAsFlow((QUERY_DOWNLOADED_SONG + getSortQuery(sortInfo)).toSQLiteQuery())
-
- suspend fun getDownloadedSongsAsList(sortInfo: ISortInfo) = getSongsAsList((QUERY_DOWNLOADED_SONG + getSortQuery(sortInfo)).toSQLiteQuery())
-
- @Query("SELECT COUNT(*) FROM song WHERE download_state = $STATE_DOWNLOADED")
- fun getDownloadedSongCount(): Flow
-
- @Transaction
- @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH)
- @Query("SELECT * FROM song WHERE id = :songId")
- suspend fun getSong(songId: String?): Song?
-
- @Transaction
- @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH)
- @Query("SELECT * FROM song WHERE id = :songId")
- fun getSongAsLiveData(songId: String?): LiveData
-
- @Transaction
- @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH)
- @Query("SELECT * FROM song WHERE id = :songId")
- fun getSongAsFlow(songId: String?): Flow
-
- @Transaction
- @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH)
- @Query("SELECT * FROM song WHERE title LIKE '%' || :query || '%' AND NOT isTrash")
- fun searchSongs(query: String): Flow>
-
- @Transaction
- @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH)
- @Query("SELECT * FROM song WHERE download_state = $STATE_DOWNLOADED AND title LIKE '%' || :query || '%'")
- fun searchDownloadedSongs(query: String): Flow>
-
- @Transaction
- @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH)
- @Query("SELECT * FROM song WHERE title LIKE '%' || :query || '%' AND NOT isTrash LIMIT :previewSize")
- fun searchSongsPreview(query: String, previewSize: Int): Flow>
-
- @Query("SELECT EXISTS (SELECT 1 FROM song WHERE id = :songId)")
- suspend fun hasSong(songId: String): Boolean
-
- @Query("SELECT EXISTS (SELECT 1 FROM song WHERE id = :songId)")
- fun hasSongAsLiveData(songId: String): LiveData
-
- @Query("UPDATE song SET totalPlayTime = totalPlayTime + :playTime WHERE id = :songId")
- suspend fun incrementSongTotalPlayTime(songId: String, playTime: Long)
-
- @Insert(onConflict = OnConflictStrategy.IGNORE)
- suspend fun insert(songs: List)
-
- @Insert(onConflict = OnConflictStrategy.IGNORE)
- suspend fun insert(song: SongEntity)
-
- @Update
- suspend fun update(song: SongEntity)
-
- @Update
- suspend fun update(songs: List)
-
- @Delete
- suspend fun delete(songs: List)
-
- fun getSortQuery(sortInfo: ISortInfo) = QUERY_ORDER.format(
- when (sortInfo.type) {
- SongSortType.CREATE_DATE -> "song.create_date"
- SongSortType.NAME -> "song.title"
- SongSortType.PLAY_TIME -> "song.totalPlayTime"
- else -> throw IllegalArgumentException("Unexpected song sort type.")
- },
- if (sortInfo.isDescending) "DESC" else "ASC"
- )
-
- companion object {
- private const val QUERY_ORDER = " ORDER BY %s %s"
- private const val QUERY_ALL_SONG = "SELECT * FROM song WHERE NOT isTrash"
- private const val QUERY_ARTIST_SONG =
- """
- SELECT song.*
- FROM song_artist_map
- JOIN song
- ON song_artist_map.songId = song.id
- WHERE artistId = "%s" AND NOT song.isTrash
- """
- private const val QUERY_PLAYLIST_SONGS =
- """
- SELECT song.*, playlist_song_map.position
- FROM playlist_song_map
- JOIN song
- ON playlist_song_map.songId = song.id
- WHERE playlistId = :playlistId AND NOT song.isTrash
- ORDER BY playlist_song_map.position
- """
- private const val QUERY_LIKED_SONG = "SELECT * FROM song WHERE liked"
- private const val QUERY_DOWNLOADED_SONG = "SELECT * FROM song WHERE download_state = $STATE_DOWNLOADED"
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/db/entities/Album.kt b/app/src/main/java/com/zionhuang/music/db/entities/Album.kt
index 46e2fbc46..0a93a7c94 100644
--- a/app/src/main/java/com/zionhuang/music/db/entities/Album.kt
+++ b/app/src/main/java/com/zionhuang/music/db/entities/Album.kt
@@ -1,9 +1,11 @@
package com.zionhuang.music.db.entities
+import androidx.compose.runtime.Immutable
import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
+@Immutable
data class Album(
@Embedded
val album: AlbumEntity,
diff --git a/app/src/main/java/com/zionhuang/music/db/entities/AlbumEntity.kt b/app/src/main/java/com/zionhuang/music/db/entities/AlbumEntity.kt
index f37cbb9fd..0f1678107 100644
--- a/app/src/main/java/com/zionhuang/music/db/entities/AlbumEntity.kt
+++ b/app/src/main/java/com/zionhuang/music/db/entities/AlbumEntity.kt
@@ -1,12 +1,11 @@
package com.zionhuang.music.db.entities
-import android.os.Parcelable
+import androidx.compose.runtime.Immutable
import androidx.room.Entity
import androidx.room.PrimaryKey
-import kotlinx.parcelize.Parcelize
import java.time.LocalDateTime
-@Parcelize
+@Immutable
@Entity(tableName = "album")
data class AlbumEntity(
@PrimaryKey val id: String,
@@ -17,4 +16,4 @@ data class AlbumEntity(
val duration: Int,
val createDate: LocalDateTime = LocalDateTime.now(),
val lastUpdateTime: LocalDateTime = LocalDateTime.now(),
-) : Parcelable
\ No newline at end of file
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/db/entities/AlbumWithSongs.kt b/app/src/main/java/com/zionhuang/music/db/entities/AlbumWithSongs.kt
new file mode 100644
index 000000000..6e64f031a
--- /dev/null
+++ b/app/src/main/java/com/zionhuang/music/db/entities/AlbumWithSongs.kt
@@ -0,0 +1,34 @@
+package com.zionhuang.music.db.entities
+
+import androidx.compose.runtime.Immutable
+import androidx.room.Embedded
+import androidx.room.Junction
+import androidx.room.Relation
+
+@Immutable
+data class AlbumWithSongs(
+ @Embedded
+ val album: AlbumEntity,
+ @Relation(
+ entity = ArtistEntity::class,
+ entityColumn = "id",
+ parentColumn = "id",
+ associateBy = Junction(
+ value = AlbumArtistMap::class,
+ parentColumn = "albumId",
+ entityColumn = "artistId"
+ )
+ )
+ val artists: List,
+ @Relation(
+ entity = SongEntity::class,
+ entityColumn = "id",
+ parentColumn = "id",
+ associateBy = Junction(
+ value = SortedSongAlbumMap::class,
+ parentColumn = "albumId",
+ entityColumn = "songId"
+ )
+ )
+ val songs: List,
+)
diff --git a/app/src/main/java/com/zionhuang/music/db/entities/Artist.kt b/app/src/main/java/com/zionhuang/music/db/entities/Artist.kt
index 742d92e08..2eff0dcb8 100644
--- a/app/src/main/java/com/zionhuang/music/db/entities/Artist.kt
+++ b/app/src/main/java/com/zionhuang/music/db/entities/Artist.kt
@@ -1,7 +1,9 @@
package com.zionhuang.music.db.entities
+import androidx.compose.runtime.Immutable
import androidx.room.Embedded
+@Immutable
data class Artist(
@Embedded
val artist: ArtistEntity,
diff --git a/app/src/main/java/com/zionhuang/music/db/entities/ArtistEntity.kt b/app/src/main/java/com/zionhuang/music/db/entities/ArtistEntity.kt
index 95a4e8dc4..e054e2e22 100644
--- a/app/src/main/java/com/zionhuang/music/db/entities/ArtistEntity.kt
+++ b/app/src/main/java/com/zionhuang/music/db/entities/ArtistEntity.kt
@@ -1,13 +1,12 @@
package com.zionhuang.music.db.entities
-import android.os.Parcelable
+import androidx.compose.runtime.Immutable
import androidx.room.Entity
import androidx.room.PrimaryKey
-import kotlinx.parcelize.Parcelize
import org.apache.commons.lang3.RandomStringUtils
import java.time.LocalDateTime
-@Parcelize
+@Immutable
@Entity(tableName = "artist")
data class ArtistEntity(
@PrimaryKey val id: String,
@@ -17,7 +16,7 @@ data class ArtistEntity(
val description: String? = null,
val createDate: LocalDateTime = LocalDateTime.now(),
val lastUpdateTime: LocalDateTime = LocalDateTime.now(),
-) : Parcelable {
+) {
override fun toString(): String = name
val isYouTubeArtist: Boolean
diff --git a/app/src/main/java/com/zionhuang/music/db/entities/DownloadEntity.kt b/app/src/main/java/com/zionhuang/music/db/entities/DownloadEntity.kt
deleted file mode 100644
index 7a0977053..000000000
--- a/app/src/main/java/com/zionhuang/music/db/entities/DownloadEntity.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.zionhuang.music.db.entities
-
-import androidx.room.Entity
-import androidx.room.PrimaryKey
-
-@Entity(tableName = "download")
-data class DownloadEntity(
- @PrimaryKey val id: Long,
- val songId: String,
-)
diff --git a/app/src/main/java/com/zionhuang/music/db/entities/Event.kt b/app/src/main/java/com/zionhuang/music/db/entities/Event.kt
new file mode 100644
index 000000000..0edb3f351
--- /dev/null
+++ b/app/src/main/java/com/zionhuang/music/db/entities/Event.kt
@@ -0,0 +1,27 @@
+package com.zionhuang.music.db.entities
+
+import androidx.compose.runtime.Immutable
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.PrimaryKey
+import java.time.LocalDateTime
+
+@Immutable
+@Entity(
+ tableName = "event",
+ foreignKeys = [
+ ForeignKey(
+ entity = SongEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["songId"],
+ onDelete = ForeignKey.CASCADE
+ )
+ ]
+)
+data class Event(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ @ColumnInfo(index = true) val songId: String,
+ val timestamp: LocalDateTime,
+ val playTime: Long,
+)
diff --git a/app/src/main/java/com/zionhuang/music/db/entities/EventWithSong.kt b/app/src/main/java/com/zionhuang/music/db/entities/EventWithSong.kt
new file mode 100644
index 000000000..639d46aed
--- /dev/null
+++ b/app/src/main/java/com/zionhuang/music/db/entities/EventWithSong.kt
@@ -0,0 +1,17 @@
+package com.zionhuang.music.db.entities
+
+import androidx.compose.runtime.Immutable
+import androidx.room.Embedded
+import androidx.room.Relation
+
+@Immutable
+data class EventWithSong(
+ @Embedded
+ val event: Event,
+ @Relation(
+ entity = SongEntity::class,
+ parentColumn = "songId",
+ entityColumn = "id"
+ )
+ val song: Song,
+)
diff --git a/app/src/main/java/com/zionhuang/music/db/entities/LocalItem.kt b/app/src/main/java/com/zionhuang/music/db/entities/LocalItem.kt
index b4e474837..0407e17f0 100644
--- a/app/src/main/java/com/zionhuang/music/db/entities/LocalItem.kt
+++ b/app/src/main/java/com/zionhuang/music/db/entities/LocalItem.kt
@@ -1,70 +1,5 @@
package com.zionhuang.music.db.entities
-import com.zionhuang.music.constants.Constants.ALBUM_HEADER_ID
-import com.zionhuang.music.constants.Constants.ARTIST_HEADER_ID
-import com.zionhuang.music.constants.Constants.DOWNLOADED_PLAYLIST_ID
-import com.zionhuang.music.constants.Constants.LIKED_PLAYLIST_ID
-import com.zionhuang.music.constants.Constants.PLAYLIST_HEADER_ID
-import com.zionhuang.music.constants.Constants.PLAYLIST_SONG_HEADER_ID
-import com.zionhuang.music.constants.Constants.SONG_HEADER_ID
-import com.zionhuang.music.constants.Constants.TEXT_HEADER_ID
-import com.zionhuang.music.models.sortInfo.*
-
-sealed class LocalBaseItem {
+sealed class LocalItem {
abstract val id: String
-}
-
-sealed class LocalItem : LocalBaseItem()
-
-data class SongHeader(
- val songCount: Int,
- val sortInfo: SortInfo,
-) : LocalBaseItem() {
- override val id = SONG_HEADER_ID
-}
-
-data class ArtistHeader(
- val artistCount: Int,
- val sortInfo: SortInfo,
-) : LocalBaseItem() {
- override val id = ARTIST_HEADER_ID
-}
-
-data class AlbumHeader(
- val albumCount: Int,
- val sortInfo: SortInfo,
-) : LocalBaseItem() {
- override val id = ALBUM_HEADER_ID
-}
-
-data class PlaylistHeader(
- val playlistCount: Int,
- val sortInfo: SortInfo,
-) : LocalBaseItem() {
- override val id = PLAYLIST_HEADER_ID
-}
-
-data class LikedPlaylist(
- val songCount: Int,
-) : LocalBaseItem() {
- override val id: String = LIKED_PLAYLIST_ID
-}
-
-data class DownloadedPlaylist(
- val songCount: Int,
-) : LocalBaseItem() {
- override val id: String = DOWNLOADED_PLAYLIST_ID
-}
-
-data class PlaylistSongHeader(
- val songCount: Int,
- val length: Long,
-) : LocalBaseItem() {
- override val id: String = PLAYLIST_SONG_HEADER_ID
-}
-
-data class TextHeader(
- val title: String,
-) : LocalBaseItem() {
- override val id = TEXT_HEADER_ID
}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/db/entities/Playlist.kt b/app/src/main/java/com/zionhuang/music/db/entities/Playlist.kt
index f80acad05..f1ee6006c 100644
--- a/app/src/main/java/com/zionhuang/music/db/entities/Playlist.kt
+++ b/app/src/main/java/com/zionhuang/music/db/entities/Playlist.kt
@@ -1,9 +1,11 @@
package com.zionhuang.music.db.entities
+import androidx.compose.runtime.Immutable
import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
+@Immutable
data class Playlist(
@Embedded
val playlist: PlaylistEntity,
diff --git a/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt b/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt
index 132845b91..b2bf56092 100644
--- a/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt
+++ b/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt
@@ -1,31 +1,21 @@
package com.zionhuang.music.db.entities
-import android.os.Parcelable
+import androidx.compose.runtime.Immutable
import androidx.room.Entity
import androidx.room.PrimaryKey
-import kotlinx.parcelize.Parcelize
import org.apache.commons.lang3.RandomStringUtils
-import java.time.LocalDateTime
-@Parcelize
+@Immutable
@Entity(tableName = "playlist")
data class PlaylistEntity(
- @PrimaryKey val id: String,
+ @PrimaryKey val id: String = generatePlaylistId(),
val name: String,
- val author: String? = null,
- val authorId: String? = null,
- val year: Int? = null,
- val thumbnailUrl: String? = null,
- val createDate: LocalDateTime = LocalDateTime.now(),
- val lastUpdateTime: LocalDateTime = LocalDateTime.now(),
-) : Parcelable {
- val isLocalPlaylist: Boolean
- get() = id.startsWith("LP")
-
- val isYouTubePlaylist: Boolean
- get() = !isLocalPlaylist
-
+ val browseId: String? = null,
+) {
companion object {
+ const val LIKED_PLAYLIST_ID = "LP_LIKED"
+ const val DOWNLOADED_PLAYLIST_ID = "LP_DOWNLOADED"
+
fun generatePlaylistId() = "LP" + RandomStringUtils.random(8, true, false)
}
}
diff --git a/app/src/main/java/com/zionhuang/music/db/entities/PlaylistSong.kt b/app/src/main/java/com/zionhuang/music/db/entities/PlaylistSong.kt
new file mode 100644
index 000000000..f2c7071b9
--- /dev/null
+++ b/app/src/main/java/com/zionhuang/music/db/entities/PlaylistSong.kt
@@ -0,0 +1,14 @@
+package com.zionhuang.music.db.entities
+
+import androidx.room.Embedded
+import androidx.room.Relation
+
+data class PlaylistSong(
+ @Embedded val map: PlaylistSongMap,
+ @Relation(
+ parentColumn = "songId",
+ entityColumn = "id",
+ entity = SongEntity::class
+ )
+ val song: Song,
+)
diff --git a/app/src/main/java/com/zionhuang/music/db/entities/RelatedSongMap.kt b/app/src/main/java/com/zionhuang/music/db/entities/RelatedSongMap.kt
new file mode 100644
index 000000000..3c347269f
--- /dev/null
+++ b/app/src/main/java/com/zionhuang/music/db/entities/RelatedSongMap.kt
@@ -0,0 +1,29 @@
+package com.zionhuang.music.db.entities
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.PrimaryKey
+
+@Entity(
+ tableName = "related_song_map",
+ foreignKeys = [
+ ForeignKey(
+ entity = SongEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["songId"],
+ onDelete = ForeignKey.CASCADE
+ ),
+ ForeignKey(
+ entity = SongEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["relatedSongId"],
+ onDelete = ForeignKey.CASCADE
+ )
+ ]
+)
+data class RelatedSongMap(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ @ColumnInfo(index = true) val songId: String,
+ @ColumnInfo(index = true) val relatedSongId: String,
+)
diff --git a/app/src/main/java/com/zionhuang/music/db/entities/Song.kt b/app/src/main/java/com/zionhuang/music/db/entities/Song.kt
index 05d403339..9e081aa46 100644
--- a/app/src/main/java/com/zionhuang/music/db/entities/Song.kt
+++ b/app/src/main/java/com/zionhuang/music/db/entities/Song.kt
@@ -1,12 +1,11 @@
package com.zionhuang.music.db.entities
-import android.os.Parcelable
+import androidx.compose.runtime.Immutable
import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
-import kotlinx.parcelize.Parcelize
-@Parcelize
+@Immutable
data class Song @JvmOverloads constructor(
@Embedded val song: SongEntity,
@Relation(
@@ -31,8 +30,7 @@ data class Song @JvmOverloads constructor(
)
)
val album: AlbumEntity? = null,
- val position: Int? = -1,
-) : LocalItem(), Parcelable {
+) : LocalItem() {
override val id: String
get() = song.id
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/zionhuang/music/db/entities/SongAlbumMap.kt b/app/src/main/java/com/zionhuang/music/db/entities/SongAlbumMap.kt
index 6a68a3e4b..c08b442be 100644
--- a/app/src/main/java/com/zionhuang/music/db/entities/SongAlbumMap.kt
+++ b/app/src/main/java/com/zionhuang/music/db/entities/SongAlbumMap.kt
@@ -24,5 +24,5 @@ import androidx.room.ForeignKey
data class SongAlbumMap(
@ColumnInfo(index = true) val songId: String,
@ColumnInfo(index = true) val albumId: String,
- val index: Int? = null,
+ val index: Int,
)
diff --git a/app/src/main/java/com/zionhuang/music/db/entities/SongEntity.kt b/app/src/main/java/com/zionhuang/music/db/entities/SongEntity.kt
index ace58a1b2..dfed7f0b7 100644
--- a/app/src/main/java/com/zionhuang/music/db/entities/SongEntity.kt
+++ b/app/src/main/java/com/zionhuang/music/db/entities/SongEntity.kt
@@ -1,29 +1,35 @@
package com.zionhuang.music.db.entities
-import android.os.Parcelable
-import androidx.room.ColumnInfo
+import androidx.compose.runtime.Immutable
import androidx.room.Entity
import androidx.room.PrimaryKey
-import com.zionhuang.music.constants.MediaConstants.STATE_NOT_DOWNLOADED
-import kotlinx.parcelize.Parcelize
import java.time.LocalDateTime
-@Parcelize
+@Immutable
@Entity(tableName = "song")
data class SongEntity(
@PrimaryKey val id: String,
val title: String,
- val duration: Int = 0, // in seconds
+ val duration: Int = -1, // in seconds
val thumbnailUrl: String? = null,
val albumId: String? = null,
val albumName: String? = null,
val liked: Boolean = false,
val totalPlayTime: Long = 0, // in milliseconds
- val isTrash: Boolean = false,
- @ColumnInfo(name = "download_state")
val downloadState: Int = STATE_NOT_DOWNLOADED,
- @ColumnInfo(name = "create_date")
- val createDate: LocalDateTime = LocalDateTime.now(),
- @ColumnInfo(name = "modify_date")
- val modifyDate: LocalDateTime = LocalDateTime.now(),
-) : Parcelable
+ val inLibrary: LocalDateTime? = null,
+) {
+ fun toggleLike() = copy(
+ liked = !liked,
+ inLibrary = if (!liked) inLibrary ?: LocalDateTime.now() else inLibrary
+ )
+
+ fun toggleLibrary() = copy(inLibrary = if (inLibrary == null) LocalDateTime.now() else null)
+
+ companion object {
+ const val STATE_NOT_DOWNLOADED = 0
+ const val STATE_PREPARING = 1
+ const val STATE_DOWNLOADING = 2
+ const val STATE_DOWNLOADED = 3
+ }
+}
diff --git a/app/src/main/java/com/zionhuang/music/db/entities/SortedSongAlbumMap.kt b/app/src/main/java/com/zionhuang/music/db/entities/SortedSongAlbumMap.kt
new file mode 100644
index 000000000..999f5f4ad
--- /dev/null
+++ b/app/src/main/java/com/zionhuang/music/db/entities/SortedSongAlbumMap.kt
@@ -0,0 +1,13 @@
+package com.zionhuang.music.db.entities
+
+import androidx.room.ColumnInfo
+import androidx.room.DatabaseView
+
+@DatabaseView(
+ viewName = "sorted_song_album_map",
+ value = "SELECT * FROM song_album_map ORDER BY `index`")
+data class SortedSongAlbumMap(
+ @ColumnInfo(index = true) val songId: String,
+ @ColumnInfo(index = true) val albumId: String,
+ val index: Int,
+)
diff --git a/app/src/main/java/com/zionhuang/music/di/AppModule.kt b/app/src/main/java/com/zionhuang/music/di/AppModule.kt
new file mode 100644
index 000000000..622932a7b
--- /dev/null
+++ b/app/src/main/java/com/zionhuang/music/di/AppModule.kt
@@ -0,0 +1,61 @@
+package com.zionhuang.music.di
+
+import android.content.Context
+import androidx.media3.database.DatabaseProvider
+import androidx.media3.database.StandaloneDatabaseProvider
+import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
+import androidx.media3.datasource.cache.NoOpCacheEvictor
+import androidx.media3.datasource.cache.SimpleCache
+import com.zionhuang.music.constants.MaxSongCacheSizeKey
+import com.zionhuang.music.db.InternalDatabase
+import com.zionhuang.music.db.MusicDatabase
+import com.zionhuang.music.utils.dataStore
+import com.zionhuang.music.utils.get
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Qualifier
+import javax.inject.Singleton
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class PlayerCache
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class DownloadCache
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AppModule {
+ @Singleton
+ @Provides
+ fun provideDatabase(@ApplicationContext context: Context): MusicDatabase =
+ InternalDatabase.newInstance(context)
+
+ @Singleton
+ @Provides
+ fun provideDatabaseProvider(@ApplicationContext context: Context): DatabaseProvider =
+ StandaloneDatabaseProvider(context)
+
+ @Singleton
+ @Provides
+ @PlayerCache
+ fun providePlayerCache(@ApplicationContext context: Context, databaseProvider: DatabaseProvider): SimpleCache =
+ SimpleCache(
+ context.filesDir.resolve("exoplayer"),
+ when (val cacheSize = context.dataStore[MaxSongCacheSizeKey] ?: 1024) {
+ -1 -> NoOpCacheEvictor()
+ else -> LeastRecentlyUsedCacheEvictor(cacheSize * 1024 * 1024L)
+ },
+ databaseProvider
+ )
+
+ @Singleton
+ @Provides
+ @DownloadCache
+ fun provideDownloadCache(@ApplicationContext context: Context, databaseProvider: DatabaseProvider): SimpleCache =
+ SimpleCache(context.filesDir.resolve("download"), NoOpCacheEvictor(), databaseProvider)
+}
diff --git a/app/src/main/java/com/zionhuang/music/download/DownloadBroadcastReceiver.kt b/app/src/main/java/com/zionhuang/music/download/DownloadBroadcastReceiver.kt
deleted file mode 100644
index 7bb930546..000000000
--- a/app/src/main/java/com/zionhuang/music/download/DownloadBroadcastReceiver.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-package com.zionhuang.music.download
-
-import android.app.DownloadManager
-import android.app.DownloadManager.*
-import android.content.BroadcastReceiver
-import android.content.Context
-import android.content.Intent
-import androidx.core.content.getSystemService
-import com.zionhuang.music.extensions.get
-import com.zionhuang.music.repos.SongRepository
-import kotlinx.coroutines.DelicateCoroutinesApi
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.launch
-
-class DownloadBroadcastReceiver : BroadcastReceiver() {
- @OptIn(DelicateCoroutinesApi::class)
- override fun onReceive(context: Context, intent: Intent) {
- val downloadManager = context.getSystemService()!!
- val songRepository = SongRepository(context)
-
- when (intent.action) {
- ACTION_DOWNLOAD_COMPLETE -> {
- val id = intent.getLongExtra(EXTRA_DOWNLOAD_ID, -1)
- if (id == -1L) return
- downloadManager.query(Query().setFilterById(id)).use { cursor ->
- val success = cursor.moveToFirst() && cursor.get(COLUMN_STATUS) == STATUS_SUCCESSFUL
- GlobalScope.launch(IO) {
- songRepository.onDownloadComplete(id, success)
- }
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/extensions/ActivityExt.kt b/app/src/main/java/com/zionhuang/music/extensions/ActivityExt.kt
deleted file mode 100644
index 9346709af..000000000
--- a/app/src/main/java/com/zionhuang/music/extensions/ActivityExt.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.zionhuang.music.extensions
-
-import android.app.Activity
-import android.view.WindowManager
-import androidx.annotation.DimenRes
-import androidx.annotation.IdRes
-import androidx.appcompat.app.AppCompatActivity
-import androidx.fragment.app.Fragment
-
-fun AppCompatActivity.replaceFragment(@IdRes id: Int, fragment: Fragment, tag: String? = null, addToBackStack: Boolean = false) {
- supportFragmentManager.beginTransaction().apply {
- replace(id, fragment, tag)
- if (addToBackStack) {
- addToBackStack(null)
- }
- commit()
- }
-}
-
-fun Activity.dip(@DimenRes id: Int): Int {
- return resources.getDimensionPixelSize(id)
-}
-
-fun AppCompatActivity.keepScreenOn(keepScreenOn: Boolean) {
- if (keepScreenOn) {
- window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- } else {
- window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/extensions/AnyExt.kt b/app/src/main/java/com/zionhuang/music/extensions/AnyExt.kt
deleted file mode 100644
index 65f5d0d2e..000000000
--- a/app/src/main/java/com/zionhuang/music/extensions/AnyExt.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.zionhuang.music.extensions
-
-import android.util.Log
-
-val Any.TAG: String
- get() = javaClass.simpleName
-
-fun Any.logd(msg: String) {
- Log.d(TAG, msg)
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/extensions/BindingExt.kt b/app/src/main/java/com/zionhuang/music/extensions/BindingExt.kt
deleted file mode 100644
index 58d13f3dc..000000000
--- a/app/src/main/java/com/zionhuang/music/extensions/BindingExt.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.zionhuang.music.extensions
-
-import android.content.Context
-import androidx.viewbinding.ViewBinding
-
-val ViewBinding.context: Context
- get() = root.context
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/extensions/ContextExt.kt b/app/src/main/java/com/zionhuang/music/extensions/ContextExt.kt
deleted file mode 100644
index 992fc5977..000000000
--- a/app/src/main/java/com/zionhuang/music/extensions/ContextExt.kt
+++ /dev/null
@@ -1,59 +0,0 @@
-package com.zionhuang.music.extensions
-
-import android.content.Context
-import android.content.ContextWrapper
-import android.content.SharedPreferences
-import android.graphics.Color
-import androidx.annotation.AttrRes
-import androidx.annotation.ColorInt
-import androidx.annotation.DrawableRes
-import androidx.annotation.StringRes
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.content.res.use
-import androidx.lifecycle.LifecycleOwner
-import androidx.preference.PreferenceManager
-import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
-import com.zionhuang.music.models.toErrorInfo
-import com.zionhuang.music.ui.activities.ErrorActivity
-import com.zionhuang.music.utils.preference.Preference
-import com.zionhuang.music.utils.preference.PreferenceLiveData
-import kotlinx.coroutines.CoroutineExceptionHandler
-
-fun Context.getDensity(): Float = resources.displayMetrics.density
-
-tailrec fun Context?.getActivity(): AppCompatActivity? = when (this) {
- is AppCompatActivity -> this
- else -> (this as? ContextWrapper)?.baseContext?.getActivity()
-}
-
-tailrec fun Context?.getLifeCycleOwner(): LifecycleOwner? = when (this) {
- is LifecycleOwner -> this
- else -> (this as? ContextWrapper)?.baseContext?.getLifeCycleOwner()
-}
-
-@ColorInt
-fun Context.resolveColor(@AttrRes attr: Int): Int = obtainStyledAttributes(intArrayOf(attr)).use {
- it.getColor(0, Color.MAGENTA)
-}
-
-fun Context.getAnimatedVectorDrawable(@DrawableRes id: Int): AnimatedVectorDrawableCompat =
- AnimatedVectorDrawableCompat.create(this, id)!!
-
-val Context.sharedPreferences: SharedPreferences
- get() = PreferenceManager.getDefaultSharedPreferences(this)
-
-fun Context.preference(@StringRes keyId: Int, defaultValue: T) = Preference(this, keyId, defaultValue)
-
-fun Context.preferenceLiveData(@StringRes keyId: Int, defaultValue: T) = PreferenceLiveData(this, keyId, defaultValue)
-fun Context.preferenceLiveData(key: String, defaultValue: T) = PreferenceLiveData(this, key, defaultValue)
-
-fun Context.tryOrReport(block: () -> Unit) = try {
- block()
-} catch (e: Exception) {
- ErrorActivity.openActivity(this, e.toErrorInfo())
-}
-
-val Context.exceptionHandler
- get() = CoroutineExceptionHandler { _, throwable ->
- ErrorActivity.openActivity(this, throwable.toErrorInfo())
- }
diff --git a/app/src/main/java/com/zionhuang/music/extensions/CoroutineExt.kt b/app/src/main/java/com/zionhuang/music/extensions/CoroutineExt.kt
new file mode 100644
index 000000000..20d884300
--- /dev/null
+++ b/app/src/main/java/com/zionhuang/music/extensions/CoroutineExt.kt
@@ -0,0 +1,21 @@
+package com.zionhuang.music.extensions
+
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+
+fun Flow.collect(scope: CoroutineScope, action: suspend (value: T) -> Unit) {
+ scope.launch {
+ collect(action)
+ }
+}
+
+fun Flow.collectLatest(scope: CoroutineScope, action: suspend (value: T) -> Unit) {
+ scope.launch {
+ collectLatest(action)
+ }
+}
+
+val SilentHandler = CoroutineExceptionHandler { _, _ -> }
diff --git a/app/src/main/java/com/zionhuang/music/extensions/CursorExt.kt b/app/src/main/java/com/zionhuang/music/extensions/CursorExt.kt
deleted file mode 100644
index 532389486..000000000
--- a/app/src/main/java/com/zionhuang/music/extensions/CursorExt.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package com.zionhuang.music.extensions
-
-import android.database.Cursor
-
-fun Cursor.forEach(action: Cursor.() -> Unit) = use {
- if (moveToFirst()) {
- do {
- action(this)
- } while (moveToNext())
- }
-}
-
-inline operator fun Cursor.get(name: String): T {
- val index = getColumnIndexOrThrow(name)
- return when (T::class) {
- Short::class -> getShort(index) as T
- Int::class -> getInt(index) as T
- Long::class -> getLong(index) as T
- Boolean::class -> (getInt(index) == 1) as T
- String::class -> getString(index) as T
- Float::class -> getFloat(index) as T
- Double::class -> getDouble(index) as T
- ByteArray::class -> getBlob(index) as T
- else -> throw IllegalStateException("Unknown class ${T::class.java.simpleName}")
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/extensions/EditTextExt.kt b/app/src/main/java/com/zionhuang/music/extensions/EditTextExt.kt
deleted file mode 100644
index a653900e6..000000000
--- a/app/src/main/java/com/zionhuang/music/extensions/EditTextExt.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.zionhuang.music.extensions
-
-import android.widget.EditText
-import androidx.core.widget.doOnTextChanged
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-
-fun EditText.getTextChangeFlow(): StateFlow {
- val query = MutableStateFlow(text.toString())
- doOnTextChanged { text, _, _, _ ->
- query.value = text.toString()
- }
- return query
-}
diff --git a/app/src/main/java/com/zionhuang/music/extensions/FragmentExt.kt b/app/src/main/java/com/zionhuang/music/extensions/FragmentExt.kt
deleted file mode 100644
index 4a9ba0172..000000000
--- a/app/src/main/java/com/zionhuang/music/extensions/FragmentExt.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package com.zionhuang.music.extensions
-
-import android.content.Context
-import androidx.appcompat.app.AppCompatActivity
-import androidx.fragment.app.DialogFragment
-import androidx.fragment.app.Fragment
-
-fun Fragment.requireAppCompatActivity(): AppCompatActivity = requireActivity() as AppCompatActivity
-
-val Fragment.sharedPreferences get() = requireContext().sharedPreferences
-
-fun DialogFragment.show(context: Context, tag: String? = null) {
- context.getActivity()?.let {
- show(it.supportFragmentManager, tag)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/extensions/ListExt.kt b/app/src/main/java/com/zionhuang/music/extensions/ListExt.kt
index 2fb41d27c..c46f1513d 100644
--- a/app/src/main/java/com/zionhuang/music/extensions/ListExt.kt
+++ b/app/src/main/java/com/zionhuang/music/extensions/ListExt.kt
@@ -1,11 +1,9 @@
package com.zionhuang.music.extensions
-@Suppress("UNCHECKED_CAST")
-inline fun List<*>.castOrNull(): List? = if (all { it is T }) this as List else null
+fun List.reversed(reversed: Boolean) = if (reversed) asReversed() else this
-fun MutableList.swap(i: Int, j: Int): MutableList {
- this[i] = this[j].also { this[j] = this[i] }
+fun MutableList.move(fromIndex: Int, toIndex: Int): MutableList {
+ val item = removeAt(fromIndex)
+ add(toIndex, item)
return this
}
-
-fun List.reversed(reversed: Boolean) = if (reversed) reversed() else this
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/extensions/MediaCotrollerExt.kt b/app/src/main/java/com/zionhuang/music/extensions/MediaCotrollerExt.kt
deleted file mode 100644
index f7fd86564..000000000
--- a/app/src/main/java/com/zionhuang/music/extensions/MediaCotrollerExt.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package com.zionhuang.music.extensions
-
-import android.support.v4.media.session.MediaControllerCompat
-import androidx.core.os.bundleOf
-import com.google.android.exoplayer2.ext.mediasession.TimelineQueueEditor
-import com.google.android.exoplayer2.ext.mediasession.TimelineQueueEditor.COMMAND_MOVE_QUEUE_ITEM
-import com.zionhuang.music.constants.MediaSessionConstants.COMMAND_SEEK_TO_QUEUE_ITEM
-import com.zionhuang.music.constants.MediaSessionConstants.EXTRA_QUEUE_INDEX
-
-fun MediaControllerCompat.moveQueueItem(from: Int, to: Int) =
- sendCommand(COMMAND_MOVE_QUEUE_ITEM, bundleOf(
- TimelineQueueEditor.EXTRA_FROM_INDEX to from,
- TimelineQueueEditor.EXTRA_TO_INDEX to to
- ), null)
-
-fun MediaControllerCompat.seekToQueueItem(index: Int) =
- sendCommand(COMMAND_SEEK_TO_QUEUE_ITEM, bundleOf(
- EXTRA_QUEUE_INDEX to index
- ), null)
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/extensions/MediaItemExt.kt b/app/src/main/java/com/zionhuang/music/extensions/MediaItemExt.kt
index bdda724d8..50ae92c12 100644
--- a/app/src/main/java/com/zionhuang/music/extensions/MediaItemExt.kt
+++ b/app/src/main/java/com/zionhuang/music/extensions/MediaItemExt.kt
@@ -1,6 +1,8 @@
package com.zionhuang.music.extensions
-import com.google.android.exoplayer2.MediaItem
+import androidx.core.net.toUri
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata.MEDIA_TYPE_MUSIC
import com.zionhuang.innertube.models.SongItem
import com.zionhuang.music.db.entities.Song
import com.zionhuang.music.models.MediaMetadata
@@ -14,6 +16,15 @@ fun Song.toMediaItem() = MediaItem.Builder()
.setUri(song.id)
.setCustomCacheKey(song.id)
.setTag(toMediaMetadata())
+ .setMediaMetadata(
+ androidx.media3.common.MediaMetadata.Builder()
+ .setTitle(song.title)
+ .setSubtitle(artists.joinToString { it.name })
+ .setArtist(artists.joinToString { it.name })
+ .setArtworkUri(song.thumbnailUrl?.toUri())
+ .setMediaType(MEDIA_TYPE_MUSIC)
+ .build()
+ )
.build()
fun SongItem.toMediaItem() = MediaItem.Builder()
@@ -21,6 +32,15 @@ fun SongItem.toMediaItem() = MediaItem.Builder()
.setUri(id)
.setCustomCacheKey(id)
.setTag(toMediaMetadata())
+ .setMediaMetadata(
+ androidx.media3.common.MediaMetadata.Builder()
+ .setTitle(title)
+ .setSubtitle(artists.joinToString { it.name })
+ .setArtist(artists.joinToString { it.name })
+ .setArtworkUri(thumbnail.toUri())
+ .setMediaType(MEDIA_TYPE_MUSIC)
+ .build()
+ )
.build()
fun MediaMetadata.toMediaItem() = MediaItem.Builder()
@@ -28,4 +48,13 @@ fun MediaMetadata.toMediaItem() = MediaItem.Builder()
.setUri(id)
.setCustomCacheKey(id)
.setTag(this)
+ .setMediaMetadata(
+ androidx.media3.common.MediaMetadata.Builder()
+ .setTitle(title)
+ .setSubtitle(artists.joinToString { it.name })
+ .setArtist(artists.joinToString { it.name })
+ .setArtworkUri(thumbnailUrl?.toUri())
+ .setMediaType(MEDIA_TYPE_MUSIC)
+ .build()
+ )
.build()
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/extensions/MediaSessionConnectorExt.kt b/app/src/main/java/com/zionhuang/music/extensions/MediaSessionConnectorExt.kt
deleted file mode 100644
index 48b111139..000000000
--- a/app/src/main/java/com/zionhuang/music/extensions/MediaSessionConnectorExt.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.zionhuang.music.extensions
-
-import android.support.v4.media.MediaDescriptionCompat
-import com.google.android.exoplayer2.Player
-import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
-import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
-
-fun MediaSessionConnector.setQueueNavigator(getMediaDescription: (player: Player, windowIndex: Int) -> MediaDescriptionCompat) = setQueueNavigator(object : TimelineQueueNavigator(mediaSession, Int.MAX_VALUE) {
- override fun getMediaDescription(player: Player, windowIndex: Int) = getMediaDescription(player, windowIndex)
- override fun onSkipToPrevious(player: Player) {
- super.onSkipToPrevious(player)
- if (player.playerError != null) {
- player.prepare()
- }
- }
-
- override fun onSkipToNext(player: Player) {
- super.onSkipToNext(player)
- if (player.playerError != null) {
- player.prepare()
- }
- }
-
- override fun onSkipToQueueItem(player: Player, id: Long) {
- super.onSkipToQueueItem(player, id)
- if (player.playerError != null) {
- player.prepare()
- }
- }
-})
diff --git a/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt b/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt
index a9f8d733a..f2785c09b 100644
--- a/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt
+++ b/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt
@@ -1,28 +1,61 @@
package com.zionhuang.music.extensions
-import com.google.android.exoplayer2.MediaItem
-import com.google.android.exoplayer2.Player
+import androidx.media3.common.C
+import androidx.media3.common.MediaItem
+import androidx.media3.common.Player
+import androidx.media3.common.Player.REPEAT_MODE_OFF
+import androidx.media3.common.Timeline
import com.zionhuang.music.models.MediaMetadata
+import java.util.ArrayDeque
-fun Player.findMediaItemById(mediaId: String): MediaItem? {
- for (i in 0 until mediaItemCount) {
- val item = getMediaItemAt(i)
- if (item.mediaId == mediaId) {
- return item
+fun Player.togglePlayPause() {
+ playWhenReady = !playWhenReady
+}
+
+fun Player.getQueueWindows(): List {
+ val timeline = currentTimeline
+ if (timeline.isEmpty) {
+ return emptyList()
+ }
+ val queue = ArrayDeque()
+ val queueSize = timeline.windowCount
+
+ val currentMediaItemIndex: Int = currentMediaItemIndex
+ queue.add(timeline.getWindow(currentMediaItemIndex, Timeline.Window()))
+
+ var firstMediaItemIndex = currentMediaItemIndex
+ var lastMediaItemIndex = currentMediaItemIndex
+ val shuffleModeEnabled = shuffleModeEnabled
+ while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET) && queue.size < queueSize) {
+ if (lastMediaItemIndex != C.INDEX_UNSET) {
+ lastMediaItemIndex = timeline.getNextWindowIndex(lastMediaItemIndex, REPEAT_MODE_OFF, shuffleModeEnabled)
+ if (lastMediaItemIndex != C.INDEX_UNSET) {
+ queue.add(timeline.getWindow(lastMediaItemIndex, Timeline.Window()))
+ }
+ }
+ if (firstMediaItemIndex != C.INDEX_UNSET && queue.size < queueSize) {
+ firstMediaItemIndex = timeline.getPreviousWindowIndex(firstMediaItemIndex, REPEAT_MODE_OFF, shuffleModeEnabled)
+ if (firstMediaItemIndex != C.INDEX_UNSET) {
+ queue.addFirst(timeline.getWindow(firstMediaItemIndex, Timeline.Window()))
+ }
}
}
- return null
+ return queue.toList()
}
-fun Player.mediaItemIndexOf(mediaId: String?): Int? {
- if (mediaId == null) return null
- for (i in 0 until mediaItemCount) {
- val item = getMediaItemAt(i)
- if (item.mediaId == mediaId) {
- return i
+fun Player.getCurrentQueueIndex(): Int {
+ if (currentTimeline.isEmpty) {
+ return -1
+ }
+ var index = 0
+ var currentMediaItemIndex = currentMediaItemIndex
+ while (currentMediaItemIndex != C.INDEX_UNSET) {
+ currentMediaItemIndex = currentTimeline.getPreviousWindowIndex(currentMediaItemIndex, REPEAT_MODE_OFF, shuffleModeEnabled)
+ if (currentMediaItemIndex != C.INDEX_UNSET) {
+ index++
}
}
- return null
+ return index
}
val Player.currentMetadata: MediaMetadata?
@@ -34,4 +67,13 @@ val Player.mediaItems: List
get() = mediaItemCount
override fun get(index: Int): MediaItem = getMediaItemAt(index)
- }
\ No newline at end of file
+ }
+
+fun Player.findNextMediaItemById(mediaId: String): MediaItem? {
+ for (i in currentMediaItemIndex until mediaItemCount) {
+ if (getMediaItemAt(i).mediaId == mediaId) {
+ return getMediaItemAt(i)
+ }
+ }
+ return null
+}
diff --git a/app/src/main/java/com/zionhuang/music/extensions/RecyclerViewExt.kt b/app/src/main/java/com/zionhuang/music/extensions/RecyclerViewExt.kt
deleted file mode 100644
index e13b1d133..000000000
--- a/app/src/main/java/com/zionhuang/music/extensions/RecyclerViewExt.kt
+++ /dev/null
@@ -1,47 +0,0 @@
-package com.zionhuang.music.extensions
-
-import android.view.View
-import androidx.recyclerview.widget.RecyclerView
-import me.zhanghai.android.fastscroll.FastScroller
-import me.zhanghai.android.fastscroll.FastScrollerBuilder
-
-typealias RecyclerViewItemClickListener = (position: Int, view: View) -> Unit
-
-fun RecyclerView.addOnClickListener(clickListener: RecyclerViewItemClickListener) {
- this.addOnChildAttachStateChangeListener(object : RecyclerView.OnChildAttachStateChangeListener {
- override fun onChildViewAttachedToWindow(view: View) {
- view.setOnClickListener {
- val position = this@addOnClickListener.getChildLayoutPosition(view)
- if (position >= 0) {
- clickListener.invoke(position, view)
- }
- }
- }
-
- override fun onChildViewDetachedFromWindow(view: View) {
- view.setOnClickListener(null)
- }
- })
-}
-
-fun RecyclerView.addOnLongClickListener(longClickListener: RecyclerViewItemClickListener) {
- this.addOnChildAttachStateChangeListener(object : RecyclerView.OnChildAttachStateChangeListener {
- override fun onChildViewAttachedToWindow(view: View) {
- view.setOnLongClickListener {
- val position = this@addOnLongClickListener.getChildLayoutPosition(view)
- if (position >= 0) {
- longClickListener.invoke(position, view)
-
- }
- return@setOnLongClickListener true
- }
- }
-
- override fun onChildViewDetachedFromWindow(view: View) {
- view.setOnLongClickListener(null)
- }
- })
-}
-
-fun RecyclerView.addFastScroller(applier: (FastScrollerBuilder.() -> Unit)): FastScroller =
- FastScrollerBuilder(this).apply(applier).build()
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/extensions/SharedPreferencesExt.kt b/app/src/main/java/com/zionhuang/music/extensions/SharedPreferencesExt.kt
deleted file mode 100644
index a8dbb4fb2..000000000
--- a/app/src/main/java/com/zionhuang/music/extensions/SharedPreferencesExt.kt
+++ /dev/null
@@ -1,73 +0,0 @@
-package com.zionhuang.music.extensions
-
-import android.content.SharedPreferences
-import androidx.core.content.edit
-import kotlinx.coroutines.channels.awaitClose
-import kotlinx.coroutines.flow.*
-import kotlinx.serialization.decodeFromString
-import kotlinx.serialization.encodeToString
-import kotlinx.serialization.json.Json
-
-inline fun SharedPreferences.getSerializable(key: String, defaultValue: T): T? = getString(key, null)?.let {
- Json.decodeFromString(it) as? T
-} ?: defaultValue
-
-inline fun SharedPreferences.putSerializable(key: String, value: T) {
- val jsonString = Json.encodeToString(value)
- edit().putString(key, jsonString).apply()
-}
-
-inline fun > SharedPreferences.getEnum(key: String, defaultValue: E): E = getString(key, null)?.let {
- try {
- enumValueOf(it)
- } catch (e: IllegalArgumentException) {
- null
- }
-} ?: defaultValue
-
-inline fun > SharedPreferences.putEnum(key: String, value: T) = edit().putString(key, value.name).apply()
-
-@Suppress("UNCHECKED_CAST")
-fun SharedPreferences.get(key: String, defaultValue: T): T = when (defaultValue::class) {
- Boolean::class -> getBoolean(key, defaultValue as Boolean)
- Float::class -> getFloat(key, defaultValue as Float)
- Int::class -> getInt(key, defaultValue as Int)
- Long::class -> getLong(key, defaultValue as Long)
- String::class -> getString(key, defaultValue as String)
- else -> throw IllegalArgumentException("Unexpected type: ${defaultValue::class.java.name}")
-} as T
-
-operator fun SharedPreferences.set(key: String, value: T) {
- edit {
- when (value::class) {
- Boolean::class -> putBoolean(key, value as Boolean)
- Float::class -> putFloat(key, value as Float)
- Int::class -> putInt(key, value as Int)
- Long::class -> putLong(key, value as Long)
- String::class -> putString(key, value as String)
- }
- }
-}
-
-val SharedPreferences.keyFlow: Flow
- get() = callbackFlow {
- val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key: String? ->
- trySend(key)
- }
- registerOnSharedPreferenceChangeListener(listener)
- awaitClose {
- unregisterOnSharedPreferenceChangeListener(listener)
- }
- }
-
-fun SharedPreferences.booleanFlow(key: String, defaultValue: Boolean) = keyFlow
- .filter { it == key || it == null }
- .onStart { emit("init trigger") }
- .map { getBoolean(key, defaultValue) }
- .conflate()
-
-inline fun > SharedPreferences.enumFlow(key: String, defaultValue: E) = keyFlow
- .filter { it == key || it == null }
- .onStart { emit("init trigger") }
- .map { getEnum(key, defaultValue) }
- .conflate()
diff --git a/app/src/main/java/com/zionhuang/music/extensions/StringExt.kt b/app/src/main/java/com/zionhuang/music/extensions/StringExt.kt
index 5b640cadf..5e63570f5 100644
--- a/app/src/main/java/com/zionhuang/music/extensions/StringExt.kt
+++ b/app/src/main/java/com/zionhuang/music/extensions/StringExt.kt
@@ -1,18 +1,17 @@
package com.zionhuang.music.extensions
import androidx.sqlite.db.SimpleSQLiteQuery
-import com.google.gson.JsonElement
-import com.google.gson.JsonParser
-import com.google.gson.JsonSyntaxException
import java.net.InetSocketAddress
import java.net.InetSocketAddress.createUnresolved
-@Throws(JsonSyntaxException::class)
-fun String.parseJsonString(): JsonElement = JsonParser.parseString(this)
+inline fun > String?.toEnum(defaultValue: T): T =
+ if (this == null) defaultValue
+ else try {
+ enumValueOf(this)
+ } catch (e: IllegalArgumentException) {
+ defaultValue
+ }
-/**
- * Database Extensions
- */
fun String.toSQLiteQuery(): SimpleSQLiteQuery = SimpleSQLiteQuery(this)
fun String.toInetSocketAddress(): InetSocketAddress {
diff --git a/app/src/main/java/com/zionhuang/music/extensions/UtilExt.kt b/app/src/main/java/com/zionhuang/music/extensions/UtilExt.kt
deleted file mode 100644
index 537ffd7cf..000000000
--- a/app/src/main/java/com/zionhuang/music/extensions/UtilExt.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package com.zionhuang.music.extensions
-
-import android.app.Application
-import com.zionhuang.music.App
-
-fun getApplication(): Application = App.INSTANCE
-
-fun tryOrNull(block: () -> T): T? = try {
- block()
-} catch (e: Exception) {
- null
-}
diff --git a/app/src/main/java/com/zionhuang/music/extensions/ViewExt.kt b/app/src/main/java/com/zionhuang/music/extensions/ViewExt.kt
deleted file mode 100644
index bd8d1d3a3..000000000
--- a/app/src/main/java/com/zionhuang/music/extensions/ViewExt.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-package com.zionhuang.music.extensions
-
-import android.animation.Animator
-import android.animation.AnimatorListenerAdapter
-import android.app.Activity
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.annotation.LayoutRes
-import androidx.core.view.isVisible
-import androidx.databinding.DataBindingUtil
-import androidx.databinding.ViewDataBinding
-
-fun ViewGroup.inflateWithBinding(@LayoutRes layoutRes: Int, attachToRoot: Boolean = false): T =
- DataBindingUtil.inflate(LayoutInflater.from(context), layoutRes, this, attachToRoot) as T
-
-fun View.getActivity(): Activity? = context.getActivity()
-
-fun View.fadeIn(duration: Long) {
- isVisible = true
- alpha = 0f
- animate()
- .alpha(1f)
- .setDuration(duration)
- .setListener(null)
-}
-
-fun View.fadeOut(duration: Long) {
- isVisible = true
- alpha = 1f
- animate()
- .alpha(0f)
- .setDuration(duration)
- .setListener(object : AnimatorListenerAdapter() {
- override fun onAnimationEnd(animation: Animator) {
- isVisible = false
- }
- })
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/extensions/ViewModelExt.kt b/app/src/main/java/com/zionhuang/music/extensions/ViewModelExt.kt
deleted file mode 100644
index f93f29791..000000000
--- a/app/src/main/java/com/zionhuang/music/extensions/ViewModelExt.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package com.zionhuang.music.extensions
-
-import androidx.annotation.StringRes
-import androidx.lifecycle.AndroidViewModel
-import com.zionhuang.music.utils.preference.Preference
-import com.zionhuang.music.utils.preference.PreferenceLiveData
-
-fun AndroidViewModel.preference(@StringRes keyId: Int, defaultValue: T) = Preference(getApplication(), keyId, defaultValue)
-fun AndroidViewModel.preferenceLiveData(@StringRes keyId: Int, defaultValue: T) = PreferenceLiveData(getApplication(), keyId, defaultValue)
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/extensions/WindowInsetExt.kt b/app/src/main/java/com/zionhuang/music/extensions/WindowInsetExt.kt
deleted file mode 100644
index b9bd0d67f..000000000
--- a/app/src/main/java/com/zionhuang/music/extensions/WindowInsetExt.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-package com.zionhuang.music.extensions
-
-import android.os.Build
-import android.view.WindowInsets
-import androidx.annotation.RequiresApi
-import androidx.core.graphics.Insets
-
-
-val WindowInsets.systemBarInsetsCompat: Insets
- get() = when {
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> getCompatInsets(WindowInsets.Type.systemBars())
- else -> getSystemWindowCompatInsets()
- }
-
-@RequiresApi(Build.VERSION_CODES.R)
-fun WindowInsets.getCompatInsets(typeMask: Int) = Insets.toCompatInsets(getInsets(typeMask))
-
-@Suppress("DEPRECATION")
-fun WindowInsets.getSystemWindowCompatInsets() = Insets.of(
- systemWindowInsetLeft,
- systemWindowInsetTop,
- systemWindowInsetRight,
- systemWindowInsetBottom
-)
-
-val WindowInsets.systemGestureInsetsCompat: Insets
- get() = when {
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
- Insets.max(
- getCompatInsets(WindowInsets.Type.systemGestures()),
- getCompatInsets(WindowInsets.Type.systemBars())
- )
- }
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
- @Suppress("DEPRECATION")
- Insets.max(getSystemGestureCompatInsets(), getSystemWindowCompatInsets())
- }
- else -> getSystemWindowCompatInsets()
- }
-
-@Suppress("DEPRECATION")
-@RequiresApi(Build.VERSION_CODES.Q)
-fun WindowInsets.getSystemGestureCompatInsets() = Insets.toCompatInsets(systemGestureInsets)
diff --git a/app/src/main/java/com/zionhuang/music/extensions/YouTubeExt.kt b/app/src/main/java/com/zionhuang/music/extensions/YouTubeExt.kt
deleted file mode 100644
index 46bdfcc0d..000000000
--- a/app/src/main/java/com/zionhuang/music/extensions/YouTubeExt.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-package com.zionhuang.music.extensions
-
-import androidx.paging.PagingSource.LoadResult
-import com.zionhuang.innertube.models.AlbumOrPlaylistHeader
-import com.zionhuang.innertube.models.BrowseResult
-import com.zionhuang.innertube.models.PlaylistItem
-import com.zionhuang.innertube.models.SongItem
-import com.zionhuang.music.db.entities.PlaylistEntity
-import com.zionhuang.music.db.entities.SongEntity
-
-// the SongItem should be produced by get_queue endpoint to have detailed information
-fun SongItem.toSongEntity() = SongEntity(
- id = id,
- title = title,
- duration = duration!!,
- thumbnailUrl = thumbnails.last().url,
- albumId = album?.navigationEndpoint?.browseId,
- albumName = album?.text
-)
-
-fun PlaylistItem.toPlaylistEntity() = PlaylistEntity(
- id = id,
- name = title,
- thumbnailUrl = thumbnails.last().url
-)
-
-fun AlbumOrPlaylistHeader.toPlaylistEntity() = PlaylistEntity(
- id = id,
- name = name,
- author = artists?.firstOrNull()?.text,
- authorId = artists?.firstOrNull()?.navigationEndpoint?.browseEndpoint?.browseId,
- year = year,
- thumbnailUrl = thumbnails.lastOrNull()?.url
-)
-
-fun BrowseResult.toPage() = LoadResult.Page(
- data = items,
- nextKey = continuations?.ifEmpty { null },
- prevKey = null
-)
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/lyrics/KuGouLyricsProvider.kt b/app/src/main/java/com/zionhuang/music/lyrics/KuGouLyricsProvider.kt
index dfe68eb15..4c4438e3d 100644
--- a/app/src/main/java/com/zionhuang/music/lyrics/KuGouLyricsProvider.kt
+++ b/app/src/main/java/com/zionhuang/music/lyrics/KuGouLyricsProvider.kt
@@ -2,17 +2,19 @@ package com.zionhuang.music.lyrics
import android.content.Context
import com.zionhuang.kugou.KuGou
-import com.zionhuang.music.R
-import com.zionhuang.music.extensions.sharedPreferences
+import com.zionhuang.music.constants.EnableKugouKey
+import com.zionhuang.music.utils.dataStore
+import com.zionhuang.music.utils.get
object KuGouLyricsProvider : LyricsProvider {
override val name = "Kugou"
override fun isEnabled(context: Context): Boolean =
- context.sharedPreferences.getBoolean(context.getString(R.string.pref_enable_kugou), true)
+ context.dataStore[EnableKugouKey] ?: true
- override suspend fun getLyrics(id: String?, title: String, artist: String, duration: Int): Result =
+ override suspend fun getLyrics(id: String, title: String, artist: String, duration: Int): Result =
KuGou.getLyrics(title, artist, duration)
- override suspend fun getAllLyrics(id: String?, title: String, artist: String, duration: Int): Result> =
- KuGou.getAllLyrics(title, artist, duration)
-}
\ No newline at end of file
+ override suspend fun getAllLyrics(id: String, title: String, artist: String, duration: Int, callback: (String) -> Unit) {
+ KuGou.getAllLyrics(title, artist, duration, callback)
+ }
+}
diff --git a/app/src/main/java/com/zionhuang/music/lyrics/LyricsEntry.kt b/app/src/main/java/com/zionhuang/music/lyrics/LyricsEntry.kt
new file mode 100644
index 000000000..1e2d7b25b
--- /dev/null
+++ b/app/src/main/java/com/zionhuang/music/lyrics/LyricsEntry.kt
@@ -0,0 +1,12 @@
+package com.zionhuang.music.lyrics
+
+data class LyricsEntry(
+ val time: Long,
+ val text: String,
+) : Comparable {
+ override fun compareTo(other: LyricsEntry): Int = (time - other.time).toInt()
+
+ companion object {
+ val HEAD_LYRICS_ENTRY = LyricsEntry(0L, "")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/lyrics/LyricsHelper.kt b/app/src/main/java/com/zionhuang/music/lyrics/LyricsHelper.kt
new file mode 100644
index 000000000..9c1121aa2
--- /dev/null
+++ b/app/src/main/java/com/zionhuang/music/lyrics/LyricsHelper.kt
@@ -0,0 +1,73 @@
+package com.zionhuang.music.lyrics
+
+import android.content.Context
+import android.util.LruCache
+import com.zionhuang.music.db.entities.LyricsEntity.Companion.LYRICS_NOT_FOUND
+import com.zionhuang.music.models.MediaMetadata
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+
+class LyricsHelper @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+ private val lyricsProviders = listOf(YouTubeSubtitleLyricsProvider, KuGouLyricsProvider, YouTubeLyricsProvider)
+ private val cache = LruCache>(MAX_CACHE_SIZE)
+
+ suspend fun getLyrics(mediaMetadata: MediaMetadata): String {
+ val cached = cache.get(mediaMetadata.id)?.firstOrNull()
+ if (cached != null) {
+ return cached.lyrics
+ }
+ lyricsProviders.forEach { provider ->
+ if (provider.isEnabled(context)) {
+ provider.getLyrics(
+ mediaMetadata.id,
+ mediaMetadata.title,
+ mediaMetadata.artists.joinToString { it.name },
+ mediaMetadata.duration
+ ).onSuccess { lyrics ->
+ return lyrics
+ }.onFailure {
+ it.printStackTrace()
+ }
+ }
+ }
+ return LYRICS_NOT_FOUND
+ }
+
+ suspend fun getAllLyrics(
+ mediaId: String,
+ songTitle: String,
+ songArtists: String,
+ duration: Int,
+ callback: (LyricsResult) -> Unit,
+ ) {
+ val cacheKey = "$songArtists-$songTitle".replace(" ", "")
+ cache.get(cacheKey)?.let { results ->
+ results.forEach {
+ callback(it)
+ }
+ return
+ }
+ val allResult = mutableListOf()
+ lyricsProviders.forEach { provider ->
+ if (provider.isEnabled(context)) {
+ provider.getAllLyrics(mediaId, songTitle, songArtists, duration) { lyrics ->
+ val result = LyricsResult(provider.name, lyrics)
+ allResult += result
+ callback(result)
+ }
+ }
+ }
+ cache.put(cacheKey, allResult)
+ }
+
+ companion object {
+ private const val MAX_CACHE_SIZE = 3
+ }
+}
+
+data class LyricsResult(
+ val providerName: String,
+ val lyrics: String,
+)
diff --git a/app/src/main/java/com/zionhuang/music/lyrics/LyricsProvider.kt b/app/src/main/java/com/zionhuang/music/lyrics/LyricsProvider.kt
index 30210fac7..d338710e9 100644
--- a/app/src/main/java/com/zionhuang/music/lyrics/LyricsProvider.kt
+++ b/app/src/main/java/com/zionhuang/music/lyrics/LyricsProvider.kt
@@ -5,6 +5,8 @@ import android.content.Context
interface LyricsProvider {
val name: String
fun isEnabled(context: Context): Boolean
- suspend fun getLyrics(id: String?, title: String, artist: String, duration: Int): Result
- suspend fun getAllLyrics(id: String?, title: String, artist: String, duration: Int): Result>
+ suspend fun getLyrics(id: String, title: String, artist: String, duration: Int): Result
+ suspend fun getAllLyrics(id: String, title: String, artist: String, duration: Int, callback: (String) -> Unit) {
+ getLyrics(id, title, artist, duration).onSuccess(callback)
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/utils/lyrics/LyricsUtils.kt b/app/src/main/java/com/zionhuang/music/lyrics/LyricsUtils.kt
similarity index 67%
rename from app/src/main/java/com/zionhuang/music/utils/lyrics/LyricsUtils.kt
rename to app/src/main/java/com/zionhuang/music/lyrics/LyricsUtils.kt
index 2137c0d65..821df447c 100644
--- a/app/src/main/java/com/zionhuang/music/utils/lyrics/LyricsUtils.kt
+++ b/app/src/main/java/com/zionhuang/music/lyrics/LyricsUtils.kt
@@ -1,9 +1,9 @@
-package com.zionhuang.music.utils.lyrics
+package com.zionhuang.music.lyrics
-import android.text.format.DateUtils.MINUTE_IN_MILLIS
-import android.text.format.DateUtils.SECOND_IN_MILLIS
-import java.util.*
+import android.text.format.DateUtils
+import com.zionhuang.music.ui.component.animateScrollDuration
+@Suppress("RegExpRedundantEscape")
object LyricsUtils {
private val LINE_REGEX = "((\\[\\d\\d:\\d\\d\\.\\d{2,3}\\])+)(.+)".toRegex()
private val TIME_REGEX = "\\[(\\d\\d):(\\d\\d)\\.(\\d{2,3})\\]".toRegex()
@@ -31,16 +31,17 @@ object LyricsUtils {
if (milString.length == 2) {
mil *= 10
}
- val time = min * MINUTE_IN_MILLIS + sec * SECOND_IN_MILLIS + mil
+ val time = min * DateUtils.MINUTE_IN_MILLIS + sec * DateUtils.SECOND_IN_MILLIS + mil
LyricsEntry(time, text)
}.toList()
}
- fun formatTime(milli: Long): String {
- val m = (milli / MINUTE_IN_MILLIS).toInt()
- val s = (milli / SECOND_IN_MILLIS % 60).toInt()
- val mm = String.format(Locale.getDefault(), "%02d", m)
- val ss = String.format(Locale.getDefault(), "%02d", s)
- return "$mm:$ss"
+ fun findCurrentLineIndex(lines: List, position: Long): Int {
+ for (index in lines.indices) {
+ if (lines[index].time >= position + animateScrollDuration) {
+ return index - 1
+ }
+ }
+ return lines.lastIndex
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/lyrics/YouTubeLyricsProvider.kt b/app/src/main/java/com/zionhuang/music/lyrics/YouTubeLyricsProvider.kt
index 6daddf107..dd876f75c 100644
--- a/app/src/main/java/com/zionhuang/music/lyrics/YouTubeLyricsProvider.kt
+++ b/app/src/main/java/com/zionhuang/music/lyrics/YouTubeLyricsProvider.kt
@@ -7,13 +7,10 @@ import com.zionhuang.innertube.models.WatchEndpoint
object YouTubeLyricsProvider : LyricsProvider {
override val name = "YouTube Music"
override fun isEnabled(context: Context) = true
- override suspend fun getLyrics(id: String?, title: String, artist: String, duration: Int): Result =
- YouTube.next(WatchEndpoint(videoId = id!!)).mapCatching { nextResult ->
- YouTube.browse(nextResult.lyricsEndpoint ?: throw IllegalStateException("Lyrics endpoint not found")).getOrThrow()
- }.mapCatching { browseResult ->
- browseResult.lyrics ?: throw IllegalStateException("Lyrics unavailable")
- }
-
- override suspend fun getAllLyrics(id: String?, title: String, artist: String, duration: Int): Result> =
- getLyrics(id, title, artist, duration).map { listOf(it) }
-}
\ No newline at end of file
+ override suspend fun getLyrics(id: String, title: String, artist: String, duration: Int): Result = runCatching {
+ val nextResult = YouTube.next(WatchEndpoint(videoId = id)).getOrThrow()
+ YouTube.lyrics(
+ endpoint = nextResult.lyricsEndpoint ?: throw IllegalStateException("Lyrics endpoint not found")
+ ).getOrThrow() ?: throw IllegalStateException("Lyrics unavailable")
+ }
+}
diff --git a/app/src/main/java/com/zionhuang/music/lyrics/YouTubeSubtitleLyricsProvider.kt b/app/src/main/java/com/zionhuang/music/lyrics/YouTubeSubtitleLyricsProvider.kt
new file mode 100644
index 000000000..781a88891
--- /dev/null
+++ b/app/src/main/java/com/zionhuang/music/lyrics/YouTubeSubtitleLyricsProvider.kt
@@ -0,0 +1,11 @@
+package com.zionhuang.music.lyrics
+
+import android.content.Context
+import com.zionhuang.innertube.YouTube
+
+object YouTubeSubtitleLyricsProvider : LyricsProvider {
+ override val name = "YouTube Subtitle"
+ override fun isEnabled(context: Context) = true
+ override suspend fun getLyrics(id: String, title: String, artist: String, duration: Int): Result =
+ YouTube.transcript(id)
+}
diff --git a/app/src/main/java/com/zionhuang/music/models/DataWrapper.kt b/app/src/main/java/com/zionhuang/music/models/DataWrapper.kt
deleted file mode 100644
index 3994807ae..000000000
--- a/app/src/main/java/com/zionhuang/music/models/DataWrapper.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package com.zionhuang.music.models
-
-import androidx.lifecycle.LiveData
-import kotlinx.coroutines.flow.Flow
-
-
-open class DataWrapper(
- val getValue: () -> T = { throw UnsupportedOperationException() },
- val getValueAsync: suspend () -> T = { throw UnsupportedOperationException() },
- open val getFlow: () -> Flow = { throw UnsupportedOperationException() },
- open val getLiveData: () -> LiveData = { throw UnsupportedOperationException() },
-) {
- val value: T get() = getValue()
- val flow: Flow get() = getFlow()
- val liveData: LiveData get() = getLiveData()
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/models/DownloadProgress.kt b/app/src/main/java/com/zionhuang/music/models/DownloadProgress.kt
deleted file mode 100644
index 0ed48c72c..000000000
--- a/app/src/main/java/com/zionhuang/music/models/DownloadProgress.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.zionhuang.music.models
-
-data class DownloadProgress(
- val status: Int,
- val currentBytes: Int = -1,
- val totalBytes: Int = -1,
-)
diff --git a/app/src/main/java/com/zionhuang/music/models/ErrorInfo.kt b/app/src/main/java/com/zionhuang/music/models/ErrorInfo.kt
deleted file mode 100644
index a0e214c23..000000000
--- a/app/src/main/java/com/zionhuang/music/models/ErrorInfo.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package com.zionhuang.music.models
-
-import android.os.Parcelable
-import kotlinx.parcelize.Parcelize
-
-@Parcelize
-data class ErrorInfo(
- val stackTrace: String,
-) : Parcelable
-
-fun Throwable.toErrorInfo() = ErrorInfo(
- stackTraceToString()
-)
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/models/ItemsPage.kt b/app/src/main/java/com/zionhuang/music/models/ItemsPage.kt
new file mode 100644
index 000000000..a5dc16c4f
--- /dev/null
+++ b/app/src/main/java/com/zionhuang/music/models/ItemsPage.kt
@@ -0,0 +1,8 @@
+package com.zionhuang.music.models
+
+import com.zionhuang.innertube.models.YTItem
+
+data class ItemsPage(
+ val items: List,
+ val continuation: String?,
+)
diff --git a/app/src/main/java/com/zionhuang/music/models/ListWrapper.kt b/app/src/main/java/com/zionhuang/music/models/ListWrapper.kt
deleted file mode 100644
index da92a33bc..000000000
--- a/app/src/main/java/com/zionhuang/music/models/ListWrapper.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.zionhuang.music.models
-
-import androidx.lifecycle.LiveData
-import kotlinx.coroutines.flow.Flow
-
-class ListWrapper(
- val getList: suspend () -> List = { throw UnsupportedOperationException() },
- override val getFlow: () -> Flow> = { throw UnsupportedOperationException() },
- override val getLiveData: () -> LiveData> = { throw UnsupportedOperationException() },
-) : DataWrapper>(getValueAsync = getList)
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt b/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt
index 31421c166..f25a8c641 100644
--- a/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt
+++ b/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt
@@ -1,21 +1,12 @@
package com.zionhuang.music.models
-import android.content.Context
-import android.os.Parcelable
-import android.support.v4.media.MediaDescriptionCompat
-import android.support.v4.media.MediaMetadataCompat.*
-import androidx.core.net.toUri
-import androidx.core.os.bundleOf
+import androidx.compose.runtime.Immutable
import com.zionhuang.innertube.models.SongItem
-import com.zionhuang.music.db.entities.ArtistEntity
-import com.zionhuang.music.db.entities.Song
-import com.zionhuang.music.db.entities.SongEntity
-import com.zionhuang.music.ui.bindings.resizeThumbnailUrl
-import kotlinx.parcelize.Parcelize
+import com.zionhuang.music.db.entities.*
+import com.zionhuang.music.ui.utils.resize
import java.io.Serializable
-import kotlin.math.roundToInt
-@Parcelize
+@Immutable
data class MediaMetadata(
val id: String,
val title: String,
@@ -23,32 +14,16 @@ data class MediaMetadata(
val duration: Int,
val thumbnailUrl: String? = null,
val album: Album? = null,
-) : Parcelable, Serializable {
- @Parcelize
+) : Serializable {
data class Artist(
- val id: String,
+ val id: String?,
val name: String,
- ) : Parcelable, Serializable
+ ) : Serializable
- @Parcelize
data class Album(
val id: String,
val title: String,
- val year: Int? = null,
- ) : Parcelable, Serializable
-
- fun toMediaDescription(context: Context): MediaDescriptionCompat = builder
- .setMediaId(id)
- .setTitle(title)
- .setSubtitle(artists.joinToString { it.name })
- .setDescription(artists.joinToString { it.name })
- .setIconUri(thumbnailUrl?.let { resizeThumbnailUrl(it, (512 * context.resources.displayMetrics.density).roundToInt(), null) }?.toUri())
- .setExtras(bundleOf(
- METADATA_KEY_DURATION to duration * 1000L,
- METADATA_KEY_ARTIST to artists.joinToString { it.name },
- METADATA_KEY_ALBUM to album?.title
- ))
- .build()
+ ) : Serializable
fun toSongEntity() = SongEntity(
id = id,
@@ -58,10 +33,6 @@ data class MediaMetadata(
albumId = album?.id,
albumName = album?.title
)
-
- companion object {
- private val builder = MediaDescriptionCompat.Builder()
- }
}
fun Song.toMediaMetadata() = MediaMetadata(
@@ -78,8 +49,7 @@ fun Song.toMediaMetadata() = MediaMetadata(
album = album?.let {
MediaMetadata.Album(
id = it.id,
- title = it.title,
- year = it.year
+ title = it.title
)
} ?: song.albumId?.let { albumId ->
MediaMetadata.Album(
@@ -94,17 +64,16 @@ fun SongItem.toMediaMetadata() = MediaMetadata(
title = title,
artists = artists.map {
MediaMetadata.Artist(
- id = it.navigationEndpoint?.browseEndpoint?.browseId ?: ArtistEntity.generateArtistId(),
- name = it.text
+ id = it.id,
+ name = it.name
)
},
- duration = duration ?: 0,
- thumbnailUrl = thumbnails.lastOrNull()?.url,
+ duration = duration ?: -1,
+ thumbnailUrl = thumbnail.resize(544, 544),
album = album?.let {
MediaMetadata.Album(
- id = it.navigationEndpoint.browseId,
- title = it.text,
- year = albumYear
+ id = it.id,
+ title = it.name
)
}
-)
\ No newline at end of file
+)
diff --git a/app/src/main/java/com/zionhuang/music/playback/PersistQueue.kt b/app/src/main/java/com/zionhuang/music/models/PersistQueue.kt
similarity index 68%
rename from app/src/main/java/com/zionhuang/music/playback/PersistQueue.kt
rename to app/src/main/java/com/zionhuang/music/models/PersistQueue.kt
index 0f422a256..5f412bfee 100644
--- a/app/src/main/java/com/zionhuang/music/playback/PersistQueue.kt
+++ b/app/src/main/java/com/zionhuang/music/models/PersistQueue.kt
@@ -1,6 +1,5 @@
-package com.zionhuang.music.playback
+package com.zionhuang.music.models
-import com.zionhuang.music.models.MediaMetadata
import java.io.Serializable
data class PersistQueue(
diff --git a/app/src/main/java/com/zionhuang/music/models/PlaybackStateData.kt b/app/src/main/java/com/zionhuang/music/models/PlaybackStateData.kt
deleted file mode 100644
index e7ef85447..000000000
--- a/app/src/main/java/com/zionhuang/music/models/PlaybackStateData.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.zionhuang.music.models
-
-import android.support.v4.media.session.MediaControllerCompat
-import android.support.v4.media.session.PlaybackStateCompat
-import android.support.v4.media.session.PlaybackStateCompat.*
-
-data class PlaybackStateData(
- @State val state: Int = STATE_NONE,
- @ShuffleMode val shuffleMode: Int = SHUFFLE_MODE_NONE,
- @RepeatMode val repeatMode: Int = REPEAT_MODE_NONE,
- @Actions val actions: Long = 0,
- val errorCode: Int = 0,
- val errorMessage: String? = null,
-) {
- companion object {
- fun from(mediaController: MediaControllerCompat, playbackState: PlaybackStateCompat) = PlaybackStateData(
- playbackState.state,
- mediaController.shuffleMode,
- mediaController.repeatMode,
- playbackState.actions,
- playbackState.errorCode,
- playbackState.errorMessage?.toString()
- )
- }
-}
diff --git a/app/src/main/java/com/zionhuang/music/models/sortInfo/AlbumSortInfoPreference.kt b/app/src/main/java/com/zionhuang/music/models/sortInfo/AlbumSortInfoPreference.kt
deleted file mode 100644
index f4dfe9f7f..000000000
--- a/app/src/main/java/com/zionhuang/music/models/sortInfo/AlbumSortInfoPreference.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package com.zionhuang.music.models.sortInfo
-
-import android.content.Context
-import com.zionhuang.music.R
-import com.zionhuang.music.extensions.booleanFlow
-import com.zionhuang.music.extensions.enumFlow
-import com.zionhuang.music.extensions.getApplication
-import com.zionhuang.music.extensions.sharedPreferences
-import com.zionhuang.music.utils.preference.Preference
-import com.zionhuang.music.utils.preference.enumPreference
-
-object AlbumSortInfoPreference : SortInfoPreference() {
- val context: Context get() = getApplication()
- override var type by enumPreference(context, R.string.pref_album_sort_type, AlbumSortType.CREATE_DATE)
- override var isDescending by Preference(context, R.string.pref_album_sort_descending, true)
- override val typeFlow = context.sharedPreferences.enumFlow(context.getString(R.string.pref_album_sort_type), AlbumSortType.CREATE_DATE)
- override val isDescendingFlow = context.sharedPreferences.booleanFlow(context.getString(R.string.pref_album_sort_descending), true)
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/models/sortInfo/ArtistSortInfoPreference.kt b/app/src/main/java/com/zionhuang/music/models/sortInfo/ArtistSortInfoPreference.kt
deleted file mode 100644
index cae0327d1..000000000
--- a/app/src/main/java/com/zionhuang/music/models/sortInfo/ArtistSortInfoPreference.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package com.zionhuang.music.models.sortInfo
-
-import android.content.Context
-import com.zionhuang.music.R
-import com.zionhuang.music.extensions.booleanFlow
-import com.zionhuang.music.extensions.enumFlow
-import com.zionhuang.music.extensions.getApplication
-import com.zionhuang.music.extensions.sharedPreferences
-import com.zionhuang.music.utils.preference.Preference
-import com.zionhuang.music.utils.preference.enumPreference
-
-object ArtistSortInfoPreference : SortInfoPreference() {
- val context: Context get() = getApplication()
- override var type: ArtistSortType by enumPreference(context, R.string.pref_artist_sort_type, ArtistSortType.CREATE_DATE)
- override var isDescending by Preference(context, R.string.pref_artist_sort_descending, true)
- override val typeFlow = context.sharedPreferences.enumFlow(context.getString(R.string.pref_artist_sort_type), ArtistSortType.CREATE_DATE)
- override val isDescendingFlow = context.sharedPreferences.booleanFlow(context.getString(R.string.pref_artist_sort_descending), true)
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/models/sortInfo/ISortInfo.kt b/app/src/main/java/com/zionhuang/music/models/sortInfo/ISortInfo.kt
deleted file mode 100644
index fb5f55143..000000000
--- a/app/src/main/java/com/zionhuang/music/models/sortInfo/ISortInfo.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package com.zionhuang.music.models.sortInfo
-
-interface ISortInfo {
- val type: T
- val isDescending: Boolean
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/models/sortInfo/PlaylistSortInfoPreference.kt b/app/src/main/java/com/zionhuang/music/models/sortInfo/PlaylistSortInfoPreference.kt
deleted file mode 100644
index b4bf67405..000000000
--- a/app/src/main/java/com/zionhuang/music/models/sortInfo/PlaylistSortInfoPreference.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package com.zionhuang.music.models.sortInfo
-
-import android.content.Context
-import com.zionhuang.music.R
-import com.zionhuang.music.extensions.booleanFlow
-import com.zionhuang.music.extensions.enumFlow
-import com.zionhuang.music.extensions.getApplication
-import com.zionhuang.music.extensions.sharedPreferences
-import com.zionhuang.music.utils.preference.Preference
-import com.zionhuang.music.utils.preference.enumPreference
-
-object PlaylistSortInfoPreference : SortInfoPreference() {
- val context: Context get() = getApplication()
- override var type by enumPreference(context, R.string.pref_playlist_sort_type, PlaylistSortType.CREATE_DATE)
- override var isDescending by Preference(context, R.string.pref_playlist_sort_descending, true)
- override val typeFlow = context.sharedPreferences.enumFlow(context.getString(R.string.pref_playlist_sort_type), PlaylistSortType.CREATE_DATE)
- override val isDescendingFlow = context.sharedPreferences.booleanFlow(context.getString(R.string.pref_playlist_sort_descending), true)
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/models/sortInfo/SongSortInfoPreference.kt b/app/src/main/java/com/zionhuang/music/models/sortInfo/SongSortInfoPreference.kt
deleted file mode 100644
index 038ed011d..000000000
--- a/app/src/main/java/com/zionhuang/music/models/sortInfo/SongSortInfoPreference.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package com.zionhuang.music.models.sortInfo
-
-import android.content.Context
-import com.zionhuang.music.R
-import com.zionhuang.music.extensions.booleanFlow
-import com.zionhuang.music.extensions.enumFlow
-import com.zionhuang.music.extensions.getApplication
-import com.zionhuang.music.extensions.sharedPreferences
-import com.zionhuang.music.utils.preference.Preference
-import com.zionhuang.music.utils.preference.enumPreference
-
-object SongSortInfoPreference : SortInfoPreference() {
- val context: Context get() = getApplication()
- override var type by enumPreference(context, R.string.pref_song_sort_type, SongSortType.CREATE_DATE)
- override var isDescending by Preference(context, R.string.pref_song_sort_descending, true)
- override val typeFlow = context.sharedPreferences.enumFlow(context.getString(R.string.pref_song_sort_type), SongSortType.CREATE_DATE)
- override val isDescendingFlow = context.sharedPreferences.booleanFlow(context.getString(R.string.pref_song_sort_descending), true)
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfo.kt b/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfo.kt
deleted file mode 100644
index aa85cbe50..000000000
--- a/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfo.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package com.zionhuang.music.models.sortInfo
-
-data class SortInfo(
- override val type: T,
- override val isDescending: Boolean,
-) : ISortInfo
-
-interface SortType
-
-enum class SongSortType : SortType {
- CREATE_DATE, NAME, ARTIST, PLAY_TIME
-}
-
-enum class ArtistSortType : SortType {
- CREATE_DATE, NAME, SONG_COUNT
-}
-
-enum class AlbumSortType : SortType {
- CREATE_DATE, NAME, ARTIST, YEAR, SONG_COUNT, LENGTH
-}
-
-enum class PlaylistSortType : SortType {
- CREATE_DATE, NAME, SONG_COUNT
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfoPreference.kt b/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfoPreference.kt
deleted file mode 100644
index 1c0655271..000000000
--- a/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfoPreference.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package com.zionhuang.music.models.sortInfo
-
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.combine
-
-abstract class SortInfoPreference : ISortInfo {
- abstract override var type: T
- abstract override var isDescending: Boolean
- protected abstract val typeFlow: Flow
- protected abstract val isDescendingFlow: Flow
-
- fun toggleIsDescending() {
- isDescending = !isDescending
- }
-
- val currentInfo: SortInfo
- get() = SortInfo(type, isDescending)
-
- val flow: Flow>
- get() = typeFlow.combine(isDescendingFlow) { type, isDescending ->
- SortInfo(type, isDescending)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/playback/BitmapProvider.kt b/app/src/main/java/com/zionhuang/music/playback/BitmapProvider.kt
index 7e9f5fd07..6043651de 100644
--- a/app/src/main/java/com/zionhuang/music/playback/BitmapProvider.kt
+++ b/app/src/main/java/com/zionhuang/music/playback/BitmapProvider.kt
@@ -9,27 +9,52 @@ import coil.request.Disposable
import coil.request.ImageRequest
class BitmapProvider(private val context: Context) {
+ var currentUrl: String? = null
+ var currentBitmap: Bitmap? = null
private val map = LruCache(MAX_CACHE_SIZE)
-
private var disposable: Disposable? = null
+ var onBitmapChanged: (Bitmap?) -> Unit = {}
+ set(value) {
+ field = value
+ value(currentBitmap)
+ }
fun load(url: String, callback: (Bitmap) -> Unit): Bitmap? {
- val cache = map.get(url)
+ if (url == currentUrl) return map.get(url)
+ currentUrl = url
disposable?.dispose()
+ val cache = map.get(url)
if (cache == null) {
- disposable = context.imageLoader.enqueue(ImageRequest.Builder(context)
- .data(url)
- .target(onSuccess = { drawable ->
- val bitmap = (drawable as BitmapDrawable).bitmap
- map.put(url, bitmap)
- callback(bitmap)
- })
- .build())
+ disposable = context.imageLoader.enqueue(
+ ImageRequest.Builder(context)
+ .data(url)
+ .allowHardware(false)
+ .target(
+ onSuccess = { drawable ->
+ val bitmap = (drawable as BitmapDrawable).bitmap
+ map.put(url, bitmap)
+ callback(bitmap)
+ currentBitmap = bitmap
+ onBitmapChanged(bitmap)
+ }
+ )
+ .build()
+ )
+ } else {
+ currentBitmap = cache
+ onBitmapChanged(cache)
}
return cache
}
+ fun clear() {
+ disposable?.dispose()
+ currentUrl = null
+ currentBitmap = null
+ onBitmapChanged(null)
+ }
+
companion object {
- const val MAX_CACHE_SIZE = 10
+ const val MAX_CACHE_SIZE = 15
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/zionhuang/music/playback/DownloadUtil.kt b/app/src/main/java/com/zionhuang/music/playback/DownloadUtil.kt
new file mode 100644
index 000000000..1e90742b0
--- /dev/null
+++ b/app/src/main/java/com/zionhuang/music/playback/DownloadUtil.kt
@@ -0,0 +1,148 @@
+package com.zionhuang.music.playback
+
+import android.content.Context
+import android.net.ConnectivityManager
+import androidx.core.content.getSystemService
+import androidx.core.net.toUri
+import androidx.media3.common.PlaybackException
+import androidx.media3.database.DatabaseProvider
+import androidx.media3.datasource.ResolvingDataSource
+import androidx.media3.datasource.cache.CacheDataSource
+import androidx.media3.datasource.cache.SimpleCache
+import androidx.media3.datasource.okhttp.OkHttpDataSource
+import androidx.media3.exoplayer.offline.Download
+import androidx.media3.exoplayer.offline.DownloadManager
+import androidx.media3.exoplayer.offline.DownloadNotificationHelper
+import com.zionhuang.innertube.YouTube
+import com.zionhuang.music.constants.AudioQuality
+import com.zionhuang.music.constants.AudioQualityKey
+import com.zionhuang.music.db.MusicDatabase
+import com.zionhuang.music.db.entities.FormatEntity
+import com.zionhuang.music.di.DownloadCache
+import com.zionhuang.music.di.PlayerCache
+import com.zionhuang.music.utils.enumPreference
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.runBlocking
+import okhttp3.OkHttpClient
+import java.util.concurrent.Executor
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class DownloadUtil @Inject constructor(
+ @ApplicationContext context: Context,
+ val database: MusicDatabase,
+ val databaseProvider: DatabaseProvider,
+ @DownloadCache val downloadCache: SimpleCache,
+ @PlayerCache val playerCache: SimpleCache,
+) {
+ private val connectivityManager = context.getSystemService()!!
+ private val audioQuality by enumPreference(context, AudioQualityKey, AudioQuality.AUTO)
+ private val songUrlCache = HashMap>()
+ private val dataSourceFactory = ResolvingDataSource.Factory(
+ CacheDataSource.Factory()
+ .setCache(playerCache)
+ .setUpstreamDataSourceFactory(
+ OkHttpDataSource.Factory(
+ OkHttpClient.Builder()
+ .proxy(YouTube.proxy)
+ .build()
+ )
+ )
+ ) { dataSpec ->
+ val mediaId = dataSpec.key ?: error("No media id")
+ val length = if (dataSpec.length >= 0) dataSpec.length else 1
+
+ if (playerCache.isCached(mediaId, dataSpec.position, length)) {
+ return@Factory dataSpec
+ }
+
+ songUrlCache[mediaId]?.takeIf { it.second < System.currentTimeMillis() }?.let {
+ return@Factory dataSpec.withUri(it.first.toUri())
+ }
+
+ val playedFormat = runBlocking(Dispatchers.IO) { database.format(mediaId).first() }
+ val playerResponse = runBlocking(Dispatchers.IO) {
+ YouTube.player(mediaId)
+ }.getOrThrow()
+ if (playerResponse.playabilityStatus.status != "OK") {
+ throw PlaybackException(playerResponse.playabilityStatus.reason, null, PlaybackException.ERROR_CODE_REMOTE_ERROR)
+ }
+
+ val format =
+ if (playedFormat != null) {
+ playerResponse.streamingData?.adaptiveFormats?.find { it.itag == playedFormat.itag }
+ } else {
+ playerResponse.streamingData?.adaptiveFormats
+ ?.filter { it.isAudio }
+ ?.maxByOrNull {
+ it.bitrate * when (audioQuality) {
+ AudioQuality.AUTO -> if (connectivityManager.isActiveNetworkMetered) -1 else 1
+ AudioQuality.HIGH -> 1
+ AudioQuality.LOW -> -1
+ } + (if (it.mimeType.startsWith("audio/webm")) 10240 else 0) // prefer opus stream
+ }
+ }!!.let {
+ // Specify range to avoid YouTube's throttling
+ it.copy(url = "${it.url}&range=0-${it.contentLength ?: 10000000}")
+ }
+
+ database.query {
+ upsert(
+ FormatEntity(
+ id = mediaId,
+ itag = format.itag,
+ mimeType = format.mimeType.split(";")[0],
+ codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""),
+ bitrate = format.bitrate,
+ sampleRate = format.audioSampleRate,
+ contentLength = format.contentLength!!,
+ loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb
+ )
+ )
+ }
+
+ songUrlCache[mediaId] = format.url!! to playerResponse.streamingData!!.expiresInSeconds * 1000L
+ dataSpec.withUri(format.url!!.toUri())
+ }
+ val downloadNotificationHelper = DownloadNotificationHelper(context, ExoDownloadService.CHANNEL_ID)
+ val downloadManager: DownloadManager = DownloadManager(context, databaseProvider, downloadCache, dataSourceFactory, Executor(Runnable::run)).apply {
+ maxParallelDownloads = 3
+ addListener(
+ ExoDownloadService.TerminalStateNotificationHelper(
+ context = context,
+ notificationHelper = downloadNotificationHelper,
+ nextNotificationId = ExoDownloadService.NOTIFICATION_ID + 1
+ )
+ )
+ }
+ val downloads = MutableStateFlow