Skip to content

Commit

Permalink
refactor media controller into view model
Browse files Browse the repository at this point in the history
  • Loading branch information
nift4 committed May 17, 2024
1 parent 7c65a34 commit 2e49307
Show file tree
Hide file tree
Showing 7 changed files with 252 additions and 119 deletions.
20 changes: 10 additions & 10 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
Expand Down Expand Up @@ -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"]
}
}
Expand All @@ -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"]
}
}
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T : View>(context: Context, attrs: AttributeSet) :
BottomSheetBehavior<T>(context, attrs) {
Expand All @@ -47,27 +44,6 @@ class MyBottomSheetBehavior<T : View>(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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package org.akanework.gramophone.logic.utils

import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner

interface LifecycleCallbackList<T> {
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<T>(lifecycle: Lifecycle? = null)
: LifecycleCallbackList<T>, DefaultLifecycleObserver {
private var list = hashMapOf<T, Pair<Boolean, CallbackLifecycleObserver?>>()

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<T> {
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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {}

Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MediaController>? = null
private val customCommandListenersImpl = LifecycleCallbackListImpl<
(MediaController, SessionCommand, Bundle) -> ListenableFuture<SessionResult>>()
private val disconnectionListenersImpl = LifecycleCallbackListImpl<() -> Unit>()
private val connectionListenersImpl = LifecycleCallbackListImpl<(MediaController) -> Unit>()
val customCommandListeners: LifecycleCallbackList<
(MediaController, SessionCommand, Bundle) -> ListenableFuture<SessionResult>>
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<SessionResult> {
var future: ListenableFuture<SessionResult>? = 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
}
}
Loading

0 comments on commit 2e49307

Please sign in to comment.