diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3d71d9f19..0e1454f45 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,11 +26,11 @@ android { signingConfigs { create("release") { - if (readProperties(file("../package.properties")).getProperty("AKANE_RELEASE_KEY_ALIAS") != null) { - storeFile = file(readProperties(file("../package.properties")).getProperty("AKANE_RELEASE_STORE_FILE")) - storePassword = readProperties(file("../package.properties")).getProperty("AKANE_RELEASE_STORE_PASSWORD") - keyAlias = readProperties(file("../package.properties")).getProperty("AKANE_RELEASE_KEY_ALIAS") - keyPassword = readProperties(file("../package.properties")).getProperty("AKANE_RELEASE_KEY_PASSWORD") + if (project.hasProperty("AKANE_RELEASE_KEY_ALIAS")) { + storeFile = file(project.properties["AKANE_RELEASE_STORE_FILE"].toString()) + storePassword = project.properties["AKANE_RELEASE_STORE_PASSWORD"].toString() + keyAlias = project.properties["AKANE_RELEASE_KEY_ALIAS"].toString() + keyPassword = project.properties["AKANE_RELEASE_KEY_PASSWORD"].toString() } } } @@ -110,7 +110,7 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) - if (readProperties(file("../package.properties")).getProperty("AKANE_RELEASE_KEY_ALIAS") != null) { + if (project.hasProperty("AKANE_RELEASE_KEY_ALIAS")) { signingConfig = signingConfigs["release"] } } @@ -126,7 +126,7 @@ android { } debug { isPseudoLocalesEnabled = true - if (readProperties(file("../package.properties")).getProperty("AKANE_RELEASE_KEY_ALIAS") != null) { + if (project.hasProperty("AKANE_RELEASE_KEY_ALIAS")) { signingConfig = signingConfigs["release"] } } @@ -168,15 +168,15 @@ aboutLibraries { dependencies { val media3Version = "1.4.0-alpha01" implementation("androidx.activity:activity-ktx:1.9.0") - implementation("androidx.appcompat:appcompat:1.7.0-beta01") + implementation("androidx.appcompat:appcompat:1.7.0-rc01") implementation("androidx.collection:collection-ktx:1.4.0") implementation("androidx.concurrent:concurrent-futures-ktx:1.1.0") implementation("androidx.constraintlayout:constraintlayout:2.2.0-alpha13") implementation("androidx.core:core-ktx:1.13.1") implementation("androidx.core:core-splashscreen:1.0.1") //implementation("androidx.datastore:datastore-preferences:1.1.0-rc01") TODO don't abuse shared prefs - implementation("androidx.fragment:fragment-ktx:1.8.0-alpha02") - implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") + implementation("androidx.fragment:fragment-ktx:1.8.0-beta01") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0") implementation("androidx.media3:media3-exoplayer:$media3Version") implementation("androidx.media3:media3-exoplayer-midi:$media3Version") implementation("androidx.media3:media3-session:$media3Version") diff --git a/app/src/main/kotlin/org/akanework/gramophone/logic/ui/MyBottomSheetBehavior.kt b/app/src/main/kotlin/org/akanework/gramophone/logic/ui/MyBottomSheetBehavior.kt index c6ca4390a..72f755bc8 100644 --- a/app/src/main/kotlin/org/akanework/gramophone/logic/ui/MyBottomSheetBehavior.kt +++ b/app/src/main/kotlin/org/akanework/gramophone/logic/ui/MyBottomSheetBehavior.kt @@ -22,10 +22,7 @@ import android.content.Context import android.util.AttributeSet import android.view.View import android.view.ViewGroup -import android.widget.OverScroller -import androidx.customview.widget.ViewDragHelper import com.google.android.material.bottomsheet.BottomSheetBehavior -import java.lang.reflect.Field class MyBottomSheetBehavior(context: Context, attrs: AttributeSet) : BottomSheetBehavior(context, attrs) { @@ -47,27 +44,6 @@ class MyBottomSheetBehavior(context: Context, attrs: AttributeSet) : return false } - // based on https://stackoverflow.com/a/63474805 - private object BottomSheetUtil { - val viewDragHelper: Field = BottomSheetBehavior::class.java - .getDeclaredField("viewDragHelper") - .apply { isAccessible = true } - val mScroller: Field = ViewDragHelper::class.java - .getDeclaredField("mScroller") - .apply { isAccessible = true } - } - - private fun getViewDragHelper(): ViewDragHelper? = - BottomSheetUtil.viewDragHelper.get(this) as? ViewDragHelper? - - private fun ViewDragHelper.getScroller(): OverScroller? = - BottomSheetUtil.mScroller.get(this) as? OverScroller? - - fun setStateWithoutAnimation(state: Int) { - setState(state) - getViewDragHelper()!!.getScroller()!!.abortAnimation() - } - @SuppressLint("RestrictedApi") override fun handleBackInvoked() { if (state != STATE_HIDDEN) { 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 new file mode 100644 index 000000000..106d41553 --- /dev/null +++ b/app/src/main/kotlin/org/akanework/gramophone/logic/utils/LifecycleCallbackList.kt @@ -0,0 +1,70 @@ +package org.akanework.gramophone.logic.utils + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner + +interface LifecycleCallbackList { + fun addCallbackForever(clear: Boolean = false, callback: T) { + addCallback(null, clear, callback) + } + fun addCallback(lifecycle: Lifecycle?, clear: Boolean = false, callback: T) + fun removeCallback(callback: T) +} + +class LifecycleCallbackListImpl(lifecycle: Lifecycle? = null) + : LifecycleCallbackList, DefaultLifecycleObserver { + private var list = hashMapOf>() + + init { + lifecycle?.addObserver(this) + } + + override fun addCallback(lifecycle: Lifecycle?, clear: Boolean, callback: T) { + list[callback] = Pair(clear, lifecycle?.let { CallbackLifecycleObserver(it, callback) }) + } + + override fun removeCallback(callback: T) { + list.remove(callback)?.second?.release() + } + + fun dispatch(callback: (T) -> Unit) { + list.forEach { callback(it.key) } + list = HashMap(list.filterValues { !it.first }) + } + + fun release() { + dispatch { removeCallback(it) } + } + + fun throwIfRelease() { + if (list.size == 0) return + release() + throw IllegalStateException("Callbacks leaked in LifecycleCallbackList") + } + + fun iterator(): Iterator { + return list.keys.iterator() + } + + override fun onDestroy(owner: LifecycleOwner) { + release() + } + + private inner class CallbackLifecycleObserver(private val lifecycle: Lifecycle, + private val callback: T) + : DefaultLifecycleObserver { + + init { + lifecycle.addObserver(this) + } + + override fun onDestroy(owner: LifecycleOwner) { + removeCallback(callback) + } + + fun release() { + lifecycle.removeObserver(this) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/akanework/gramophone/ui/MainActivity.kt b/app/src/main/kotlin/org/akanework/gramophone/ui/MainActivity.kt index 11cc2e9d5..5d70cf4a0 100644 --- a/app/src/main/kotlin/org/akanework/gramophone/ui/MainActivity.kt +++ b/app/src/main/kotlin/org/akanework/gramophone/ui/MainActivity.kt @@ -71,6 +71,7 @@ class MainActivity : AppCompatActivity() { // Import our viewModels. private val libraryViewModel: LibraryViewModel by viewModels() + val controllerViewModel: MediaControllerViewModel by viewModels() val startingActivity = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {} @@ -105,6 +106,7 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen().setKeepOnScreenCondition { !ready } super.onCreate(savedInstanceState) + lifecycle.addObserver(controllerViewModel) enableEdgeToEdgeProperly() autoPlay = intent?.extras?.getBoolean(PLAYBACK_AUTO_START_FOR_FGS, false) == true intentSender = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { @@ -249,7 +251,7 @@ class MainActivity : AppCompatActivity() { * getPlayer: * Returns a media controller. */ - fun getPlayer() = playerBottomSheet.getPlayer() + fun getPlayer() = controllerViewModel.get() fun consumeAutoPlay(): Boolean { return autoPlay.also { autoPlay = false } diff --git a/app/src/main/kotlin/org/akanework/gramophone/ui/MediaControllerViewModel.kt b/app/src/main/kotlin/org/akanework/gramophone/ui/MediaControllerViewModel.kt new file mode 100644 index 000000000..41566b4df --- /dev/null +++ b/app/src/main/kotlin/org/akanework/gramophone/ui/MediaControllerViewModel.kt @@ -0,0 +1,116 @@ +package org.akanework.gramophone.ui + +import android.app.Application +import android.content.ComponentName +import android.os.Bundle +import androidx.core.content.ContextCompat +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.media3.session.MediaController +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult +import androidx.media3.session.SessionToken +import com.google.common.util.concurrent.ListenableFuture +import org.akanework.gramophone.logic.GramophoneApplication +import org.akanework.gramophone.logic.GramophonePlaybackService +import org.akanework.gramophone.logic.utils.LifecycleCallbackList +import org.akanework.gramophone.logic.utils.LifecycleCallbackListImpl + +class MediaControllerViewModel(application: Application) : AndroidViewModel(application), + DefaultLifecycleObserver, MediaController.Listener { + + private val context: GramophoneApplication + get() = getApplication() + private var controllerFuture: ListenableFuture? = null + private val customCommandListenersImpl = LifecycleCallbackListImpl< + (MediaController, SessionCommand, Bundle) -> ListenableFuture>() + private val disconnectionListenersImpl = LifecycleCallbackListImpl<() -> Unit>() + private val connectionListenersImpl = LifecycleCallbackListImpl<(MediaController) -> Unit>() + val customCommandListeners: LifecycleCallbackList< + (MediaController, SessionCommand, Bundle) -> ListenableFuture> + get() = customCommandListenersImpl + val disconnectionListeners: LifecycleCallbackList<() -> Unit> + get() = disconnectionListenersImpl + val connectionListeners: LifecycleCallbackList<(MediaController) -> Unit> + get() = connectionListenersImpl + + override fun onStart(owner: LifecycleOwner) { + val sessionToken = + SessionToken(context, ComponentName(context, GramophonePlaybackService::class.java)) + controllerFuture = + MediaController + .Builder(context, sessionToken) + .setListener(this) + .buildAsync() + .apply { + addListener( + { + if (controllerFuture?.isDone == true && + controllerFuture?.isCancelled == false) { + val instance = get() + connectionListenersImpl.dispatch { it(instance) } + } + }, ContextCompat.getMainExecutor(context) + ) + } + } + + fun addOneOffControllerCallback(lifecycle: Lifecycle?, callback: (MediaController) -> Unit) { + val instance = get() + if (instance != null) { + callback(instance) + } else { + connectionListeners.addCallback(lifecycle, true, callback) + } + } + + fun get(): MediaController? { + if (controllerFuture?.isDone == true && controllerFuture?.isCancelled == false) { + return controllerFuture!!.get() + } + return null + } + + override fun onDisconnected(controller: MediaController) { + controllerFuture = null + disconnectionListenersImpl.dispatch { it() } + } + + override fun onStop(owner: LifecycleOwner) { + if (controllerFuture?.isDone == true) { + if (controllerFuture?.isCancelled == false) { + controllerFuture?.get()?.release() + } else { + throw IllegalStateException("controllerFuture?.isCancelled != false") + } + } else { + controllerFuture?.cancel(true) + controllerFuture = null + disconnectionListenersImpl.dispatch { it() } + } + } + + override fun onDestroy(owner: LifecycleOwner) { + ContextCompat.getMainExecutor(context).execute { + customCommandListenersImpl.throwIfRelease() + connectionListenersImpl.throwIfRelease() + disconnectionListenersImpl.throwIfRelease() + } + } + + override fun onCustomCommand( + controller: MediaController, + command: SessionCommand, + args: Bundle + ): ListenableFuture { + var future: ListenableFuture? = null + val listenerIterator = customCommandListenersImpl.iterator() + while (future == null || (future.isDone && + future.get().resultCode == SessionResult.RESULT_ERROR_NOT_SUPPORTED)) { + future = listenerIterator.next()(controller, command, args) + } + return future + } +} \ No newline at end of file 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 517c107d9..f74f9f6a2 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 @@ -8,7 +8,6 @@ import android.content.res.ColorStateList import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.TransitionDrawable -import android.os.Bundle import android.util.AttributeSet import android.util.Size import android.view.Gravity @@ -31,7 +30,6 @@ import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.session.MediaController -import androidx.media3.session.SessionCommand import androidx.media3.session.SessionResult import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager @@ -57,8 +55,6 @@ import com.google.android.material.slider.Slider import com.google.android.material.timepicker.MaterialTimePicker import com.google.android.material.timepicker.TimeFormat import com.google.common.util.concurrent.Futures -import com.google.common.util.concurrent.ListenableFuture -import com.google.common.util.concurrent.MoreExecutors import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -92,6 +88,7 @@ import org.akanework.gramophone.logic.utils.MediaStoreUtils import org.akanework.gramophone.ui.MainActivity import kotlin.math.min +@SuppressLint("NotifyDataSetChanged") class FullBottomSheet(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : ConstraintLayout(context, attrs, defStyleAttr, defStyleRes), Player.Listener, SharedPreferences.OnSharedPreferenceChangeListener { @@ -102,10 +99,8 @@ class FullBottomSheet(context: Context, attrs: AttributeSet?, defStyleAttr: Int, private val activity get() = context as MainActivity - private var controllerFuture: ListenableFuture? = null private val instance: MediaController? - get() = if (controllerFuture?.isDone == false || controllerFuture?.isCancelled == true) - null else controllerFuture?.get() + get() = activity.getPlayer() var minimize: (() -> Unit)? = null private var wrappedContext: Context? = null @@ -260,6 +255,37 @@ class FullBottomSheet(context: Context, attrs: AttributeSet?, defStyleAttr: Int, } refreshSettings(null) prefs.registerOnSharedPreferenceChangeListener(this) + activity.controllerViewModel.customCommandListeners.addCallback(activity.lifecycle) { _, command, _ -> + when (command.customAction) { + GramophonePlaybackService.SERVICE_TIMER_CHANGED -> { + bottomSheetTimerButton.isChecked = instance?.hasTimer() == true + } + + GramophonePlaybackService.SERVICE_GET_LYRICS -> { + val parsedLyrics = instance?.getLyrics() + if (bottomSheetFullLyricList != parsedLyrics) { + bottomSheetFullLyricList.clear() + if (parsedLyrics?.isEmpty() != false) { + bottomSheetFullLyricList.add( + MediaStoreUtils.Lyric( + null, + context.getString(R.string.no_lyric_found) + ) + ) + } else { + bottomSheetFullLyricList.addAll(parsedLyrics) + } + bottomSheetFullLyricAdapter.notifyDataSetChanged() + resetToDefaultLyricPosition() + } + } + + else -> { + return@addCallback Futures.immediateFuture(SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED)) + } + } + return@addCallback Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } val seekBarProgressWavelength = context.resources @@ -432,41 +458,6 @@ class FullBottomSheet(context: Context, attrs: AttributeSet?, defStyleAttr: Int, removeColorScheme() } - val sessionListener: MediaController.Listener = object : MediaController.Listener { - @SuppressLint("NotifyDataSetChanged") - override fun onCustomCommand( - controller: MediaController, - command: SessionCommand, - args: Bundle - ): ListenableFuture { - when (command.customAction) { - GramophonePlaybackService.SERVICE_TIMER_CHANGED -> { - bottomSheetTimerButton.isChecked = controller.hasTimer() - } - - GramophonePlaybackService.SERVICE_GET_LYRICS -> { - val parsedLyrics = instance?.getLyrics() - if (bottomSheetFullLyricList != parsedLyrics) { - bottomSheetFullLyricList.clear() - if (parsedLyrics?.isEmpty() != false) { - bottomSheetFullLyricList.add( - MediaStoreUtils.Lyric( - null, - context.getString(R.string.no_lyric_found) - ) - ) - } else { - bottomSheetFullLyricList.addAll(parsedLyrics) - } - bottomSheetFullLyricAdapter.notifyDataSetChanged() - resetToDefaultLyricPosition() - } - } - } - return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } - } - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { if (key == "color_accuracy" || key == "content_based_color") { if (DynamicColors.isDynamicColorAvailable() && @@ -521,9 +512,8 @@ class FullBottomSheet(context: Context, attrs: AttributeSet?, defStyleAttr: Int, } } - fun onStart(cf: ListenableFuture) { - controllerFuture = cf - controllerFuture!!.addListener({ + fun onStart() { + activity.controllerViewModel.addOneOffControllerCallback(activity.lifecycle) { firstTime = true instance?.addListener(this) bottomSheetTimerButton.isChecked = instance?.hasTimer() == true @@ -546,13 +536,11 @@ class FullBottomSheet(context: Context, attrs: AttributeSet?, defStyleAttr: Int, } */ - }, MoreExecutors.directExecutor()) + } } fun onStop() { runnableRunning = false - instance?.removeListener(this) - controllerFuture = null } override fun dispatchApplyWindowInsets(platformInsets: WindowInsets): WindowInsets { 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 4b6ba97aa..601c5ec80 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 @@ -17,7 +17,6 @@ package org.akanework.gramophone.ui.components -import android.content.ComponentName import android.content.Context import android.os.Handler import android.os.Looper @@ -42,7 +41,6 @@ import androidx.lifecycle.LifecycleOwner import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.session.MediaController -import androidx.media3.session.SessionToken import androidx.preference.PreferenceManager import coil3.annotation.ExperimentalCoilApi import coil3.imageLoader @@ -53,11 +51,8 @@ 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.common.util.concurrent.ListenableFuture -import com.google.common.util.concurrent.MoreExecutors import org.akanework.gramophone.BuildConfig import org.akanework.gramophone.R -import org.akanework.gramophone.logic.GramophonePlaybackService import org.akanework.gramophone.logic.clone import org.akanework.gramophone.logic.fadInAnimation import org.akanework.gramophone.logic.fadOutAnimation @@ -79,9 +74,7 @@ class PlayerBottomSheet private constructor( private const val TAG = "PlayerBottomSheet" } - private var sessionToken: SessionToken? = null private var lastDisposable: Disposable? = null - private var controllerFuture: ListenableFuture? = null private var standardBottomSheetBehavior: MyBottomSheetBehavior? = null private var bottomSheetBackCallback: OnBackPressedCallback? = null val fullPlayer: FullBottomSheet @@ -99,8 +92,7 @@ class PlayerBottomSheet private constructor( get() = activity private val handler = Handler(Looper.getMainLooper()) private val instance: MediaController? - get() = if (controllerFuture?.isDone == false || controllerFuture?.isCancelled == true) - null else controllerFuture?.get() + get() = activity.getPlayer() private var lastActuallyVisible: Boolean? = null private var lastMeasuredHeight: Int? = null var visible = false @@ -419,38 +411,27 @@ class PlayerBottomSheet private constructor( override fun onStart(owner: LifecycleOwner) { super.onStart(owner) - sessionToken = - SessionToken(context, ComponentName(context, GramophonePlaybackService::class.java)) - controllerFuture = - MediaController - .Builder(context, sessionToken!!) - .setListener(fullPlayer.sessionListener) - .buildAsync() - controllerFuture!!.addListener( - { - instance?.addListener(this) - onPlaybackStateChanged(instance?.playbackState ?: Player.STATE_IDLE) - onMediaItemTransition( - instance?.currentMediaItem, - Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED - ) - if ((activity.consumeAutoPlay() || prefs.getBooleanStrict("autoplay", - false)) && instance?.isPlaying != true) { - instance?.play() - } - }, - MoreExecutors.directExecutor(), - ) - fullPlayer.onStart(controllerFuture!!) + activity.controllerViewModel.addOneOffControllerCallback(activity.lifecycle) { + instance?.addListener(this) + onPlaybackStateChanged(instance?.playbackState ?: Player.STATE_IDLE) + onMediaItemTransition( + instance?.currentMediaItem, + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED + ) + if ((activity.consumeAutoPlay() || prefs.getBooleanStrict( + "autoplay", + false + )) && instance?.isPlaying != true + ) { + instance?.play() + } + } + fullPlayer.onStart() } override fun onStop(owner: LifecycleOwner) { super.onStop(owner) fullPlayer.onStop() - instance?.removeListener(this) - instance?.release() - controllerFuture?.cancel(true) - controllerFuture = null } } \ No newline at end of file