diff --git a/app/src/main/kotlin/org/akanework/gramophone/logic/utils/LifecycleCallbackList.kt b/app/src/main/kotlin/org/akanework/gramophone/logic/utils/LifecycleCallbackList.kt index 106d41553..b56255935 100644 --- a/app/src/main/kotlin/org/akanework/gramophone/logic/utils/LifecycleCallbackList.kt +++ b/app/src/main/kotlin/org/akanework/gramophone/logic/utils/LifecycleCallbackList.kt @@ -21,6 +21,7 @@ class LifecycleCallbackListImpl(lifecycle: Lifecycle? = null) } override fun addCallback(lifecycle: Lifecycle?, clear: Boolean, callback: T) { + if (list.containsKey(callback)) throw IllegalArgumentException("cannot add same callback twice") list[callback] = Pair(clear, lifecycle?.let { CallbackLifecycleObserver(it, callback) }) } diff --git a/app/src/main/kotlin/org/akanework/gramophone/ui/MediaControllerViewModel.kt b/app/src/main/kotlin/org/akanework/gramophone/ui/MediaControllerViewModel.kt index 41566b4df..359788623 100644 --- a/app/src/main/kotlin/org/akanework/gramophone/ui/MediaControllerViewModel.kt +++ b/app/src/main/kotlin/org/akanework/gramophone/ui/MediaControllerViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner +import androidx.media3.common.Player import androidx.media3.session.MediaController import androidx.media3.session.SessionCommand import androidx.media3.session.SessionResult @@ -57,12 +58,13 @@ class MediaControllerViewModel(application: Application) : AndroidViewModel(appl } } - fun addOneOffControllerCallback(lifecycle: Lifecycle?, callback: (MediaController) -> Unit) { + fun addOneOffControllerCallback(lifecycle: Lifecycle?, clear: Boolean = true, callback: (MediaController) -> Unit) { val instance = get() + if (instance == null || !clear) { + connectionListeners.addCallback(lifecycle, clear, callback) + } if (instance != null) { callback(instance) - } else { - connectionListeners.addCallback(lifecycle, true, callback) } } @@ -113,4 +115,13 @@ class MediaControllerViewModel(application: Application) : AndroidViewModel(appl } return future } +} + +fun Player.registerLifecycleCallback(lifecycle: Lifecycle, callback: Player.Listener) { + addListener(callback) + lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + removeListener(callback) + } + }) } \ No newline at end of file diff --git a/app/src/main/kotlin/org/akanework/gramophone/ui/adapters/BaseAdapter.kt b/app/src/main/kotlin/org/akanework/gramophone/ui/adapters/BaseAdapter.kt index baccfe0b3..642ff9508 100644 --- a/app/src/main/kotlin/org/akanework/gramophone/ui/adapters/BaseAdapter.kt +++ b/app/src/main/kotlin/org/akanework/gramophone/ui/adapters/BaseAdapter.kt @@ -18,7 +18,6 @@ package org.akanework.gramophone.ui.adapters import android.annotation.SuppressLint -import android.content.Context import android.content.res.Configuration import android.net.Uri import android.os.Handler @@ -63,7 +62,7 @@ import org.akanework.gramophone.ui.fragments.AdapterFragment import java.util.Collections abstract class BaseAdapter( - val context: Context, + protected val fragment: Fragment, protected var liveData: MutableLiveData>?, sortHelper: Sorter.Helper, naturalOrderHelper: Sorter.NaturalOrderHelper?, @@ -78,45 +77,17 @@ abstract class BaseAdapter( private val fallbackSpans: Int = 1 ) : AdapterFragment.BaseInterface.ViewHolder>(), Observer>, PopupTextProvider, ItemHeightHelper { - constructor( - fragment: Fragment, - liveData: MutableLiveData>?, - sortHelper: Sorter.Helper, - naturalOrderHelper: Sorter.NaturalOrderHelper?, - initialSortType: Sorter.Type, - pluralStr: Int, - ownsView: Boolean, - defaultLayoutType: LayoutType, - isSubFragment: Boolean = false, - rawOrderExposed: Boolean = false, - allowDiffUtils: Boolean = false, - canSort: Boolean = true, - fallbackSpans: Int = 1 - ) : this( - fragment.requireContext(), - liveData, - sortHelper, - naturalOrderHelper, - initialSortType, - pluralStr, - ownsView, - defaultLayoutType, - isSubFragment, - rawOrderExposed, - allowDiffUtils, - canSort, - fallbackSpans - ) { this.fragment = fragment } companion object { // this relies on the assumption that all RecyclerViews always have same width // (though it does get invalidated if that is not the case, for eg rotation) private var gridHeightCache = 0 } - protected var fragment: Fragment? = null - protected val mainActivity = context as MainActivity - internal val layoutInflater: LayoutInflater - get() = fragment?.layoutInflater ?: LayoutInflater.from(context) + val context = fragment.requireContext() + protected inline val mainActivity + get() = context as MainActivity + internal inline val layoutInflater: LayoutInflater + get() = fragment.layoutInflater private val listHeight = context.resources.getDimensionPixelSize(R.dimen.list_height) private val largerListHeight = context.resources.getDimensionPixelSize(R.dimen.larger_list_height) private var gridHeight: Int? = null @@ -217,6 +188,7 @@ abstract class BaseAdapter( view: View, ) : RecyclerView.ViewHolder(view) { val songCover: ImageView = view.findViewById(R.id.cover) + val nowPlaying: MaterialButton = view.findViewById(R.id.now_playing) val title: TextView = view.findViewById(R.id.title) val subTitle: TextView = view.findViewById(R.id.artist) val moreButton: MaterialButton = view.findViewById(R.id.more) @@ -340,6 +312,7 @@ abstract class BaseAdapter( notifyDataSetChanged() } if (oldCount != newCount) decorAdapter.updateSongCounter() + onListUpdated() } finally { listLock.release() } @@ -364,6 +337,8 @@ abstract class BaseAdapter( } } + protected open fun onListUpdated() {} + protected open fun createDecorAdapter(): BaseDecorAdapter> { return BaseDecorAdapter(this, pluralStr, isSubFragment) } diff --git a/app/src/main/kotlin/org/akanework/gramophone/ui/adapters/BaseDecorAdapter.kt b/app/src/main/kotlin/org/akanework/gramophone/ui/adapters/BaseDecorAdapter.kt index 76c079c84..d9e3994e1 100644 --- a/app/src/main/kotlin/org/akanework/gramophone/ui/adapters/BaseDecorAdapter.kt +++ b/app/src/main/kotlin/org/akanework/gramophone/ui/adapters/BaseDecorAdapter.kt @@ -77,6 +77,7 @@ open class BaseDecorAdapter>( Pair(R.id.album, Sorter.Type.ByAlbumTitleAscending), Pair(R.id.size, Sorter.Type.BySizeDescending), Pair(R.id.add_date, Sorter.Type.ByAddDateDescending), + Pair(R.id.release_date, Sorter.Type.ByReleaseDateDescending), Pair(R.id.mod_date, Sorter.Type.ByModifiedDateDescending) ) val layoutMap = mapOf( diff --git a/app/src/main/kotlin/org/akanework/gramophone/ui/adapters/SongAdapter.kt b/app/src/main/kotlin/org/akanework/gramophone/ui/adapters/SongAdapter.kt index f4ea49c7f..6bf813f44 100644 --- a/app/src/main/kotlin/org/akanework/gramophone/ui/adapters/SongAdapter.kt +++ b/app/src/main/kotlin/org/akanework/gramophone/ui/adapters/SongAdapter.kt @@ -18,21 +18,26 @@ package org.akanework.gramophone.ui.adapters import android.net.Uri -import androidx.activity.viewModels +import android.view.View import androidx.appcompat.widget.PopupMenu import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels import androidx.lifecycle.MutableLiveData import androidx.media3.common.C import androidx.media3.common.MediaItem +import androidx.media3.common.Player import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.akanework.gramophone.R import org.akanework.gramophone.ui.LibraryViewModel +import org.akanework.gramophone.ui.MediaControllerViewModel import org.akanework.gramophone.ui.fragments.ArtistSubFragment import org.akanework.gramophone.ui.fragments.DetailDialogFragment import org.akanework.gramophone.ui.fragments.GeneralSubFragment +import org.akanework.gramophone.ui.registerLifecycleCallback +import java.util.GregorianCalendar /** @@ -96,7 +101,40 @@ class SongAdapter( fun getActivity() = mainActivity - private val viewModel: LibraryViewModel by mainActivity.viewModels() + private val viewModel: LibraryViewModel by fragment.activityViewModels() + private val mediaControllerViewModel: MediaControllerViewModel by fragment.activityViewModels() + private val idToPosMap = hashMapOf() + private var currentMediaItem = mediaControllerViewModel.get()?.currentMediaItem?.mediaId + set(value) { + if (field != value) { + val oldValue = field + field = value + val oldPos = idToPosMap[oldValue] + val newPos = idToPosMap[value] + if (oldPos != null) { + notifyItemChanged(oldPos) + } + if (newPos != null) { + notifyItemChanged(newPos) + } + } + } + + init { + mediaControllerViewModel.addOneOffControllerCallback(fragment.viewLifecycleOwner.lifecycle, false) { + it.registerLifecycleCallback(fragment.viewLifecycleOwner.lifecycle, object : Player.Listener { + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + currentMediaItem = mediaItem?.mediaId + } + }) + } + } + + override fun onListUpdated() { + // TODO run this method on a different thread / in advance + idToPosMap.clear() + list.forEachIndexed { i, item -> idToPosMap[item.mediaId] = i } + } override fun virtualTitleOf(item: MediaItem): String { return "null" @@ -262,6 +300,14 @@ class SongAdapter( } } + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + super.onBindViewHolder(holder, position) + val item = list[position] + holder.nowPlaying.visibility = + if (currentMediaItem != null && item.mediaId == currentMediaItem) + View.VISIBLE else View.GONE + } + class MediaItemHelper( types: Set = setOf( Sorter.Type.ByTitleDescending, Sorter.Type.ByTitleAscending, @@ -269,6 +315,7 @@ class SongAdapter( Sorter.Type.ByAlbumTitleDescending, Sorter.Type.ByAlbumTitleAscending, Sorter.Type.ByAlbumArtistDescending, Sorter.Type.ByAlbumArtistAscending, Sorter.Type.ByAddDateDescending, Sorter.Type.ByAddDateAscending, + Sorter.Type.ByReleaseDateDescending, Sorter.Type.ByReleaseDateAscending, Sorter.Type.ByModifiedDateDescending, Sorter.Type.ByModifiedDateAscending ) ) : Sorter.Helper(types) { @@ -300,6 +347,13 @@ class SongAdapter( return item.mediaMetadata.extras!!.getLong("AddDate") } + override fun getReleaseDate(item: MediaItem): Long { + return GregorianCalendar((item.mediaMetadata.releaseYear ?: 0) + 1900, + (item.mediaMetadata.releaseMonth ?: 1) - 1, + item.mediaMetadata.releaseDay ?: 0, 0, 0, 0) + .timeInMillis + } + override fun getModifiedDate(item: MediaItem): Long { return item.mediaMetadata.extras!!.getLong("ModifiedDate") } diff --git a/app/src/main/kotlin/org/akanework/gramophone/ui/adapters/Sorter.kt b/app/src/main/kotlin/org/akanework/gramophone/ui/adapters/Sorter.kt index 851ef41a7..169e05d3b 100644 --- a/app/src/main/kotlin/org/akanework/gramophone/ui/adapters/Sorter.kt +++ b/app/src/main/kotlin/org/akanework/gramophone/ui/adapters/Sorter.kt @@ -43,6 +43,7 @@ class Sorter( open fun getAlbumArtist(item: T): String? = throw UnsupportedOperationException() open fun getSize(item: T): Int = throw UnsupportedOperationException() open fun getAddDate(item: T): Long = throw UnsupportedOperationException() + open fun getReleaseDate(item: T): Long = throw UnsupportedOperationException() open fun getModifiedDate(item: T): Long = throw UnsupportedOperationException() fun canGetTitle(): Boolean = typesSupported.contains(Type.ByTitleAscending) || typesSupported.contains(Type.ByTitleDescending) @@ -62,6 +63,9 @@ class Sorter( fun canGetAddDate(): Boolean = typesSupported.contains(Type.ByAddDateAscending) || typesSupported.contains(Type.ByAddDateDescending) + fun canGetReleaseDate(): Boolean = typesSupported.contains(Type.ByReleaseDateAscending) + || typesSupported.contains(Type.ByReleaseDateDescending) + fun canGetModifiedDate(): Boolean = typesSupported.contains(Type.ByModifiedDateAscending) || typesSupported.contains(Type.ByModifiedDateDescending) } @@ -77,8 +81,9 @@ class Sorter( ByAlbumArtistDescending, ByAlbumArtistAscending, BySizeDescending, BySizeAscending, NaturalOrder, ByAddDateDescending, ByAddDateAscending, + ByReleaseDateDescending, ByReleaseDateAscending, ByModifiedDateDescending, ByModifiedDateAscending, - /* do not use nativeorder for smth other than title */ + /* do not use NativeOrder for something other than title -> TODO why was that lol */ None, NativeOrder, NativeOrderDescending } @@ -172,6 +177,18 @@ class Sorter( ) } + Type.ByReleaseDateDescending -> { + SupportComparator.createInversionComparator( + compareBy { sortingHelper.getReleaseDate(it) }, true + ) + } + + Type.ByReleaseDateAscending -> { + SupportComparator.createInversionComparator( + compareBy { sortingHelper.getReleaseDate(it) }, false + ) + } + Type.ByModifiedDateDescending -> { SupportComparator.createInversionComparator( compareBy { sortingHelper.getModifiedDate(it) }, true @@ -223,6 +240,10 @@ class Sorter( CalculationUtils.convertUnixTimestampToMonthDay(sortingHelper.getAddDate(item)) } + Type.ByReleaseDateDescending, Type.ByReleaseDateAscending -> { + CalculationUtils.convertUnixTimestampToMonthDay(sortingHelper.getReleaseDate(item)) + } + Type.ByModifiedDateDescending, Type.ByModifiedDateAscending -> { CalculationUtils.convertUnixTimestampToMonthDay(sortingHelper.getAddDate(item)) } diff --git a/app/src/main/kotlin/org/akanework/gramophone/ui/components/FullBottomSheet.kt b/app/src/main/kotlin/org/akanework/gramophone/ui/components/FullBottomSheet.kt index f74f9f6a2..bd5cf95de 100644 --- a/app/src/main/kotlin/org/akanework/gramophone/ui/components/FullBottomSheet.kt +++ b/app/src/main/kotlin/org/akanework/gramophone/ui/components/FullBottomSheet.kt @@ -540,6 +540,7 @@ class FullBottomSheet(context: Context, attrs: AttributeSet?, defStyleAttr: Int, } fun onStop() { + instance?.removeListener(this) runnableRunning = false } diff --git a/app/src/main/kotlin/org/akanework/gramophone/ui/components/PlayerBottomSheet.kt b/app/src/main/kotlin/org/akanework/gramophone/ui/components/PlayerBottomSheet.kt index 601c5ec80..a86fbc6d5 100644 --- a/app/src/main/kotlin/org/akanework/gramophone/ui/components/PlayerBottomSheet.kt +++ b/app/src/main/kotlin/org/akanework/gramophone/ui/components/PlayerBottomSheet.kt @@ -348,8 +348,6 @@ class PlayerBottomSheet private constructor( .toWindowInsets()!! } - fun getPlayer(): MediaController? = instance - @OptIn(ExperimentalCoilApi::class) override fun onMediaItemTransition( mediaItem: MediaItem?, @@ -431,6 +429,7 @@ class PlayerBottomSheet private constructor( override fun onStop(owner: LifecycleOwner) { super.onStop(owner) + instance?.removeListener(this) fullPlayer.onStop() } diff --git a/app/src/main/res/layout/adapter_grid_card.xml b/app/src/main/res/layout/adapter_grid_card.xml index b488fa494..8c9e22312 100644 --- a/app/src/main/res/layout/adapter_grid_card.xml +++ b/app/src/main/res/layout/adapter_grid_card.xml @@ -44,7 +44,7 @@ app:layout_constraintTop_toBottomOf="@id/cover_frame" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="@id/cover_frame" - app:layout_constraintEnd_toStartOf="@id/more" + app:layout_constraintEnd_toStartOf="@id/now_playing" android:orientation="vertical" tools:ignore="RtlSymmetry"> @@ -74,14 +74,31 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/adapter_list_card.xml b/app/src/main/res/layout/adapter_list_card.xml index a344d0747..97fadbce7 100644 --- a/app/src/main/res/layout/adapter_list_card.xml +++ b/app/src/main/res/layout/adapter_list_card.xml @@ -1,75 +1,93 @@ - - + - + android:background="?attr/colorSurfaceContainer" + android:importantForAccessibility="no" + android:scaleType="centerCrop" + android:src="@drawable/ic_default_cover" /> - + - + - - - - - + android:singleLine="true" + android:textColor="?attr/colorOnSurface" + android:textFontWeight="400" + android:fontFamily="sans-serif" + android:textSize="17sp" + tools:text="Example Title" /> - + + + + app:iconTint="?attr/colorOnSurface" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" /> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/adapter_list_card_larger.xml b/app/src/main/res/layout/adapter_list_card_larger.xml index 89cef0581..e5b4b0641 100644 --- a/app/src/main/res/layout/adapter_list_card_larger.xml +++ b/app/src/main/res/layout/adapter_list_card_larger.xml @@ -1,75 +1,91 @@ - - + - + android:background="?attr/colorSurfaceContainer" + android:importantForAccessibility="no" + android:scaleType="centerCrop" + android:src="@drawable/ic_default_cover" /> - + - + - - - - - + android:singleLine="true" + android:textColor="?attr/colorOnSurface" + android:textFontWeight="400" + android:fontFamily="sans-serif" + android:textSize="17sp" + tools:text="Example Title" /> - + + + + app:iconTint="?attr/colorOnSurface" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" /> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/menu/sort_menu.xml b/app/src/main/res/menu/sort_menu.xml index 12a4af519..e15780b09 100644 --- a/app/src/main/res/menu/sort_menu.xml +++ b/app/src/main/res/menu/sort_menu.xml @@ -44,9 +44,15 @@ android:title="@string/sort_by_add_date" app:showAsAction="never" /> + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 92d3b6e7a..9e73d9cdd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -182,4 +182,5 @@ Failed to resume music playback Click here to open the app and continue playback Playback failed to resume + Release date