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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@ -38,11 +39,15 @@ fun MusicViewOptionsDialog(
appPreferences: AppPreferences,
onDismissRequest: () -> Unit,
onViewOptionsChange: (AppPreferences) -> Unit,
onEnableVisualizer: () -> Unit,
) {
val context = LocalContext.current
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
val columnState = rememberLazyListState()
val items = remember { getMusicPreferences() }
Dialog(
onDismissRequest = onDismissRequest,
properties =
@ -55,6 +60,7 @@ fun MusicViewOptionsDialog(
window.setGravity(Gravity.END)
window.setDimAmount(0f)
}
Column {
Text(
text = stringResource(R.string.view_options),
@ -79,18 +85,48 @@ fun MusicViewOptionsDialog(
pref as AppPreference<AppPreferences, Any>
val interactionSource = remember { MutableInteractionSource() }
val value = pref.getter.invoke(appPreferences)
ComposablePreference(
preference = pref,
value = value,
onNavigate = {},
onValueChange = { newValue ->
onViewOptionsChange.invoke(pref.setter(appPreferences, newValue))
},
interactionSource = interactionSource,
modifier = Modifier,
onClickPreference = { pref ->
},
)
// Using title is a bit hacky
when (pref.title) {
R.string.show_visualizer -> {
ComposablePreference(
preference = pref,
value = value,
onNavigate = {},
onValueChange = { newValue ->
newValue as Boolean
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
import android.Manifest
import android.view.Gravity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
@ -41,6 +44,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.media3.common.util.UnstableApi
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
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.UserPreferences
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.DialogPopup
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.BottomDialogItem
import com.github.damontecres.wholphin.ui.playback.PlaybackKeyHandler
@ -141,6 +147,14 @@ fun NowPlayingPage(
focusRequester.tryRequestFocus()
}
var showRationaleDialog by remember { mutableStateOf(false) }
val launcher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { isGranted ->
viewModel.startVisualizer(isGranted, true)
}
Box(modifier) {
Box(
modifier =
@ -317,6 +331,34 @@ fun NowPlayingPage(
appPreferences = preferences,
onDismissRequest = { showViewOptionsDialog = false },
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,
)
}
@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 lyrics: LyricDto? = null,
val currentLyricIndex: Int? = null,
val visualizerPermissions: Boolean = false,
) {
val hasLyrics: Boolean get() = lyrics != null && lyrics.lyrics.isNotEmpty()
}

View file

@ -1,6 +1,10 @@
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 androidx.core.content.ContextCompat
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
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.preferences.AppPreference
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.MusicService
import com.github.damontecres.wholphin.services.NavigationManager
@ -24,13 +29,17 @@ import com.mayakapps.kache.InMemoryKache
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
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.extensions.lyricsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
@ -48,6 +57,7 @@ import kotlin.time.Duration.Companion.milliseconds
class NowPlayingViewModel
@AssistedInject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val musicService: MusicService,
private val backdropService: BackdropService,
@ -62,6 +72,7 @@ class NowPlayingViewModel
fun create(): NowPlayingViewModel
}
private val visualizerMutex = Mutex()
private var visualizer: Visualizer? = null
val controllerViewState =
@ -87,25 +98,16 @@ class NowPlayingViewModel
player.removeListener(this)
visualizer?.release()
}
val visualizerPermissions =
ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
startVisualizer(visualizerPermissions, false)
viewModelScope.launchDefault {
musicService.state.collectLatest { musicServiceState ->
if (musicServiceState.status != NowPlayingStatus.IDLE) {
viewModelScope.launchDefault {
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
}
visualizer?.enabled = musicServiceState.status == NowPlayingStatus.PLAYING
}
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_backdrop">Show backdrop</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>