Ask for record audio permission when enabling visualizer

This commit is contained in:
Damontecres 2026-03-11 13:53:51 -04:00
parent c91ac434e0
commit 8aa64c6fef
No known key found for this signature in database
5 changed files with 184 additions and 28 deletions

View file

@ -18,6 +18,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -38,11 +39,15 @@ fun MusicViewOptionsDialog(
appPreferences: AppPreferences, appPreferences: AppPreferences,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onViewOptionsChange: (AppPreferences) -> Unit, onViewOptionsChange: (AppPreferences) -> Unit,
onEnableVisualizer: () -> Unit,
) { ) {
val context = LocalContext.current
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
val columnState = rememberLazyListState() val columnState = rememberLazyListState()
val items = remember { getMusicPreferences() } val items = remember { getMusicPreferences() }
Dialog( Dialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
properties = properties =
@ -55,6 +60,7 @@ fun MusicViewOptionsDialog(
window.setGravity(Gravity.END) window.setGravity(Gravity.END)
window.setDimAmount(0f) window.setDimAmount(0f)
} }
Column { Column {
Text( Text(
text = stringResource(R.string.view_options), text = stringResource(R.string.view_options),
@ -79,18 +85,48 @@ fun MusicViewOptionsDialog(
pref as AppPreference<AppPreferences, Any> pref as AppPreference<AppPreferences, Any>
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val value = pref.getter.invoke(appPreferences) val value = pref.getter.invoke(appPreferences)
ComposablePreference( // Using title is a bit hacky
preference = pref, when (pref.title) {
value = value, R.string.show_visualizer -> {
onNavigate = {}, ComposablePreference(
onValueChange = { newValue -> preference = pref,
onViewOptionsChange.invoke(pref.setter(appPreferences, newValue)) value = value,
}, onNavigate = {},
interactionSource = interactionSource, onValueChange = { newValue ->
modifier = Modifier, newValue as Boolean
onClickPreference = { pref -> if (newValue) {
}, onEnableVisualizer.invoke()
) } else {
onViewOptionsChange.invoke(
pref.setter(
appPreferences,
newValue,
),
)
}
},
interactionSource = interactionSource,
modifier = Modifier,
onClickPreference = { pref ->
},
)
}
else -> {
ComposablePreference(
preference = pref,
value = value,
onNavigate = {},
onValueChange = { newValue ->
onViewOptionsChange.invoke(pref.setter(appPreferences, newValue))
},
interactionSource = interactionSource,
modifier = Modifier,
onClickPreference = { pref ->
},
)
}
}
} }
} }
} }

View file

@ -1,7 +1,10 @@
package com.github.damontecres.wholphin.ui.detail.music package com.github.damontecres.wholphin.ui.detail.music
import android.Manifest
import android.view.Gravity import android.view.Gravity
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
@ -41,6 +44,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
@ -49,9 +53,11 @@ import com.github.damontecres.wholphin.data.model.AudioItem
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.rememberQueue import com.github.damontecres.wholphin.services.rememberQueue
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.findActivity
import com.github.damontecres.wholphin.ui.playback.BottomDialog import com.github.damontecres.wholphin.ui.playback.BottomDialog
import com.github.damontecres.wholphin.ui.playback.BottomDialogItem import com.github.damontecres.wholphin.ui.playback.BottomDialogItem
import com.github.damontecres.wholphin.ui.playback.PlaybackKeyHandler import com.github.damontecres.wholphin.ui.playback.PlaybackKeyHandler
@ -141,6 +147,14 @@ fun NowPlayingPage(
focusRequester.tryRequestFocus() focusRequester.tryRequestFocus()
} }
var showRationaleDialog by remember { mutableStateOf(false) }
val launcher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { isGranted ->
viewModel.startVisualizer(isGranted, true)
}
Box(modifier) { Box(modifier) {
Box( Box(
modifier = modifier =
@ -317,6 +331,34 @@ fun NowPlayingPage(
appPreferences = preferences, appPreferences = preferences,
onDismissRequest = { showViewOptionsDialog = false }, onDismissRequest = { showViewOptionsDialog = false },
onViewOptionsChange = { viewModel.updatePreferences(it) }, onViewOptionsChange = { viewModel.updatePreferences(it) },
onEnableVisualizer = {
val showRationale =
context
.findActivity()
?.shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO) == true
when {
!state.visualizerPermissions && showRationale -> {
showRationaleDialog = true
}
!state.visualizerPermissions -> {
launcher.launch(Manifest.permission.RECORD_AUDIO)
}
else -> {
viewModel.startVisualizer(true, true)
}
}
},
)
}
if (showRationaleDialog) {
RecordAudioRationaleDialog(
onDismissRequest = { showRationaleDialog = false },
onClick = {
showRationaleDialog = false
launcher.launch(Manifest.permission.RECORD_AUDIO)
},
) )
} }
} }
@ -356,3 +398,31 @@ fun NowPlayingBottomDialog(
gravity = Gravity.START, gravity = Gravity.START,
) )
} }
@Composable
private fun RecordAudioRationaleDialog(
onDismissRequest: () -> Unit,
onClick: () -> Unit,
) {
BasicDialog(
onDismissRequest = onDismissRequest,
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(16.dp),
) {
Text(
text = stringResource(R.string.visualizer_rationale),
color = MaterialTheme.colorScheme.onSurface,
)
Button(
onClick = onClick,
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(
text = stringResource(R.string.continue_string),
)
}
}
}
}

View file

@ -9,6 +9,7 @@ data class NowPlayingState(
val musicServiceState: MusicServiceState, val musicServiceState: MusicServiceState,
val lyrics: LyricDto? = null, val lyrics: LyricDto? = null,
val currentLyricIndex: Int? = null, val currentLyricIndex: Int? = null,
val visualizerPermissions: Boolean = false,
) { ) {
val hasLyrics: Boolean get() = lyrics != null && lyrics.lyrics.isNotEmpty() val hasLyrics: Boolean get() = lyrics != null && lyrics.lyrics.isNotEmpty()
} }

View file

@ -1,6 +1,10 @@
package com.github.damontecres.wholphin.ui.detail.music package com.github.damontecres.wholphin.ui.detail.music
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.audiofx.Visualizer import android.media.audiofx.Visualizer
import androidx.core.content.ContextCompat
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -11,6 +15,7 @@ import com.github.damontecres.wholphin.data.model.AudioItem
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.updateMusicPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
@ -24,13 +29,17 @@ import com.mayakapps.kache.InMemoryKache
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.lyricsApi import org.jellyfin.sdk.api.client.extensions.lyricsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
@ -48,6 +57,7 @@ import kotlin.time.Duration.Companion.milliseconds
class NowPlayingViewModel class NowPlayingViewModel
@AssistedInject @AssistedInject
constructor( constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient, private val api: ApiClient,
private val musicService: MusicService, private val musicService: MusicService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
@ -62,6 +72,7 @@ class NowPlayingViewModel
fun create(): NowPlayingViewModel fun create(): NowPlayingViewModel
} }
private val visualizerMutex = Mutex()
private var visualizer: Visualizer? = null private var visualizer: Visualizer? = null
val controllerViewState = val controllerViewState =
@ -87,25 +98,16 @@ class NowPlayingViewModel
player.removeListener(this) player.removeListener(this)
visualizer?.release() visualizer?.release()
} }
val visualizerPermissions =
ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
startVisualizer(visualizerPermissions, false)
viewModelScope.launchDefault { viewModelScope.launchDefault {
musicService.state.collectLatest { musicServiceState -> musicService.state.collectLatest { musicServiceState ->
if (musicServiceState.status != NowPlayingStatus.IDLE) { if (musicServiceState.status != NowPlayingStatus.IDLE) {
viewModelScope.launchDefault { visualizer?.enabled = musicServiceState.status == NowPlayingStatus.PLAYING
if (visualizer == null) {
visualizer =
Visualizer(onMain { player.audioSessionId }).apply {
captureSize = Visualizer.getCaptureSizeRange()[1]
setDataCaptureListener(
this@NowPlayingViewModel,
Visualizer.getMaxCaptureRate() / 2,
true,
false,
)
}
}
visualizer?.enabled =
musicServiceState.status == NowPlayingStatus.PLAYING
}
} }
state.update { it.copy(musicServiceState = musicServiceState) } state.update { it.copy(musicServiceState = musicServiceState) }
@ -294,4 +296,49 @@ class NowPlayingViewModel
} }
} }
} }
private fun initVisualizer() {
viewModelScope.launchDefault {
visualizerMutex.withLock {
val prefs = preferencesDataStore.data.first()
if (visualizer == null &&
state.value.visualizerPermissions &&
prefs.musicPreferences.showVisualizer
) {
Timber.v("Creating visualizer")
visualizer =
Visualizer(onMain { player.audioSessionId }).apply {
captureSize = Visualizer.getCaptureSizeRange()[1]
setDataCaptureListener(
this@NowPlayingViewModel,
Visualizer.getMaxCaptureRate() / 2,
true,
false,
)
enabled = true
}
}
}
}
}
fun startVisualizer(
permissionGranted: Boolean,
updatePreferences: Boolean,
) {
Timber.v("startVisualizer: permissionGranted=%s", permissionGranted)
state.update {
it.copy(
visualizerPermissions = permissionGranted,
)
}
viewModelScope.launchDefault {
if (updatePreferences || !permissionGranted) {
preferencesDataStore.updateData {
it.updateMusicPreferences { showVisualizer = permissionGranted }
}
}
initVisualizer()
}
}
} }

View file

@ -742,6 +742,8 @@
<string name="show_visualizer">Show visualizer</string> <string name="show_visualizer">Show visualizer</string>
<string name="show_backdrop">Show backdrop</string> <string name="show_backdrop">Show backdrop</string>
<string name="play_as_type">Play as?</string> <string name="play_as_type">Play as?</string>
<string name="visualizer_rationale">Visualizing the currently playing audio requires permission to record audio.</string>
<string name="continue_string">Continue</string>
</resources> </resources>