diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewOptionsDialog.kt index 1b99fa9f..cf9f9c7c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewOptionsDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewOptionsDialog.kt @@ -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 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 -> + }, + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt index 06997742..b691ea9e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt @@ -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), + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt index de285169..39552c59 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt @@ -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() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt index 27a075f1..a5d36860 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt @@ -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() + } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d0b85039..6ca00f24 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -742,6 +742,8 @@ Show visualizer Show backdrop Play as? + Visualizing the currently playing audio requires permission to record audio. + Continue