diff --git a/app/src/main/java/org/akanework/gramophone/ui/components/PlayerBottomSheet.kt b/app/src/main/java/org/akanework/gramophone/ui/components/PlayerBottomSheet.kt index aa475a21b..8fcbe4953 100644 --- a/app/src/main/java/org/akanework/gramophone/ui/components/PlayerBottomSheet.kt +++ b/app/src/main/java/org/akanework/gramophone/ui/components/PlayerBottomSheet.kt @@ -29,48 +29,31 @@ import androidx.media3.common.util.Log import android.view.View import android.view.WindowInsets import android.widget.FrameLayout -import android.widget.ImageView -import android.widget.TextView import androidx.activity.BackEventCompat import androidx.activity.OnBackPressedCallback -import androidx.appcompat.content.res.AppCompatResources import androidx.core.graphics.Insets -import androidx.core.view.HapticFeedbackConstantsCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.doOnLayout import androidx.core.view.doOnNextLayout import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.fragment.app.findFragment import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.session.MediaController import androidx.preference.PreferenceManager -import coil3.asDrawable -import coil3.imageLoader -import coil3.request.Disposable -import coil3.request.ImageRequest -import coil3.request.error -import coil3.size.Scale import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback -import com.google.android.material.button.MaterialButton import com.google.android.material.motion.MaterialBottomContainerBackHelper import org.akanework.gramophone.BuildConfig import org.akanework.gramophone.R import org.akanework.gramophone.logic.clone -import org.akanework.gramophone.logic.fadInAnimation import org.akanework.gramophone.logic.fadOutAnimation import org.akanework.gramophone.logic.getBooleanStrict -import org.akanework.gramophone.logic.playOrPause -import org.akanework.gramophone.logic.startAnimation import org.akanework.gramophone.logic.ui.MyBottomSheetBehavior import org.akanework.gramophone.ui.MainActivity - class PlayerBottomSheet private constructor( context: Context, attributeSet: AttributeSet?, defStyleAttr: Int, defStyleRes: Int ) : FrameLayout(context, attributeSet, defStyleAttr, defStyleRes), @@ -87,7 +70,7 @@ class PlayerBottomSheet private constructor( private var lyricsBackHelper: MaterialBottomContainerBackHelper? = null private var bottomSheetBackCallback: OnBackPressedCallback? = null val fullPlayer: FullBottomSheet - private val previewPlayer: View + val previewPlayer: View private val activity get() = context as MainActivity @@ -131,7 +114,8 @@ class PlayerBottomSheet private constructor( } } - activity.controllerViewModel.addRecreationalPlayerListener(activity.lifecycle, this) { + activity.controllerViewModel.addControllerCallback(activity.lifecycle) { _, _ -> + instance?.addListener(this@PlayerBottomSheet) onMediaItemTransition( instance?.currentMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED diff --git a/app/src/main/java/org/akanework/gramophone/ui/components/PreviewBottomSheet.kt b/app/src/main/java/org/akanework/gramophone/ui/components/PreviewBottomSheet.kt index 01fcf4949..4f9545cd7 100644 --- a/app/src/main/java/org/akanework/gramophone/ui/components/PreviewBottomSheet.kt +++ b/app/src/main/java/org/akanework/gramophone/ui/components/PreviewBottomSheet.kt @@ -2,8 +2,9 @@ package org.akanework.gramophone.ui.components import android.content.Context import android.util.AttributeSet +import android.view.MotionEvent import android.widget.ImageView -import android.widget.TextView +import android.view.View import androidx.appcompat.content.res.AppCompatResources import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.HapticFeedbackConstantsCompat @@ -11,6 +12,7 @@ import androidx.core.view.ViewCompat import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.session.MediaController +import androidx.viewpager2.widget.ViewPager2 import coil3.asDrawable import coil3.imageLoader import coil3.request.Disposable @@ -25,93 +27,173 @@ import org.akanework.gramophone.logic.startAnimation import org.akanework.gramophone.ui.MainActivity class PreviewBottomSheet(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : - ConstraintLayout(context, attrs, defStyleAttr, defStyleRes), Player.Listener { - private val activity - get() = context as MainActivity - private val instance: MediaController? - get() = activity.getPlayer() - private val bottomSheetPreviewCover: ImageView - private val bottomSheetPreviewTitle: TextView - private val bottomSheetPreviewSubtitle: TextView - private val bottomSheetPreviewControllerButton: MaterialButton - private val bottomSheetPreviewNextButton: MaterialButton - private var lastDisposable: Disposable? = null - - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : - this(context, attrs, defStyleAttr, 0) - - constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) - - init { - inflate(context, R.layout.preview_player, this) - bottomSheetPreviewTitle = findViewById(R.id.preview_song_name) - bottomSheetPreviewSubtitle = findViewById(R.id.preview_artist_name) - bottomSheetPreviewCover = findViewById(R.id.preview_album_cover) - bottomSheetPreviewControllerButton = findViewById(R.id.preview_control) - bottomSheetPreviewNextButton = findViewById(R.id.preview_next) - - bottomSheetPreviewControllerButton.setOnClickListener { - ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK) - instance?.playOrPause() - } - - bottomSheetPreviewNextButton.setOnClickListener { - ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK) - instance?.seekToNext() - } - - activity.controllerViewModel.addRecreationalPlayerListener(activity.lifecycle, this) { - onPlaybackStateChanged(instance?.playbackState ?: Player.STATE_IDLE) - onMediaItemTransition( - instance?.currentMediaItem, - Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED - ) - } - } - - override fun onIsPlayingChanged(isPlaying: Boolean) { - onPlaybackStateChanged(instance?.playbackState ?: Player.STATE_IDLE) - } - - override fun onPlaybackStateChanged(playbackState: Int) { - if (playbackState == Player.STATE_BUFFERING) return - val myTag = bottomSheetPreviewControllerButton.getTag(R.id.play_next) as Int? - if (instance?.isPlaying == true && myTag != 1) { - bottomSheetPreviewControllerButton.icon = - AppCompatResources.getDrawable(context, R.drawable.play_anim) - bottomSheetPreviewControllerButton.icon.startAnimation() - bottomSheetPreviewControllerButton.setTag(R.id.play_next, 1) - } else if (instance?.isPlaying == false && myTag != 2) { - bottomSheetPreviewControllerButton.icon = - AppCompatResources.getDrawable(context, R.drawable.pause_anim) - bottomSheetPreviewControllerButton.icon.startAnimation() - bottomSheetPreviewControllerButton.setTag(R.id.play_next, 2) - } - } - - override fun onMediaItemTransition( - mediaItem: MediaItem?, - reason: @Player.MediaItemTransitionReason Int - ) { - if ((instance?.mediaItemCount ?: 0) > 0) { - lastDisposable?.dispose() - lastDisposable = context.imageLoader.enqueue(ImageRequest.Builder(context).apply { - target(onSuccess = { - bottomSheetPreviewCover.setImageDrawable(it.asDrawable(context.resources)) - }, onError = { - bottomSheetPreviewCover.setImageDrawable(it?.asDrawable(context.resources)) - }) // do not react to onStart() which sets placeholder - data(mediaItem?.mediaMetadata?.artworkUri) - scale(Scale.FILL) - allowHardware(bottomSheetPreviewCover.isHardwareAccelerated) - error(R.drawable.ic_default_cover) - }.build()) - bottomSheetPreviewTitle.text = mediaItem?.mediaMetadata?.title - bottomSheetPreviewSubtitle.text = - mediaItem?.mediaMetadata?.artist ?: context.getString(R.string.unknown_artist) - } else { - lastDisposable?.dispose() - lastDisposable = null - } - } + ConstraintLayout(context, attrs, defStyleAttr, defStyleRes), Player.Listener { + private val activity + get() = context as MainActivity + private val instance: MediaController? + get() = activity.getPlayer() + private val bottomSheetPreviewCover: ImageView + private val bottomSheetPreviewControllerButton: MaterialButton + private val bottomSheetPreviewNextButton: MaterialButton + private val pager: ViewPager2 + private var pagerAllowLeftSwipe = true + private var pagerAllowRightSwipe = true + private val adapter: PreviewPlayerPagerAdapter + private var titles = listOf("", "", "") + private var artists = listOf("", "", "") + private var lastDisposable: Disposable? = null + private var userScrollInProgress = false + + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : + this(context, attrs, defStyleAttr, 0) + + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + + init { + inflate(context, R.layout.preview_player, this) + bottomSheetPreviewCover = findViewById(R.id.preview_album_cover) + bottomSheetPreviewControllerButton = findViewById(R.id.preview_control) + bottomSheetPreviewNextButton = findViewById(R.id.preview_next) + bottomSheetPreviewControllerButton.setOnClickListener { + ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK) + instance?.playOrPause() + } + + bottomSheetPreviewNextButton.setOnClickListener { + ViewCompat.performHapticFeedback(it, HapticFeedbackConstantsCompat.CONTEXT_CLICK) + instance?.seekToNext() + } + + pager = findViewById(R.id.preview_player_pager) + adapter = PreviewPlayerPagerAdapter(context) + pager.adapter = adapter + updatePages() + pager.setCurrentItem(1, false) + + pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageScrollStateChanged(state: Int) { + super.onPageScrollStateChanged(state) + when (state) { + ViewPager2.SCROLL_STATE_DRAGGING -> userScrollInProgress = true + ViewPager2.SCROLL_STATE_IDLE -> { + if (userScrollInProgress) { + userScrollInProgress = false + handlePageSettled() + } + } + } + } + }) + + pager.getChildAt(0).setOnTouchListener(object: View.OnTouchListener { + var initX = 0f + + override fun onTouch(v: View, event: MotionEvent): Boolean { + when (event.action) { + MotionEvent.ACTION_DOWN -> initX = event.x + MotionEvent.ACTION_MOVE -> { + val diff = event.x - initX + if (diff > 0 && !pagerAllowLeftSwipe) return true + if (diff < 0 && !pagerAllowRightSwipe) return true + } + } + return false + } + }) + + activity.controllerViewModel.addControllerCallback(activity.lifecycle) { _, _ -> + instance?.addListener(this@PreviewBottomSheet) + onPlaybackStateChanged(instance?.playbackState ?: Player.STATE_IDLE) + onMediaItemTransition( + instance?.currentMediaItem, + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED + ) + } + } + + private fun handlePageSettled() { + when (pager.currentItem) { + 0 -> { + if (instance?.hasPreviousMediaItem() == true){ + ViewCompat.performHapticFeedback(activity.playerBottomSheet.previewPlayer, HapticFeedbackConstantsCompat.CONTEXT_CLICK) + instance?.seekToPrevious() + } + } + 2 -> { + if (instance?.hasNextMediaItem() == true){ + ViewCompat.performHapticFeedback(activity.playerBottomSheet.previewPlayer, HapticFeedbackConstantsCompat.CONTEXT_CLICK) + instance?.seekToNext() + } + } + else -> return + } + pager.setCurrentItem(1, false) + updatePages() + } + + private fun updatePages() { + adapter.updateData( + titles, + artists + ) + + pagerAllowRightSwipe = (instance?.hasNextMediaItem() == true) + pagerAllowLeftSwipe = (instance?.hasPreviousMediaItem() == true) + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + onPlaybackStateChanged(instance?.playbackState ?: Player.STATE_IDLE) + } + + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_BUFFERING) return + val myTag = bottomSheetPreviewControllerButton.getTag(R.id.play_next) as Int? + if (instance?.isPlaying == true && myTag != 1) { + bottomSheetPreviewControllerButton.icon = + AppCompatResources.getDrawable(context, R.drawable.play_anim) + bottomSheetPreviewControllerButton.icon.startAnimation() + bottomSheetPreviewControllerButton.setTag(R.id.play_next, 1) + } else if (instance?.isPlaying == false && myTag != 2) { + bottomSheetPreviewControllerButton.icon = + AppCompatResources.getDrawable(context, R.drawable.pause_anim) + bottomSheetPreviewControllerButton.icon.startAnimation() + bottomSheetPreviewControllerButton.setTag(R.id.play_next, 2) + } + } + + override fun onMediaItemTransition( + mediaItem: MediaItem?, + reason: @Player.MediaItemTransitionReason Int + ) { + if ((instance?.mediaItemCount ?: 0) > 0) { + lastDisposable?.dispose() + lastDisposable = context.imageLoader.enqueue(ImageRequest.Builder(context).apply { + target(onSuccess = { + bottomSheetPreviewCover.setImageDrawable(it.asDrawable(context.resources)) + }, onError = { + bottomSheetPreviewCover.setImageDrawable(it?.asDrawable(context.resources)) + }) // do not react to onStart() which sets placeholder + data(mediaItem?.mediaMetadata?.artworkUri) + scale(Scale.FILL) + allowHardware(bottomSheetPreviewCover.isHardwareAccelerated) + error(R.drawable.ic_default_cover) + }.build()) + val prevIndex = (instance?.currentMediaItemIndex?.minus(1) ?: 0).coerceIn(0, instance?.mediaItemCount) + val nextIndex = (instance?.currentMediaItemIndex?.plus(1) ?: 0).coerceIn(0, instance?.mediaItemCount) + titles = listOf( + instance?.getMediaItemAt(prevIndex)?.mediaMetadata?.title?.toString() ?: context.getString(R.string.unknown_title), + mediaItem?.mediaMetadata?.title?.toString() ?: context.getString(R.string.unknown_title), + instance?.getMediaItemAt(nextIndex)?.mediaMetadata?.title?.toString() ?: context.getString(R.string.unknown_title), + ) + artists = listOf( + instance?.getMediaItemAt(prevIndex)?.mediaMetadata?.artist?.toString() ?: context.getString(R.string.unknown_artist), + mediaItem?.mediaMetadata?.artist?.toString() ?: context.getString(R.string.unknown_artist), + instance?.getMediaItemAt(nextIndex)?.mediaMetadata?.artist?.toString() ?: context.getString(R.string.unknown_artist), + ) + updatePages() + } else { + lastDisposable?.dispose() + lastDisposable = null + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/akanework/gramophone/ui/components/PreviewPlayerPagerAdapter.kt b/app/src/main/java/org/akanework/gramophone/ui/components/PreviewPlayerPagerAdapter.kt new file mode 100644 index 000000000..e77ca2ebc --- /dev/null +++ b/app/src/main/java/org/akanework/gramophone/ui/components/PreviewPlayerPagerAdapter.kt @@ -0,0 +1,38 @@ +package org.akanework.gramophone.ui.components + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import org.akanework.gramophone.R + +class PreviewPlayerPagerAdapter (private val context: Context) : + RecyclerView.Adapter() { + + private var titles = listOf() + private var artists = listOf() + + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val title: MarqueeTextView = view.findViewById(R.id.preview_song_name) + val artist: MarqueeTextView = view.findViewById(R.id.preview_artist_name) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(context).inflate(R.layout.preview_player_item, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.title.text = titles[position] + holder.artist.text = artists[position] + } + + override fun getItemCount() = titles.size + + fun updateData(newTitles: List, newSubtitles: List) { + titles = newTitles + artists = newSubtitles + notifyDataSetChanged() + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/preview_player.xml b/app/src/main/res/layout/preview_player.xml index 2deeee06b..28e7bc0d2 100644 --- a/app/src/main/res/layout/preview_player.xml +++ b/app/src/main/res/layout/preview_player.xml @@ -1,105 +1,87 @@ + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout" + android:id="@+id/preview_player" + android:layout_width="match_parent" + android:layout_height="wrap_content"> - + - + - + - - + - + - - - - - - + \ No newline at end of file diff --git a/app/src/main/res/layout/preview_player_item.xml b/app/src/main/res/layout/preview_player_item.xml new file mode 100644 index 000000000..29d0c6a59 --- /dev/null +++ b/app/src/main/res/layout/preview_player_item.xml @@ -0,0 +1,28 @@ + + + + + + + + + + \ No newline at end of file diff --git a/media3 b/media3 index 22d2156be..afee2d12e 160000 --- a/media3 +++ b/media3 @@ -1 +1 @@ -Subproject commit 22d2156bec74542a0764bf0ec27c839cc70874ed +Subproject commit afee2d12e675ed7597a24e19e681ccd9f4f74b0e