|
|
|
@@ -8,6 +8,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
|
|
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
|
|
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
|
|
|
|
import androidx.compose.foundation.background
|
|
|
|
|
import androidx.compose.foundation.focusable
|
|
|
|
|
import androidx.compose.foundation.rememberScrollState
|
|
|
|
|
import androidx.compose.foundation.verticalScroll
|
|
|
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
|
|
@@ -50,10 +51,19 @@ import androidx.compose.runtime.LaunchedEffect
|
|
|
|
|
import androidx.compose.runtime.getValue
|
|
|
|
|
import androidx.compose.runtime.mutableStateOf
|
|
|
|
|
import androidx.compose.runtime.remember
|
|
|
|
|
import androidx.compose.runtime.rememberCoroutineScope
|
|
|
|
|
import androidx.compose.runtime.setValue
|
|
|
|
|
import androidx.compose.ui.Alignment
|
|
|
|
|
import androidx.compose.ui.focus.FocusRequester
|
|
|
|
|
import androidx.compose.ui.focus.focusRequester
|
|
|
|
|
import androidx.compose.ui.Modifier
|
|
|
|
|
import androidx.compose.ui.draw.clip
|
|
|
|
|
import androidx.compose.ui.input.key.Key
|
|
|
|
|
import androidx.compose.ui.input.key.KeyEvent
|
|
|
|
|
import androidx.compose.ui.input.key.KeyEventType
|
|
|
|
|
import androidx.compose.ui.input.key.key
|
|
|
|
|
import androidx.compose.ui.input.key.onKeyEvent
|
|
|
|
|
import androidx.compose.ui.input.key.type
|
|
|
|
|
import androidx.compose.ui.platform.LocalContext
|
|
|
|
|
import androidx.compose.ui.text.style.TextAlign
|
|
|
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
|
|
@@ -74,11 +84,13 @@ import com.composables.icons.lucide.Shuffle
|
|
|
|
|
import com.composables.icons.lucide.SkipBack
|
|
|
|
|
import com.composables.icons.lucide.SkipForward
|
|
|
|
|
import com.fabledsword.minstrel.player.RepeatMode
|
|
|
|
|
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
|
|
|
|
import com.fabledsword.minstrel.player.output.DeviceChip
|
|
|
|
|
import com.fabledsword.minstrel.player.output.OutputPickerSheet
|
|
|
|
|
import com.fabledsword.minstrel.player.output.OutputPickerViewModel
|
|
|
|
|
import com.fabledsword.minstrel.player.output.OutputRoute
|
|
|
|
|
import com.fabledsword.minstrel.player.output.RouteSnapshot
|
|
|
|
|
import kotlinx.coroutines.launch
|
|
|
|
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
|
|
|
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
|
|
|
|
import com.fabledsword.minstrel.nav.HERO_KEY_NOW_PLAYING_COVER
|
|
|
|
@@ -129,27 +141,30 @@ fun NowPlayingScreen(
|
|
|
|
|
snackbarHostState.showSnackbar(msg)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
LaunchedEffect(Unit) {
|
|
|
|
|
viewModel.dropEvents.collect { snackbarHostState.showSnackbar("Disconnected from $it") }
|
|
|
|
|
}
|
|
|
|
|
val track = state.currentTrack
|
|
|
|
|
if (track == null) {
|
|
|
|
|
// Session torn down (queue finished + auto-stop, or user cleared
|
|
|
|
|
// the queue from elsewhere). Pop back to whichever shell screen
|
|
|
|
|
// launched NowPlaying rather than stranding the user on an
|
|
|
|
|
// EmptyState with no escape. A short delay swallows the
|
|
|
|
|
// momentary null during MediaController IPC bind on cold-mount.
|
|
|
|
|
LaunchedEffect(Unit) {
|
|
|
|
|
kotlinx.coroutines.delay(POP_GRACE_MS)
|
|
|
|
|
if (viewModel.uiState.value.currentTrack == null) {
|
|
|
|
|
navController.popBackStack()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
NowPlayingNullTrackGuard(navController, viewModel)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
val outputViewModel: OutputPickerViewModel = hiltViewModel()
|
|
|
|
|
val activeUpnp by outputViewModel.activeUpnp.collectAsStateWithLifecycle()
|
|
|
|
|
val onKeyEvent = rememberUpnpVolumeKeyHandler(activeUpnp)
|
|
|
|
|
val focusRequester = remember { FocusRequester() }
|
|
|
|
|
LaunchedEffect(activeUpnp) { if (activeUpnp != null) focusRequester.requestFocus() }
|
|
|
|
|
val dominant = rememberDominantColor(track.coverUrl)
|
|
|
|
|
val dismissConnection = rememberDragDismissConnection(
|
|
|
|
|
onDismiss = { navController.popBackStack() },
|
|
|
|
|
)
|
|
|
|
|
Scaffold(
|
|
|
|
|
modifier = Modifier.fillMaxSize().nestedScroll(dismissConnection),
|
|
|
|
|
modifier = Modifier
|
|
|
|
|
.fillMaxSize()
|
|
|
|
|
.nestedScroll(dismissConnection)
|
|
|
|
|
.focusRequester(focusRequester)
|
|
|
|
|
.focusable()
|
|
|
|
|
.onKeyEvent(onKeyEvent),
|
|
|
|
|
topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) },
|
|
|
|
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
|
|
|
containerColor = Color.Transparent,
|
|
|
|
@@ -166,11 +181,32 @@ fun NowPlayingScreen(
|
|
|
|
|
navController = navController,
|
|
|
|
|
viewModel = viewModel,
|
|
|
|
|
trackActionsViewModel = trackActionsViewModel,
|
|
|
|
|
outputViewModel = outputViewModel,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Null-track guard extracted from [NowPlayingScreen] to keep that
|
|
|
|
|
* function under detekt's LongMethod ceiling. Session torn down
|
|
|
|
|
* (queue finished + auto-stop, or user cleared the queue from
|
|
|
|
|
* elsewhere). Pops back after a short grace delay so a momentary
|
|
|
|
|
* null during MediaController IPC bind on cold-mount doesn't flash.
|
|
|
|
|
*/
|
|
|
|
|
@Composable
|
|
|
|
|
private fun NowPlayingNullTrackGuard(
|
|
|
|
|
navController: NavHostController,
|
|
|
|
|
viewModel: PlayerViewModel,
|
|
|
|
|
) {
|
|
|
|
|
LaunchedEffect(Unit) {
|
|
|
|
|
kotlinx.coroutines.delay(POP_GRACE_MS)
|
|
|
|
|
if (viewModel.uiState.value.currentTrack == null) {
|
|
|
|
|
navController.popBackStack()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Composable
|
|
|
|
|
private fun dominantGradient(top: Color): Brush {
|
|
|
|
|
val base = MaterialTheme.colorScheme.background
|
|
|
|
@@ -270,6 +306,7 @@ private fun NowPlayingTopBar(onClose: () -> Unit) {
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Suppress("LongParameterList") // Compose screen wiring — layout args, not logic
|
|
|
|
|
@Composable
|
|
|
|
|
private fun NowPlayingBody(
|
|
|
|
|
inner: androidx.compose.foundation.layout.PaddingValues,
|
|
|
|
@@ -278,10 +315,10 @@ private fun NowPlayingBody(
|
|
|
|
|
navController: NavHostController,
|
|
|
|
|
viewModel: PlayerViewModel,
|
|
|
|
|
trackActionsViewModel: TrackActionsViewModel,
|
|
|
|
|
outputViewModel: OutputPickerViewModel,
|
|
|
|
|
) {
|
|
|
|
|
val isLiked by trackActionsViewModel.isLikedFlow(track.id)
|
|
|
|
|
.collectAsStateWithLifecycle(initialValue = false)
|
|
|
|
|
val outputViewModel: OutputPickerViewModel = hiltViewModel()
|
|
|
|
|
val routes by outputViewModel.routes.collectAsStateWithLifecycle()
|
|
|
|
|
val sheetVisible by outputViewModel.sheetVisible.collectAsStateWithLifecycle()
|
|
|
|
|
val permissionDenied = rememberBluetoothPermissionState(sheetVisible)
|
|
|
|
@@ -703,3 +740,40 @@ private fun TransportRow(
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Returns a key-event handler that intercepts volume-up/down when a UPnP
|
|
|
|
|
* route is active and routes the step through [ActiveUpnp.rendering].
|
|
|
|
|
* Volume is cached locally so rapid key presses don't each wait on a
|
|
|
|
|
* getVolume() round-trip. Returns false (not consumed) for every event
|
|
|
|
|
* when no UPnP route is active so the system handles volume normally.
|
|
|
|
|
*/
|
|
|
|
|
@Composable
|
|
|
|
|
private fun rememberUpnpVolumeKeyHandler(activeUpnp: ActiveUpnp?): (KeyEvent) -> Boolean {
|
|
|
|
|
val scope = rememberCoroutineScope()
|
|
|
|
|
val cache = remember { VolumeCache() }
|
|
|
|
|
return remember(activeUpnp) {
|
|
|
|
|
handler@{ event: KeyEvent ->
|
|
|
|
|
val rc = activeUpnp?.rendering ?: return@handler false
|
|
|
|
|
if (event.type != KeyEventType.KeyDown) return@handler false
|
|
|
|
|
val delta = when (event.key) {
|
|
|
|
|
Key.VolumeUp -> VOLUME_KEY_STEP
|
|
|
|
|
Key.VolumeDown -> -VOLUME_KEY_STEP
|
|
|
|
|
else -> return@handler false
|
|
|
|
|
}
|
|
|
|
|
scope.launch {
|
|
|
|
|
val current = cache.value ?: runCatching { rc.getVolume() }.getOrNull() ?: 0
|
|
|
|
|
val next = (current + delta).coerceIn(VOLUME_MIN_PERCENT, VOLUME_MAX_PERCENT)
|
|
|
|
|
cache.value = next
|
|
|
|
|
runCatching { rc.setVolume(next) }
|
|
|
|
|
}
|
|
|
|
|
true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private class VolumeCache(var value: Int? = null)
|
|
|
|
|
|
|
|
|
|
private const val VOLUME_KEY_STEP = 5
|
|
|
|
|
private const val VOLUME_MIN_PERCENT = 0
|
|
|
|
|
private const val VOLUME_MAX_PERCENT = 100
|
|
|
|
|