diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt index b7228d508..c0b901c3b 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -29,18 +29,26 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.widget.Toolbar import androidx.core.view.updatePadding import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback import dagger.hilt.android.AndroidEntryPoint +import java.lang.reflect.Field +import kotlin.math.abs import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.playback.pager.PlaybackPageListener +import org.oxycblt.auxio.playback.pager.PlaybackPagerAdapter +import org.oxycblt.auxio.playback.queue.QueueViewModel import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.ui.StyledSeekBar import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.lazyReflectedField import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.overrideOnOverflowMenuClick import org.oxycblt.auxio.util.showToast @@ -58,11 +66,14 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat class PlaybackPanelFragment : ViewBindingFragment(), Toolbar.OnMenuItemClickListener, - StyledSeekBar.Listener { + StyledSeekBar.Listener, + PlaybackPageListener { private val playbackModel: PlaybackViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels() + private val queueModel: QueueViewModel by activityViewModels() private val listModel: ListViewModel by activityViewModels() private var equalizerLauncher: ActivityResultLauncher? = null + private var coverAdapter: PlaybackPagerAdapter? = null override fun onCreateBinding(inflater: LayoutInflater) = FragmentPlaybackPanelBinding.inflate(inflater) @@ -99,11 +110,19 @@ class PlaybackPanelFragment : } } + // cover carousel adapter + coverAdapter = PlaybackPagerAdapter(this, viewLifecycleOwner) + binding.playbackCoverPager.apply { + adapter = coverAdapter + registerOnPageChangeCallback(OnCoverChangedCallback(queueModel)) + val recycler = VP_RECYCLER_FIELD.get(this@apply) as RecyclerView + recycler.isNestedScrollingEnabled = false + } // Set up marquee on song information, alongside click handlers that navigate to each // respective item. binding.playbackSong.apply { isSelected = true - setOnClickListener { playbackModel.song.value?.let(detailModel::showAlbum) } + setOnClickListener { navigateToCurrentSong() } } binding.playbackArtist.apply { isSelected = true @@ -131,15 +150,14 @@ class PlaybackPanelFragment : collectImmediately(playbackModel.repeatMode, ::updateRepeat) collectImmediately(playbackModel.isPlaying, ::updatePlaying) collectImmediately(playbackModel.isShuffled, ::updateShuffled) + collectImmediately(queueModel.queue, ::updateQueue) + collectImmediately(queueModel.index, ::updateQueuePosition) } override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) { equalizerLauncher = null + coverAdapter = null binding.playbackToolbar.setOnMenuItemClickListener(null) - // Marquee elements leak if they are not disabled when the views are destroyed. - binding.playbackSong.isSelected = false - binding.playbackArtist.isSelected = false - binding.playbackAlbum.isSelected = false } override fun onMenuItemClick(item: MenuItem): Boolean { @@ -170,6 +188,18 @@ class PlaybackPanelFragment : playbackModel.seekTo(positionDs) } + private fun updateQueue(queue: List) { + coverAdapter?.update(queue, queueModel.queueInstructions.flow.value) + } + + private fun updateQueuePosition(position: Int) { + val pager = requireBinding().playbackCoverPager + val distance = abs(pager.currentItem - position) + if (distance != 0) { + pager.setCurrentItem(position, distance == 1) + } + } + private fun updateSong(song: Song?) { if (song == null) { // Nothing to do. @@ -177,12 +207,7 @@ class PlaybackPanelFragment : } val binding = requireBinding() - val context = requireContext() logD("Updating song display: $song") - binding.playbackCover.bind(song) - binding.playbackSong.text = song.name.resolve(context) - binding.playbackArtist.text = song.artists.resolveNames(context) - binding.playbackAlbum.text = song.album.name.resolve(context) binding.playbackSeekBar.durationDs = song.durationMs.msToDs() } @@ -212,11 +237,43 @@ class PlaybackPanelFragment : requireBinding().playbackShuffle.isActivated = isShuffled } - private fun navigateToCurrentArtist() { + override fun navigateToCurrentSong() { + playbackModel.song.value?.let(detailModel::showAlbum) + } + + override fun navigateToCurrentArtist() { playbackModel.song.value?.let(detailModel::showArtist) } - private fun navigateToCurrentAlbum() { + override fun navigateToCurrentAlbum() { playbackModel.song.value?.let { detailModel.showAlbum(it.album) } } + + override fun navigateToMenu() { + binding?.playbackToolbar?.showOverflowMenu() + } + + private class OnCoverChangedCallback(private val viewModel: QueueViewModel) : + OnPageChangeCallback() { + + private var targetPosition = RecyclerView.NO_POSITION + + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + targetPosition = position + } + + override fun onPageScrollStateChanged(state: Int) { + super.onPageScrollStateChanged(state) + if (state == ViewPager2.SCROLL_STATE_IDLE && + targetPosition != RecyclerView.NO_POSITION && + targetPosition != viewModel.index.value) { + viewModel.goto(targetPosition, playIfPaused = false) + } + } + } + + private companion object { + val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView") + } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/pager/PlaybackPageListener.kt b/app/src/main/java/org/oxycblt/auxio/playback/pager/PlaybackPageListener.kt new file mode 100644 index 000000000..a47c88494 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/pager/PlaybackPageListener.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 Auxio Project + * PlaybackPageListener.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.playback.pager + +interface PlaybackPageListener { + + fun navigateToCurrentArtist() + + fun navigateToCurrentAlbum() + + fun navigateToCurrentSong() + + fun navigateToMenu() +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/pager/PlaybackPagerAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/pager/PlaybackPagerAdapter.kt new file mode 100644 index 000000000..f171677cc --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/pager/PlaybackPagerAdapter.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2023 Auxio Project + * PlaybackPagerAdapter.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.playback.pager + +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import kotlin.jvm.internal.Intrinsics +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.ItemPlaybackSongBinding +import org.oxycblt.auxio.list.adapter.FlexibleListAdapter +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.util.inflater + +class PlaybackPagerAdapter( + private val listener: PlaybackPageListener, + private val lifecycleOwner: LifecycleOwner +) : FlexibleListAdapter(CoverViewHolder.DIFF_CALLBACK) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CoverViewHolder { + return CoverViewHolder.from(parent, listener).also { + lifecycleOwner.lifecycle.addObserver(it) + } + } + + override fun onBindViewHolder(holder: CoverViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + override fun onViewRecycled(holder: CoverViewHolder) { + holder.recycle() + super.onViewRecycled(holder) + } +} + +class CoverViewHolder +private constructor( + private val binding: ItemPlaybackSongBinding, + private val listener: PlaybackPageListener +) : RecyclerView.ViewHolder(binding.root), DefaultLifecycleObserver, View.OnClickListener { + + init { + binding.playbackSong.setOnClickListener(this) + binding.playbackArtist.setOnClickListener(this) + binding.playbackAlbum.setOnClickListener(this) + binding.playbackCover.setOnClickListener(this) + } + + override fun onClick(v: View) { + when (v.id) { + R.id.playback_album -> listener.navigateToCurrentAlbum() + R.id.playback_artist -> listener.navigateToCurrentArtist() + R.id.playback_song -> listener.navigateToCurrentSong() + R.id.playback_cover -> listener.navigateToMenu() + } + } + + override fun onResume(owner: LifecycleOwner) { + super.onResume(owner) + setSelected(true) + } + + override fun onPause(owner: LifecycleOwner) { + super.onPause(owner) + setSelected(false) + } + + override fun onDestroy(owner: LifecycleOwner) { + super.onDestroy(owner) + owner.lifecycle.removeObserver(this) + } + + /** + * Bind new data to this instance. + * + * @param item The new [Song] to bind. + */ + fun bind(item: Song) { + binding.playbackCover.bind(item) + val context = binding.root.context + binding.playbackSong.text = item.name.resolve(context) + binding.playbackArtist.text = item.artists.resolveNames(context) + binding.playbackAlbum.text = item.album.name.resolve(context) + setSelected(true) + } + + fun recycle() { + // Marquee elements leak if they are not disabled when the views are destroyed. + setSelected(false) + } + + private fun setSelected(value: Boolean) { + binding.playbackSong.isSelected = value + binding.playbackArtist.isSelected = value + binding.playbackAlbum.isSelected = value + } + + companion object { + /** + * Create a new instance. + * + * @param parent The parent to inflate this instance from. + * @return A new instance. + */ + fun from(parent: ViewGroup, listener: PlaybackPageListener) = + CoverViewHolder( + ItemPlaybackSongBinding.inflate(parent.context.inflater, parent, false), + listener + ) + + /** A comparator that can be used with DiffUtil. */ + val DIFF_CALLBACK = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Song, newItem: Song) = + oldItem.uid == newItem.uid + + override fun areContentsTheSame(oldItem: Song, newItem: Song): Boolean { + return Intrinsics.areEqual(oldItem, newItem) + } + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt index 2db007971..ca3a924c4 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt @@ -88,7 +88,7 @@ class QueueFragment : ViewBindingFragment(), EditClickList } override fun onClick(item: Song, viewHolder: RecyclerView.ViewHolder) { - queueModel.goto(viewHolder.bindingAdapterPosition) + queueModel.goto(viewHolder.bindingAdapterPosition, playIfPaused = true) } override fun onPickUp(viewHolder: RecyclerView.ViewHolder) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt index 5b1edce73..12a51bbf7 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt @@ -106,13 +106,14 @@ class QueueViewModel @Inject constructor(private val playbackManager: PlaybackSt * * @param adapterIndex The index of the queue item to play. Does nothing if the index is out of * range. + * @param playIfPaused Start playing after switching even if it currently is paused */ - fun goto(adapterIndex: Int) { + fun goto(adapterIndex: Int, playIfPaused: Boolean) { if (adapterIndex !in queue.value.indices) { return } logD("Going to position $adapterIndex in queue") - playbackManager.goto(adapterIndex) + playbackManager.goto(adapterIndex, playIfPaused || playbackManager.playerState.isPlaying) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index 870d7a84e..7071f5111 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -120,8 +120,9 @@ interface PlaybackStateManager { * Play a [Song] at the given position in the queue. * * @param index The position of the [Song] in the queue to start playing. + * @param play Whether to start playing after switching to target index */ - fun goto(index: Int) + fun goto(index: Int, play: Boolean) /** * Add [Song]s to the top of the queue. @@ -429,12 +430,12 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { } @Synchronized - override fun goto(index: Int) { + override fun goto(index: Int, play: Boolean) { val internalPlayer = internalPlayer ?: return if (queue.goto(index)) { logD("Moving to $index") notifyIndexMoved() - internalPlayer.loadSong(queue.currentSong, true) + internalPlayer.loadSong(queue.currentSong, play) } else { logW("$index was not in bounds, could not move to it") } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt index 1910b1a01..efb266eab 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt @@ -256,7 +256,7 @@ constructor( } override fun onSkipToQueueItem(id: Long) { - playbackManager.goto(id.toInt()) + playbackManager.goto(id.toInt(), true) } override fun onCustomAction(action: String?, extras: Bundle?) { diff --git a/app/src/main/res/layout-h480dp/fragment_playback_panel.xml b/app/src/main/res/layout-h480dp/fragment_playback_panel.xml index 263bf2a7d..28f69b2b2 100644 --- a/app/src/main/res/layout-h480dp/fragment_playback_panel.xml +++ b/app/src/main/res/layout-h480dp/fragment_playback_panel.xml @@ -16,54 +16,14 @@ app:title="@string/lbl_playback" tools:subtitle="@string/lbl_all_songs" /> - - - - - - - - + app:layout_constraintTop_toBottomOf="@+id/playback_toolbar" /> + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml index b46562fa2..b7ead10f6 100644 --- a/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml +++ b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml @@ -16,54 +16,14 @@ app:title="@string/lbl_playback" tools:subtitle="@string/lbl_all_songs" /> - - - - - - - - + app:layout_constraintTop_toBottomOf="@+id/playback_toolbar" /> + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_playback_panel.xml b/app/src/main/res/layout/fragment_playback_panel.xml index 74f106903..e873722e1 100644 --- a/app/src/main/res/layout/fragment_playback_panel.xml +++ b/app/src/main/res/layout/fragment_playback_panel.xml @@ -16,64 +16,22 @@ app:title="@string/lbl_playback" tools:subtitle="@string/lbl_all_songs" /> - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/item_playback_song.xml b/app/src/main/res/layout/item_playback_song.xml new file mode 100644 index 000000000..3e8c0c6a1 --- /dev/null +++ b/app/src/main/res/layout/item_playback_song.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file