diff --git a/android/README.md b/android/README.md index 0624fec..7edd816 100644 --- a/android/README.md +++ b/android/README.md @@ -59,7 +59,10 @@ the one core for the process through `CoreHost`: - `Event.Backend(BackendCommand)` -> `ExoBackend` -> ExoPlayer. `Load` builds a per-item media source through `DefaultMediaSourceFactory` over `CoreStreamDataSourceFactory` (each item carries its own headers) plus the gapless follow-up as a second playlist item; `SetNext` replaces everything after the current item; the transition is - detected from `onMediaItemTransition(AUTO)` and the played item removed. `PreBuffer` prepares a + detected from `onMediaItemTransition(AUTO)`, reported as `Ended` (played item) then + `TransitionedToNext`, and the played item removed. The player holds `C.WAKE_MODE_NETWORK` (wake + + Wi-Fi lock) so streams keep going with the screen off. Network errors (connection failed/timeout) + are retried with `prepare()` on a ~1 min backoff and reported non-fatal; only then fatal. `PreBuffer` prepares a second silent ExoPlayer at the requested position (`PreBufferReady` when READY); `DiscardPreBuffer` releases it. `gain_db` is applied as `10^(gain/20) * masterVolume` clamped to 1.0 — Media3 has no gain stage, so positive gain is an approximation (documented in `ExoBackend`). @@ -76,13 +79,24 @@ the one core for the process through `CoreHost`: is no `CacheDataSource` (the core caches). `NativeCore` implements the `CoreStreams` seam. - Reports back: `Ready`, `Playing`, `Paused`, `Buffering`, `Position` every 750 ms while playing and on every seek/transition, `Ended`, `TransitionedToNext`, `Error`, `PreBufferReady`, - `AudioFocusLost` (transient when Media3 suppresses rather than pauses). + `AudioFocusLost` (transient when Media3 suppresses rather than pauses; the core then leaves the + player alone so it resumes when focus returns). - `Event.MediaSession(state)` -> `MediaSessionBridge` -> `CoreSessionPlayer`, a `SimpleBasePlayer` whose state *is* the core's session state (metadata, extrapolated position from the stamp, shuffle/repeat, available commands from the customised action list). Custom buttons (love, shuffle, repeat, rate) are media button preferences. Controls map to `Command.MediaSessionCommand`. Notification, lockscreen, Bluetooth and headset controls come from - Media3. Playback resumption from the system is refused (resuming is always explicit). + Media3; the service `addSession`s the session in `onCreate` (the UI is not a Media3 controller, so + `onGetSession` alone would never register it and no notification or foreground promotion would + happen). +- Remote output: while another Connect device plays, `CoreSessionPlayer` reports + `DeviceInfo(PLAYBACK_TYPE_REMOTE, routingControllerId = "hocket-connect")` (fixed volume: Connect + volume is per device) and `ConnectRouteProvider` (a `MediaRoute2ProviderService`, API 30+) keeps a + routing session with that id, named after the playing device. SystemUI pairs the two and shows the + device on the media controls' output chip; the other devices are routes in the system output + switcher, and picking one (or this phone) is a `HandoffTo`. `PlaybackService` feeds + `ConnectRoutes` from `DevicesChanged`/`TransportChanged`/`MediaSession` and registers the app's + MediaRouter2 discovery preference for `app.hocket.feature.CONNECT`, which keeps the provider bound. Playback resumption from the system is refused (resuming is always explicit). - `NetworkMonitor` -> `SetNetworkState` (kind, metered, hashed SSID or transport id); `BatterySaverMonitor` -> `SetBatterySaver` while `battery.autoSaver` is on. - The app binds with `ACTION_BIND_CORE` (plus a per-process token, since the service is exported @@ -153,6 +167,12 @@ absent. - Now-playing sheet: `AnchoredDraggable` with a velocity-aware fling, scrim, corners morphing from pill to square, the artwork scaling from the 48 dp thumbnail to the hero; predictive back drags it down with the gesture. +- Page navigation (`ui/nav/Transitions.kt`): pushes use a shared X axis, bar switches a short + fade-through. Predictive back (after Navic) scrubs its own transition on every screen: the page + shrinks into a rounded card that follows the finger away from the swipe edge while the previous + page slides in from a short offset; releasing finishes it, cancelling runs it back. Reduced + motion makes it a crossfade. Holding the bottom bar does nothing special: it is edited from + Settings > Customise or the account sheet. - Mini player: tap to expand, swipe left/right to skip with resistance past the threshold, a thin wavy progress line. - Hero artwork: horizontal swipe to skip (springs back); long-press toggles a whole-app dynamic diff --git a/android/app/src/main/java/app/hocket/ui/components/Rows.kt b/android/app/src/main/java/app/hocket/ui/components/Rows.kt index 1dd04ba..1fb6b26 100644 --- a/android/app/src/main/java/app/hocket/ui/components/Rows.kt +++ b/android/app/src/main/java/app/hocket/ui/components/Rows.kt @@ -58,6 +58,7 @@ import app.hocket.R import app.hocket.core.ActionIds import app.hocket.core.ArtworkSizes import app.hocket.core.Commands +import app.hocket.core.SwipeOptions import app.hocket.core.api.ActionTarget import app.hocket.ui.LocalCoreClient import app.hocket.ui.LocalDetailNavigator @@ -196,57 +197,73 @@ fun TrackRow( actionTarget: ActionTarget? = null, /** Row-specific accessibility actions (the queue's move and remove), added to the row menu's. */ extraActions: List = emptyList(), + /** Whose swipe settings the row follows; rows with a menu swipe like song lists, read-only rows not at all. */ + swipe: SwipeSurface? = if (onMore != null) SwipeSurface.List else null, ) { val artist = track.artist ?: stringResource(R.string.unknown_artist) val label = trackLabel(track) var rating by remember { mutableStateOf(false) } + var playlistPicker by remember { mutableStateOf(false) } val target = actionTarget ?: Commands.tracks(listOf(track.id)) // Rows with a menu offer it as actions; read-only rows (stats, filter previews) offer none. val actions = if (onMore != null) trackRowActions(track, target, onRate = { rating = true }, onMore = onMore, extra = extraActions) else extraActions + val (startId, endId) = if (swipe != null) swipeActionIds(swipe) else SwipeOptions.NONE to SwipeOptions.NONE + // The row (and the caller's modifier) stays the one merged accessibility item; only its content + // slides under the swipe. SelectableRow( - selected, selectionActive, onClick, onToggleSelect, label, modifier, + selected, selectionActive, onClick, onToggleSelect, label, + modifier = modifier, // "playing, downloaded": the row's state, merged with its label into one item. state = listOfNotNull(if (nowPlaying) stringResource(R.string.row_state_playing) else null, offlineStateText(track.offline)) .joinToString(", ").ifEmpty { null }, actions = actions, clickLabel = stringResource(R.string.action_play), ) { - Row(Modifier.heightIn(min = 64.dp).padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { - leading?.invoke() - if (showArtwork) { - Box(Modifier.size(48.dp)) { - Artwork(track.coverArt, ArtworkSizes.THUMB, null, Modifier.size(48.dp), RoundedCornerShape(ListArtCorner)) - if (selected) Box(Modifier.size(48.dp).clip(RoundedCornerShape(ListArtCorner)), contentAlignment = Alignment.Center) { - Surface(shape = CircleShape, color = MaterialTheme.colorScheme.primary) { Icon(Icons.Filled.Check, null, Modifier.padding(4.dp), tint = MaterialTheme.colorScheme.onPrimary) } + SwipeActionBox( + startToEnd = trackSwipeAction(startId, track, target, onAddToPlaylist = { playlistPicker = true }), + endToStart = trackSwipeAction(endId, track, target, onAddToPlaylist = { playlistPicker = true }), + // Not while selecting: rows toggle then, and a stray swipe must not act on one of them. + enabled = swipe != null && !selectionActive, + swipeSurface = MaterialTheme.colorScheme.surface, + ) { + Row(Modifier.heightIn(min = 64.dp).padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { + leading?.invoke() + if (showArtwork) { + Box(Modifier.size(48.dp)) { + Artwork(track.coverArt, ArtworkSizes.THUMB, null, Modifier.size(48.dp), RoundedCornerShape(ListArtCorner)) + if (selected) Box(Modifier.size(48.dp).clip(RoundedCornerShape(ListArtCorner)), contentAlignment = Alignment.Center) { + Surface(shape = CircleShape, color = MaterialTheme.colorScheme.primary) { Icon(Icons.Filled.Check, null, Modifier.padding(4.dp), tint = MaterialTheme.colorScheme.onPrimary) } + } } + Spacer(Modifier.width(14.dp)) } - Spacer(Modifier.width(14.dp)) - } - Column(Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (nowPlaying) { - Icon(Icons.Filled.GraphicEq, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(6.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (nowPlaying) { + Icon(Icons.Filled.GraphicEq, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + } + Text(track.title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, + color = if (nowPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface) + } + Row(verticalAlignment = Alignment.CenterVertically) { + OfflineBadge(track.offline, describe = false) + Text(artist, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) } - Text(track.title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = if (nowPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface) } - Row(verticalAlignment = Alignment.CenterVertically) { - OfflineBadge(track.offline, describe = false) - Text(artist, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (track.loved) { + Icon(Icons.Filled.Favorite, null, tint = MaterialTheme.colorScheme.tertiary, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(8.dp)) + } + Text(formatClock(track.durationMs), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + trailing?.invoke() + if (onMore != null) { + IconButton(onClick = onMore) { Icon(Icons.Filled.MoreVert, stringResource(R.string.action_more_for, track.title)) } } - } - if (track.loved) { - Icon(Icons.Filled.Favorite, null, tint = MaterialTheme.colorScheme.tertiary, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(8.dp)) - } - Text(formatClock(track.durationMs), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) - trailing?.invoke() - if (onMore != null) { - IconButton(onClick = onMore) { Icon(Icons.Filled.MoreVert, stringResource(R.string.action_more_for, track.title)) } } } } + if (playlistPicker) PlaylistPicker(target = target, onDismiss = { playlistPicker = false }) if (rating) { val client = LocalCoreClient.current RatingDialog(current = track.rating.toInt(), onRate = { stars -> client.dispatch(Commands.runAction(ActionIds.rate(stars), target)); rating = false }, onDismiss = { rating = false }) diff --git a/android/app/src/main/java/app/hocket/ui/components/SongMenu.kt b/android/app/src/main/java/app/hocket/ui/components/SongMenu.kt new file mode 100644 index 0000000..6d44a88 --- /dev/null +++ b/android/app/src/main/java/app/hocket/ui/components/SongMenu.kt @@ -0,0 +1,258 @@ +package app.hocket.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.hocket.R +import app.hocket.core.ActionIds +import app.hocket.core.ArtworkSizes +import app.hocket.core.Commands +import app.hocket.core.api.ActionDescriptor +import app.hocket.core.api.ActionTarget +import app.hocket.core.api.TrackSummary +import app.hocket.ui.DetailNavigator +import app.hocket.ui.LocalCoreClient +import app.hocket.ui.LocalDetailNavigator +import kotlinx.coroutines.launch + +/** + * A row a context adds to the song menu (the playlist's "Remove from playlist", the player's sleep + * timer). Extras always sit in one place: after the registry's actions, below a divider. + */ +@Immutable +class SongMenuExtra( + val label: String, + val icon: ImageVector, + val onClick: () -> Unit, + val supporting: String? = null, + val destructive: Boolean = false, + val highlighted: Boolean = false, + val testTag: String? = null, +) + +/** One opening of the song menu: the song, what its actions act on (queue rows pass their item), the context's extras. */ +@Immutable +data class SongMenuRequest(val track: TrackSummary, val target: ActionTarget, val extras: List = emptyList()) + +/** + * Opens THE song menu. There is one per screen tree, hosted at the app level ([SongMenuHost]) and + * driven by this state, so the menu looks and behaves the same from every list, the queue and the + * full player, and its sheet never lives inside a surface that moves or leaves composition (the + * player sheet): it is dismissed by its own gesture and nothing else. + */ +@Stable +class SongMenuController { + /** Asked for, actions not loaded yet. */ + internal var pending by mutableStateOf(null) + /** On screen, with the registry's actions it was opened with. */ + internal var shown by mutableStateOf>?>(null) + + fun open(track: TrackSummary, target: ActionTarget = Commands.tracks(listOf(track.id)), extras: List = emptyList()) { + if (shown == null) pending = SongMenuRequest(track, target, extras) + } + + val isOpen: Boolean get() = pending != null || shown != null + + internal fun close() { pending = null; shown = null } +} + +val LocalSongMenu = staticCompositionLocalOf { null } + +/** + * The song menu for this tree: the app-level one inside the main shell, or (for a screen shown on its + * own) one hosted here. + */ +@Composable +fun rememberSongMenu(): SongMenuController { + LocalSongMenu.current?.let { return it } + val local = remember { SongMenuController() } + SongMenuHost(local, LocalDetailNavigator.current) + return local +} + +/** + * Hosts [controller]'s sheet. The registry's actions are fetched before the sheet is shown, so it + * opens at its final height instead of growing (and re-anchoring) under the finger. [navigator] + * performs "Go to album / artist" (the shell's collapses the player as it navigates). + */ +@Composable +fun SongMenuHost(controller: SongMenuController, navigator: DetailNavigator?) { + val client = LocalCoreClient.current + val pending = controller.pending + LaunchedEffect(pending) { + val request = pending ?: return@LaunchedEffect + val actions = client.actions("contextMenu", request.target) + if (controller.pending == request) { + controller.pending = null + controller.shown = request to actions + } + } + var pickerFor by remember { mutableStateOf(null) } + controller.shown?.let { (request, actions) -> + // Keyed on the request: a new opening starts from a fresh sheet state, never a stale one. + androidx.compose.runtime.key(request) { + SongMenuSheet( + request, actions, navigator, + onDismiss = { controller.close() }, + onAddToPlaylist = { pickerFor = request.target }, + ) + } + } + pickerFor?.let { t -> PlaylistPicker(target = t, onDismiss = { pickerFor = null }) } +} + +/** The registry's rate entries: the menu's star row replaces them. */ +private val RATE_IDS = (0..5).map { ActionIds.rate(it) }.toSet() + ActionIds.RATE + +/** Registry actions that only apply in one context (the queue, a playlist): listed with the extras. */ +private val CONTEXT_IDS = setOf(ActionIds.REMOVE_FROM_QUEUE, ActionIds.REMOVE_FROM_PLAYLIST) + +/** The song menu's rows: the common actions, and the context's (listed last, with the extras). */ +internal data class SongMenuRows(val common: List, val context: List) + +/** + * What the song menu lists, in the registry's (user-customisable) order: the rating is the star row + * above, go-to entries only where the song has an album / artist and something can navigate, and + * context-only actions split off so they always sit in the same place, after the common ones. + */ +internal fun songMenuRows(actions: List, track: TrackSummary, canNavigate: Boolean): SongMenuRows { + val shown = actions.filter { a -> + when (a.id) { + in RATE_IDS -> false + ActionIds.GO_TO_ALBUM -> canNavigate && track.albumId != null + ActionIds.GO_TO_ARTIST -> canNavigate && track.artistId != null + else -> true + } + } + val (context, common) = shown.partition { it.id in CONTEXT_IDS } + return SongMenuRows(common, context) +} + +/** + * The song menu: artwork, title and "artist · album"; the rating as stars; the registry's actions + * for the target; then, below a divider, the context's own (remove from queue / playlist, extras). + * Every row closes the sheet with its hide animation (actions and navigation start at once, behind it). + */ +@Composable +private fun SongMenuSheet( + request: SongMenuRequest, + actions: List, + navigator: DetailNavigator?, + onDismiss: () -> Unit, + onAddToPlaylist: () -> Unit, +) { + val client = LocalCoreClient.current + val track = request.track + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + fun hideThen(after: () -> Unit = {}) { + scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss(); after() } + } + var rating by remember { mutableIntStateOf(track.rating.toInt()) } + val rows = remember(actions, track, navigator) { songMenuRows(actions, track, navigator != null) } + @Composable fun ActionRow(a: ActionDescriptor) { + MenuRow(a.label, actionIcon(a.icon), destructive = a.destructive, enabled = a.enabled, tag = "songMenu.action.${a.id}") { + when (a.id) { + ActionIds.ADD_TO_PLAYLIST -> hideThen(onAddToPlaylist) + ActionIds.GO_TO_ALBUM -> { track.albumId?.let { navigator?.openAlbum?.invoke(it) }; hideThen() } + ActionIds.GO_TO_ARTIST -> { track.artistId?.let { navigator?.openArtist?.invoke(it) }; hideThen() } + in ActionIds.UI_HANDLED -> hideThen() + else -> { client.dispatch(Commands.runAction(a.id, request.target)); hideThen() } + } + } + } + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = Modifier.testTag("songMenu")) { + Column(Modifier.navigationBarsPadding().verticalScroll(rememberScrollState())) { + Row(Modifier.fillMaxWidth().padding(horizontal = 24.dp).testTag("songMenu.header"), verticalAlignment = Alignment.CenterVertically) { + Artwork(track.coverArt, ArtworkSizes.THUMB, null, Modifier.size(48.dp), RoundedCornerShape(ListArtCorner)) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text(track.title, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.semantics { heading() }) + val sub = listOfNotNull(track.artist ?: stringResource(R.string.unknown_artist), track.album).joinToString(stringResource(R.string.dot_separator)) + Text(sub, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + RatingStars( + rating, + onRate = { stars -> rating = stars; client.dispatch(Commands.runAction(ActionIds.rate(stars), request.target)) }, + starSize = 32.dp, + modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.CenterHorizontally).padding(vertical = 4.dp), + ) + HorizontalDivider() + rows.common.forEach { ActionRow(it) } + if (rows.context.isNotEmpty() || request.extras.isNotEmpty()) { + HorizontalDivider() + rows.context.forEach { ActionRow(it) } + request.extras.forEach { e -> + MenuRow(e.label, e.icon, supporting = e.supporting, destructive = e.destructive, highlighted = e.highlighted, tag = e.testTag) { hideThen(e.onClick) } + } + } + Spacer(Modifier.height(16.dp)) + } + } +} + +@Composable +private fun MenuRow( + label: String, + icon: ImageVector, + supporting: String? = null, + destructive: Boolean = false, + highlighted: Boolean = false, + enabled: Boolean = true, + tag: String? = null, + onClick: () -> Unit, +) { + val tint = when { + destructive -> MaterialTheme.colorScheme.error + highlighted -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + ListItem( + headlineContent = { Text(label, color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified) }, + supportingContent = supporting?.let { { Text(it) } }, + leadingContent = { Icon(icon, null, tint = tint) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.clickable(enabled = enabled, onClick = onClick).let { if (tag != null) it.testTag(tag) else it }, + ) +} diff --git a/android/app/src/main/java/app/hocket/ui/components/SwipeActions.kt b/android/app/src/main/java/app/hocket/ui/components/SwipeActions.kt new file mode 100644 index 0000000..409379e --- /dev/null +++ b/android/app/src/main/java/app/hocket/ui/components/SwipeActions.kt @@ -0,0 +1,227 @@ +package app.hocket.ui.components + +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd +import androidx.compose.material.icons.automirrored.filled.PlaylistPlay +import androidx.compose.material.icons.automirrored.filled.QueueMusic +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import app.hocket.core.ActionIds +import app.hocket.core.Commands +import app.hocket.core.SettingKeys +import app.hocket.core.SwipeOptions +import app.hocket.core.api.ActionTarget +import app.hocket.core.api.OfflineState +import app.hocket.core.api.TrackSummary +import app.hocket.ui.LocalCoreClient +import app.hocket.ui.screens.settings.actionLabel +import app.hocket.ui.screens.settings.setting +import kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.sign + +/** How far (of the row's width) a swipe has to travel before letting go runs its action. */ +private const val SWIPE_THRESHOLD = 0.3f + +/** How far (of the row's width) the row follows the finger. */ +private const val SWIPE_MAX = 0.7f + +/** One side of a row's swipe: the action it runs and how its revealed background looks. */ +@Immutable +class SwipeAction( + /** The registry id actually run (love or unlove for "love"): a test and debugging handle. */ + val id: String, + val label: String, + val icon: ImageVector, + val container: Color, + val content: Color, + /** The row goes away (remove from queue): it slides out instead of springing back. */ + val dismisses: Boolean, + val onSwipe: () -> Unit, +) + +/** Which pair of swipe settings a row follows. */ +enum class SwipeSurface(val startToEndKey: String, val endToStartKey: String) { + Queue(SettingKeys.SWIPE_QUEUE_START_TO_END, SettingKeys.SWIPE_QUEUE_END_TO_START), + List(SettingKeys.SWIPE_LIST_START_TO_END, SettingKeys.SWIPE_LIST_END_TO_START), +} + +/** The configured action ids (start-to-end, end-to-start) for [surface]; the registry defaults until the core has sent them. */ +@Composable +fun swipeActionIds(surface: SwipeSurface): Pair { + fun id(key: String, stored: String?) = stored ?: SwipeOptions.DEFAULTS.getValue(key) + return id(surface.startToEndKey, setting(surface.startToEndKey).string) to id(surface.endToStartKey, setting(surface.endToStartKey).string) +} + +/** + * The [SwipeAction] a swipe setting's [id] means for one song row, or null (none, or nothing to do: + * "download" on a row that is already downloaded). Everything runs through the core's action + * registry on [target]; "love" runs love or unlove by the row's state, "addToPlaylist" (handled by + * the platform) calls [onAddToPlaylist] to open the picker. + */ +@Composable +fun trackSwipeAction(id: String, track: TrackSummary, target: ActionTarget, onAddToPlaylist: () -> Unit): SwipeAction? { + val client = LocalCoreClient.current + val scheme = MaterialTheme.colorScheme + val run = { action: String -> { client.dispatch(Commands.runAction(action, target)) } } + return when (id) { + ActionIds.REMOVE_FROM_QUEUE -> SwipeAction(id, actionLabel(id), Icons.Filled.Delete, scheme.errorContainer, scheme.onErrorContainer, dismisses = true, run(id)) + ActionIds.PLAY_NEXT -> SwipeAction(id, actionLabel(id), Icons.AutoMirrored.Filled.PlaylistPlay, scheme.primaryContainer, scheme.onPrimaryContainer, dismisses = false, run(id)) + ActionIds.PLAY_LATER -> SwipeAction(id, actionLabel(id), Icons.AutoMirrored.Filled.QueueMusic, scheme.secondaryContainer, scheme.onSecondaryContainer, dismisses = false, run(id)) + ActionIds.LOVE -> { + val action = if (track.loved) ActionIds.UNLOVE else ActionIds.LOVE + SwipeAction(action, actionLabel(action), if (track.loved) Icons.Filled.FavoriteBorder else Icons.Filled.Favorite, scheme.tertiaryContainer, scheme.onTertiaryContainer, dismisses = false, run(action)) + } + ActionIds.ADD_TO_PLAYLIST -> SwipeAction(id, actionLabel(id), Icons.AutoMirrored.Filled.PlaylistAdd, scheme.secondaryContainer, scheme.onSecondaryContainer, dismisses = false, onAddToPlaylist) + ActionIds.DOWNLOAD -> if (track.offline == OfflineState.Downloaded) null + else SwipeAction(id, actionLabel(id), Icons.Filled.Download, scheme.primaryContainer, scheme.onPrimaryContainer, dismisses = false, run(id)) + else -> null + } +} + +/** + * A row that can be swiped sideways. Past [SWIPE_THRESHOLD] of its width (a haptic tick says so), + * letting go runs the side's action: one that [SwipeAction.dismisses] slides the row out, anything + * else springs it back. A side without an action does not move; with neither, the box takes no + * gestures at all (so a pager around it keeps its swipes). + * + * Horizontal only: vertical drags stay with the list, and a drag handle inside wins the drags that + * start on it. The revealed background (and [swipeSurface] under the content, for rows that are + * transparent at rest, like the queue over the player's artwork) is drawn only while the row is + * off its rest position. The swipe is a shortcut: every action is also in the row's menu and its + * accessibility actions, so the background is decorative. + */ +@Composable +fun SwipeActionBox( + startToEnd: SwipeAction?, + endToStart: SwipeAction?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + swipeSurface: Color = Color.Unspecified, + content: @Composable () -> Unit, +) { + val scope = rememberCoroutineScope() + val haptics = LocalHapticFeedback.current + val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl + // In reading-direction terms: positive is towards the end of the line. + var offset by remember { mutableFloatStateOf(0f) } + var width by remember { mutableIntStateOf(0) } + var armed by remember { mutableStateOf(false) } + val start by rememberUpdatedState(startToEnd) + val end by rememberUpdatedState(endToStart) + val side by remember { derivedStateOf { sign(offset) } } + val active = enabled && (startToEnd != null || endToStart != null) + val drag = rememberDraggableState { delta -> + val max = if (start != null) width * SWIPE_MAX else 0f + val min = if (end != null) -width * SWIPE_MAX else 0f + offset = (offset + delta).coerceIn(min, max) + val nowArmed = width > 0 && abs(offset) >= width * SWIPE_THRESHOLD + if (nowArmed != armed) { + armed = nowArmed + haptics.performHapticFeedback(if (nowArmed) HapticFeedbackType.GestureThresholdActivate else HapticFeedbackType.SegmentTick) + } + } + Box( + modifier + .onSizeChanged { width = it.width } + .draggable( + state = drag, + orientation = Orientation.Horizontal, + enabled = active, + reverseDirection = rtl, + onDragStopped = { + val from = offset + // Only past the threshold; short of it the row just springs back. + val action = (if (from > 0f) start else if (from < 0f) end else null)?.takeIf { armed } + armed = false + scope.launch { + if (action != null) { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + action.onSwipe() + if (action.dismisses) { + // Out of sight; the row leaves the list with the core's update. Back to + // rest afterwards, in case it stays (the core refused). + animate(from, sign(from) * width, animationSpec = tween(160)) { v, _ -> offset = v } + offset = 0f + return@launch + } + } + animate(from, 0f, animationSpec = spring()) { v, _ -> offset = v } + } + }, + ), + ) { + if (side != 0f) { + val action = if (side > 0f) startToEnd else endToStart + if (action != null) SwipeBackground(action, fromStart = side > 0f, armed = armed, modifier = Modifier.matchParentSize()) + } + Box( + Modifier + .graphicsLayer { translationX = if (rtl) -offset else offset } + .drawBehind { if (offset != 0f && swipeSurface.isSpecified) drawRect(swipeSurface) }, + ) { content() } + } +} + +/** The revealed side: the action's colour, icon and label at the edge the row moved away from. */ +@Composable +private fun SwipeBackground(action: SwipeAction, fromStart: Boolean, armed: Boolean, modifier: Modifier) { + Box( + modifier.clearAndSetSemantics { }.background(action.container).padding(horizontal = 24.dp).testTag("swipe.background"), + contentAlignment = if (fromStart) Alignment.CenterStart else Alignment.CenterEnd, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) { + if (!fromStart) Text(action.label, style = MaterialTheme.typography.labelLarge, color = action.content, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (!fromStart) Spacer(Modifier.width(12.dp)) + Icon(action.icon, null, tint = action.content, modifier = Modifier.scale(if (armed) 1.2f else 1f)) + if (fromStart) Spacer(Modifier.width(12.dp)) + if (fromStart) Text(action.label, style = MaterialTheme.typography.labelLarge, color = action.content, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} diff --git a/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt b/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt index b2050bd..1ec3b9d 100644 --- a/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt +++ b/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt @@ -73,15 +73,12 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.zIndex import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.semantics.CustomAccessibilityAction -import androidx.compose.ui.semantics.customActions -import androidx.compose.ui.semantics.semantics import kotlin.math.roundToInt import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -315,12 +312,14 @@ private val CollapsedRailWidth = 96.dp private fun MainShell(snackbar: SnackbarHostState) { val nav = rememberNavController() val sheet = rememberNowPlayingSheetState() + // The one song menu, hosted here (not inside the player sheet or a list) and opened from anywhere. + val songMenu = remember { app.hocket.ui.components.SongMenuController() } val client = LocalCoreClient.current val nowPlaying = client.nowPlaying.collectAsStateWithLifecycle() val playerVisible by remember { derivedStateOf { nowPlaying.value != null } } BoxWithConstraints(Modifier.fillMaxSize()) { val wide = maxWidth >= 600.dp - CompositionLocalProvider(LocalWideLayout provides wide, LocalPlayerVisible provides playerVisible) { + CompositionLocalProvider(LocalWideLayout provides wide, LocalPlayerVisible provides playerVisible, app.hocket.ui.components.LocalSongMenu provides songMenu) { val items = navItems() val backStack by nav.currentBackStack.collectAsStateWithLifecycle() // The root entry of the stack last switched to (see selectedPlace). @@ -328,7 +327,6 @@ private fun MainShell(snackbar: SnackbarHostState) { val selected = selectedPlace(backStack, items, rootEntryId) val scope = rememberCoroutineScope() val density = LocalDensity.current - val haptics = LocalHapticFeedback.current var accountOpen by rememberSaveable { mutableStateOf(false) } var editorOpen by rememberSaveable { mutableStateOf(false) } var devicesOpen by rememberSaveable { mutableStateOf(false) } @@ -371,8 +369,6 @@ private fun MainShell(snackbar: SnackbarHostState) { } } val shell = remember(items, nav) { ShellNavigator(items, goTo = go, openAccount = { accountOpen = true }, openBarEditor = { editorOpen = true }, openAvailableOffline = { nav.navigate(Route.AvailableOffline) { launchSingleTop = true } }) } - val editLabel = stringResource(R.string.bottom_bar_edit) - val openEditor = remember { { haptics.performHapticFeedback(HapticFeedbackType.LongPress); editorOpen = true } } val bottomInset = with(density) { bottomInsetPx.toDp() } CompositionLocalProvider(LocalShellNavigator provides shell, LocalTabReselected provides reselected, LocalBottomBarInset provides bottomInset) { Row(Modifier.fillMaxSize().then(hiddenWhenCovered)) { @@ -420,6 +416,7 @@ private fun MainShell(snackbar: SnackbarHostState) { onOpenArtist = navigator.openArtist, ) } + app.hocket.ui.components.SongMenuHost(songMenu, navigator) // The scrim the bar and the mini player float on (Navic): the surface colour eased in // from transparent, so content scrolling under them fades out instead of clashing. if (!wide || playerVisible) { @@ -433,8 +430,8 @@ private fun MainShell(snackbar: SnackbarHostState) { if (!wide) { // Drawn above the sheet so the collapsed sheet body never covers it; slides out as the // sheet expands and the full player takes the screen (read in the placement phase, so - // dragging the sheet does not recompose the bar). A long press opens its editor (also - // a TalkBack action on every item). + // dragging the sheet does not recompose the bar). Holding it does nothing special: the + // editor lives in Settings > Customise and the account sheet. ShortNavigationBar( // Transparent over the scrim (Navic's detached style). containerColor = Color.Transparent, @@ -445,7 +442,6 @@ private fun MainShell(snackbar: SnackbarHostState) { .offset { IntOffset(0, (sheet.progress * navBarHeightPx).roundToInt()) } // Its icons fade quickly as the player grows over them. .graphicsLayer { alpha = (1f - sheet.progress * 3f).coerceIn(0f, 1f) } - .longPressToEdit(openEditor) .testTag("navBar") .then(hiddenWhenCovered), ) { @@ -453,7 +449,7 @@ private fun MainShell(snackbar: SnackbarHostState) { val isSelected = item == selected ShortNavigationBarItem( selected = isSelected, onClick = { go(item) }, icon = { Icon(item.icon(isSelected), null) }, label = { Text(item.barLabel(), maxLines = 1) }, - modifier = Modifier.testTag("navBar." + item.id).semantics { customActions = listOf(CustomAccessibilityAction(editLabel) { editorOpen = true; true }) }, + modifier = Modifier.testTag("navBar." + item.id), ) } } @@ -505,6 +501,14 @@ private fun easedScrim(color: Color, stops: Int = 16): Brush = Brush.verticalGra }, ) +/** + * A NavHost destination drawn inside [PredictiveBackCard], so every screen (places, details, + * settings pages) takes part in the predictive back animation the same way. + */ +private inline fun NavGraphBuilder.screen(transitions: ShellTransitionInfo, noinline content: @Composable (NavBackStackEntry) -> Unit) { + composable { entry -> PredictiveBackCard(transitions, entry.id) { content(entry) } } +} + @Composable private fun AppNavHost(nav: NavHostController, transitions: ShellTransitionInfo, modifier: Modifier) { NavHost( @@ -515,39 +519,41 @@ private fun AppNavHost(nav: NavHostController, transitions: ShellTransitionInfo, exitTransition = { transitions.exit(this) }, popEnterTransition = { transitions.popEnter(this) }, popExitTransition = { transitions.popExit(this) }, + predictivePopEnterTransition = { edge -> transitions.predictivePopEnter(edge) }, + predictivePopExitTransition = { edge -> transitions.predictivePopExit(this, edge) }, sizeTransform = null, ) { - composable { HomeScreen(nav) } - composable { entry -> LibraryScreen(nav, initialTab = entry.toRoute().tab) } - composable { SearchScreen(nav) } - composable { SettingsScreen(nav) } - composable { DownloadsScreen(nav) } - composable { app.hocket.ui.screens.library.LibraryListScreen(nav, app.hocket.ui.screens.library.LibraryList.Albums) } - composable { app.hocket.ui.screens.library.LibraryListScreen(nav, app.hocket.ui.screens.library.LibraryList.Artists) } - composable { app.hocket.ui.screens.library.LibraryListScreen(nav, app.hocket.ui.screens.library.LibraryList.Playlists) } - composable { app.hocket.ui.screens.library.LibraryListScreen(nav, app.hocket.ui.screens.library.LibraryList.Songs) } - composable { app.hocket.ui.screens.library.LibraryListScreen(nav, app.hocket.ui.screens.library.LibraryList.Genres) } - composable { app.hocket.ui.screens.library.AvailableOfflineScreen(nav) } - composable { FiltersScreen(nav) } - composable { entry -> FilterBuilderScreen(nav, entry.toRoute().id) } - composable { StatsScreen(nav) } - composable { entry -> AlbumDetailScreen(nav, entry.toRoute().id) } - composable { entry -> ArtistDetailScreen(nav, entry.toRoute().id) } - composable { entry -> PlaylistDetailScreen(nav, entry.toRoute().id) } - composable { entry -> GenreDetailScreen(nav, entry.toRoute().name) } - composable { SavedQueuesScreen(nav) } - composable { AudioSettingsScreen(nav) } - composable { TranscodingSettingsScreen(nav) } - composable { ConnectSettingsScreen(nav) } - composable { CustomiseSettingsScreen(nav) } - composable { AboutScreen(nav) } - composable { AccountSettingsScreen(nav) } - composable { AppearanceSettingsScreen(nav) } - composable { PlaybackSettingsScreen(nav) } - composable { DownloadsSettingsScreen(nav) } - composable { LyricsSettingsScreen(nav) } - composable { LibrarySettingsScreen(nav) } - composable { BatterySettingsScreen(nav) } - composable { BackupSettingsScreen(nav) } + screen(transitions) { HomeScreen(nav) } + screen(transitions) { entry -> LibraryScreen(nav, initialTab = entry.toRoute().tab) } + screen(transitions) { SearchScreen(nav) } + screen(transitions) { SettingsScreen(nav) } + screen(transitions) { DownloadsScreen(nav) } + screen(transitions) { app.hocket.ui.screens.library.LibraryListScreen(nav, app.hocket.ui.screens.library.LibraryList.Albums) } + screen(transitions) { app.hocket.ui.screens.library.LibraryListScreen(nav, app.hocket.ui.screens.library.LibraryList.Artists) } + screen(transitions) { app.hocket.ui.screens.library.LibraryListScreen(nav, app.hocket.ui.screens.library.LibraryList.Playlists) } + screen(transitions) { app.hocket.ui.screens.library.LibraryListScreen(nav, app.hocket.ui.screens.library.LibraryList.Songs) } + screen(transitions) { app.hocket.ui.screens.library.LibraryListScreen(nav, app.hocket.ui.screens.library.LibraryList.Genres) } + screen(transitions) { app.hocket.ui.screens.library.AvailableOfflineScreen(nav) } + screen(transitions) { FiltersScreen(nav) } + screen(transitions) { entry -> FilterBuilderScreen(nav, entry.toRoute().id) } + screen(transitions) { StatsScreen(nav) } + screen(transitions) { entry -> AlbumDetailScreen(nav, entry.toRoute().id) } + screen(transitions) { entry -> ArtistDetailScreen(nav, entry.toRoute().id) } + screen(transitions) { entry -> PlaylistDetailScreen(nav, entry.toRoute().id) } + screen(transitions) { entry -> GenreDetailScreen(nav, entry.toRoute().name) } + screen(transitions) { SavedQueuesScreen(nav) } + screen(transitions) { AudioSettingsScreen(nav) } + screen(transitions) { TranscodingSettingsScreen(nav) } + screen(transitions) { ConnectSettingsScreen(nav) } + screen(transitions) { CustomiseSettingsScreen(nav) } + screen(transitions) { AboutScreen(nav) } + screen(transitions) { AccountSettingsScreen(nav) } + screen(transitions) { AppearanceSettingsScreen(nav) } + screen(transitions) { PlaybackSettingsScreen(nav) } + screen(transitions) { DownloadsSettingsScreen(nav) } + screen(transitions) { LyricsSettingsScreen(nav) } + screen(transitions) { LibrarySettingsScreen(nav) } + screen(transitions) { BatterySettingsScreen(nav) } + screen(transitions) { BackupSettingsScreen(nav) } } } diff --git a/android/app/src/main/java/app/hocket/ui/nav/BottomBar.kt b/android/app/src/main/java/app/hocket/ui/nav/BottomBar.kt index ec3d2c3..8ec8fae 100644 --- a/android/app/src/main/java/app/hocket/ui/nav/BottomBar.kt +++ b/android/app/src/main/java/app/hocket/ui/nav/BottomBar.kt @@ -1,8 +1,5 @@ package app.hocket.ui.nav -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -65,8 +62,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag @@ -97,9 +92,9 @@ import kotlinx.coroutines.launch import sh.calvin.reorderable.ReorderableColumn /* - * The phone's bottom bar: which places it holds (a device-local preference), what is selected, the - * long-press shortcut to its editor, and the editor itself (Settings > Customise > Bottom bar, or - * the sheet the long-press opens). + * The phone's bottom bar: which places it holds (a device-local preference), what is selected, and + * the editor itself (Settings > Customise > Bottom bar, or the sheet the account sheet's "Customise + * bottom bar" opens). Holding the bar does nothing special: a long press is not an editor shortcut. */ /** The bar's storage for this composition; AppRoot provides one so every reader shares it. */ @@ -277,25 +272,6 @@ fun NavItem.label(): String = stringResource( @Composable fun NavItem.barLabel(): String = if (this == NavItem.RecentQueues) stringResource(R.string.nav_recent_queues_short) else label() -/** - * A long press anywhere on the bar calls [onLongPress] (the editor shortcut). It watches the - * gesture before the items do, and once it fires it swallows the rest of the gesture so the item - * under the finger does not also navigate. A tap passes through untouched. - */ -fun Modifier.longPressToEdit(onLongPress: () -> Unit): Modifier = pointerInput(onLongPress) { - awaitEachGesture { - awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) - val up = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) { waitForUpOrCancellation(PointerEventPass.Initial) } - if (up == null) { - onLongPress() - do { - val event = awaitPointerEvent(PointerEventPass.Initial) - event.changes.forEach { it.consume() } - } while (event.changes.any { it.pressed }) - } - } -} - /** Saves the bar and keeps the core's `sidebar` surface in step (as the navigation items always have). */ @Composable fun rememberBarSaver(prefs: NavBarPrefs = rememberNavBarPrefs()): (List) -> Unit { diff --git a/android/app/src/main/java/app/hocket/ui/nav/Transitions.kt b/android/app/src/main/java/app/hocket/ui/nav/Transitions.kt index a9a26b6..fc20ebe 100644 --- a/android/app/src/main/java/app/hocket/ui/nav/Transitions.kt +++ b/android/app/src/main/java/app/hocket/ui/nav/Transitions.kt @@ -1,19 +1,35 @@ package app.hocket.ui.nav import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterExitState import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.Easing import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.navigation.NavBackStackEntry +import androidx.navigationevent.NavigationEvent import kotlin.math.roundToInt /* @@ -21,11 +37,15 @@ import kotlin.math.roundToInt * * - Pushes (a detail or settings screen) use Material's shared X axis: the new screen slides in * from ~30% of the width on an emphasized-decelerate curve while it fades in; the old one drifts - * 30 dp the other way while fading out quickly. Pops (and predictive back, which scrubs the pop - * transition) reverse it. + * 30 dp the other way while fading out quickly. Pops reverse it. + * - Predictive back (after Navic) has its own pair, scrubbed by the gesture: the screen being left + * becomes a card that shrinks, rounds its corners ([PredictiveBackCard]) and moves with the finger + * (toward the far side from the edge the swipe started at), while the screen behind slides in + * from a short parallax offset on that side. Letting go finishes the same motion (the card leaves + * the screen); cancelling runs it back. * - Switching places from the bottom bar or rail is a quick fade-through with a short slide in the * direction of the tab (right for a tab to the right), so it reads as a lateral move, not a push. - * - With reduced motion everything is a plain short crossfade. + * - With reduced motion everything is a plain short crossfade (predictive back scrubs a crossfade). */ /** Material's emphasized decelerate curve (entering elements). */ @@ -41,6 +61,16 @@ private const val TAB_EXIT_MS = 90 private const val REDUCED_MS = 150 private val ExitDrift = 30.dp +/** Predictive back: the length the gesture scrubs through (and so how quickly a release finishes it). */ +internal const val PREDICTIVE_MS = 350 +/** How small the card being left gets, and how round its corners. */ +private const val PREDICTIVE_SCALE = 0.9f +private val PredictiveCorner = 28.dp +/** The card's shrink (and corners) lead: most of it happens in the first third of the gesture. */ +private val PredictiveShrink = Easing { t -> 1f - (1f - (t * 2.5f).coerceAtMost(1f)).let { it * it } } +/** The card lags the finger at first, then leaves quickly (a release finishes the slide). */ +private val PredictiveSlide = CubicBezierEasing(0.4f, 0f, 1f, 1f) + /** * What the shell tells the transitions about the navigation it just made. Not snapshot state: it is * written right before `navigate` and read by the transition lambdas when the change is animated. @@ -55,6 +85,13 @@ internal class ShellTransitionInfo { var tabTargetId: String? = null var tabForward: Boolean = true var reducedMotion: Boolean = false + + /** + * The entry a predictive back gesture is currently dragging away (set when its transition is + * built, cleared by any other transition), so [PredictiveBackCard] only rounds that one. + */ + var predictiveExitId: String? = null + private set var layoutDirection: LayoutDirection = LayoutDirection.Ltr var density: Density = Density(1f) @@ -71,11 +108,14 @@ internal class ShellTransitionInfo { slideInHorizontally(tween(PUSH_MS, easing = EmphasizedDecelerate)) { w -> sign * (w * 0.3f).roundToInt() } } - fun exit(scope: AnimatedContentTransitionScope): ExitTransition = when { - reducedMotion -> fadeOut(tween(REDUCED_MS)) - isTabSwitch(scope) -> tabExit() - else -> fadeOut(tween(PUSH_EXIT_MS / 2 + 50, easing = LinearEasing)) + - slideOutHorizontally(tween(PUSH_EXIT_MS, easing = EmphasizedAccelerate)) { -sign * drift } + fun exit(scope: AnimatedContentTransitionScope): ExitTransition { + predictiveExitId = null + return when { + reducedMotion -> fadeOut(tween(REDUCED_MS)) + isTabSwitch(scope) -> tabExit() + else -> fadeOut(tween(PUSH_EXIT_MS / 2 + 50, easing = LinearEasing)) + + slideOutHorizontally(tween(PUSH_EXIT_MS, easing = EmphasizedAccelerate)) { -sign * drift } + } } fun popEnter(scope: AnimatedContentTransitionScope): EnterTransition = when { @@ -85,11 +125,45 @@ internal class ShellTransitionInfo { slideInHorizontally(tween(PUSH_MS, easing = EmphasizedDecelerate)) { -sign * drift } } - fun popExit(scope: AnimatedContentTransitionScope): ExitTransition = when { - reducedMotion -> fadeOut(tween(REDUCED_MS)) - isTabSwitch(scope) -> tabExit() - else -> fadeOut(tween(PUSH_EXIT_MS, easing = LinearEasing)) + - slideOutHorizontally(tween(PUSH_MS, easing = EmphasizedAccelerate)) { w -> sign * (w * 0.3f).roundToInt() } + fun popExit(scope: AnimatedContentTransitionScope): ExitTransition { + predictiveExitId = null + return when { + reducedMotion -> fadeOut(tween(REDUCED_MS)) + isTabSwitch(scope) -> tabExit() + else -> fadeOut(tween(PUSH_EXIT_MS, easing = LinearEasing)) + + slideOutHorizontally(tween(PUSH_MS, easing = EmphasizedAccelerate)) { w -> sign * (w * 0.3f).roundToInt() } + } + } + + /** + * Horizontal direction the predictive card moves in: away from the edge the swipe started at + * (right from the left edge, left from the right edge; physical, whatever the layout direction). + */ + private fun predictiveDirection(swipeEdge: Int): Int = when (swipeEdge) { + NavigationEvent.EDGE_LEFT -> 1 + NavigationEvent.EDGE_RIGHT -> -1 + else -> sign + } + + /** The screen behind, revealed by a back gesture: slides in from a short offset on the swipe's side. */ + fun predictivePopEnter(swipeEdge: Int): EnterTransition = when { + reducedMotion -> fadeIn(tween(PREDICTIVE_MS, easing = LinearEasing)) + else -> { + val dir = predictiveDirection(swipeEdge) + slideInHorizontally(tween(PREDICTIVE_MS, easing = EmphasizedDecelerate)) { w -> -dir * w / 6 } + } + } + + /** The screen a back gesture drags away: a shrinking card following the finger ([PredictiveBackCard] rounds it). */ + fun predictivePopExit(scope: AnimatedContentTransitionScope, swipeEdge: Int): ExitTransition { + if (reducedMotion) { + predictiveExitId = null + return fadeOut(tween(PREDICTIVE_MS, easing = LinearEasing)) + } + predictiveExitId = scope.initialState.id + val dir = predictiveDirection(swipeEdge) + return scaleOut(tween(PREDICTIVE_MS, easing = PredictiveShrink), targetScale = PREDICTIVE_SCALE) + + slideOutHorizontally(tween(PREDICTIVE_MS, easing = PredictiveSlide)) { w -> dir * w } } private fun tabEnter(): EnterTransition { @@ -101,3 +175,33 @@ internal class ShellTransitionInfo { // The outgoing tab just fades out fast: no ghosting of two pages over each other. private fun tabExit(): ExitTransition = fadeOut(tween(TAB_EXIT_MS, easing = LinearEasing)) } + +/** + * Wraps every screen in the NavHost. While a predictive back gesture drags this screen away + * ([ShellTransitionInfo.predictiveExitId]) it is drawn as a card: opaque, clipped to corners that + * round in as it shrinks, with a soft shadow over the screen revealed behind it. The amount follows + * the entry's own enter/exit transition, so the gesture scrubs it with everything else; otherwise + * this adds nothing (an instant, zero-length animation). + */ +@Composable +internal fun AnimatedVisibilityScope.PredictiveBackCard(transitions: ShellTransitionInfo, entryId: String, content: @Composable () -> Unit) { + val card by transition.animateFloat( + transitionSpec = { if (transitions.predictiveExitId == entryId) tween(PREDICTIVE_MS, easing = PredictiveShrink) else snap() }, + label = "predictiveBackCard", + ) { state -> if (state == EnterExitState.PostExit && transitions.predictiveExitId == entryId) 1f else 0f } + val background = MaterialTheme.colorScheme.background + Box( + Modifier + .fillMaxSize() + .graphicsLayer { + // Read here (not in composition), so scrubbing only redraws the layer. + val f = card + if (f > 0f) { + shape = RoundedCornerShape(PredictiveCorner * f) + clip = true + shadowElevation = 6.dp.toPx() * f + } + } + .drawBehind { if (card > 0f) drawRect(background) }, + ) { content() } +} diff --git a/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt b/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt index 335043b..03a0720 100644 --- a/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt +++ b/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt @@ -41,6 +41,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material.icons.automirrored.filled.QueueMusic +import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Bedtime import androidx.compose.material.icons.filled.Cast import androidx.compose.material.icons.filled.Close @@ -66,6 +67,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.BlendMode @@ -111,7 +113,6 @@ import app.hocket.core.api.Command import app.hocket.core.api.QueueSource import app.hocket.ui.LocalCoreClient import app.hocket.ui.a11y.LocalReducedMotion -import app.hocket.ui.components.ActionSheet import app.hocket.ui.components.Artwork import app.hocket.ui.components.PlaylistPicker import app.hocket.ui.components.formatClock @@ -123,26 +124,24 @@ import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.roundToInt import androidx.compose.ui.text.style.LineHeightStyle -import app.hocket.ui.components.RatingStars -import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.combinedClickable /** * The full player (the owner's mockup, made Material 3 Expressive). Top to bottom: * - * - "Playing from" and the queue's source (album, playlist, search...), with Connect and the collapse chevron; - * - the mode area: the big artwork in [PlayerMode.Artwork], or Lyrics / Queue / About in its place; - * - notices (resume offer, a problem, autoplay's reason, remote playback, the sleep timer); - * - the title row: a small thumbnail slot (non-artwork modes), title, "artist • album" links, and - * add to playlist and the More sheet (which holds the rating and the sleep timer); - * - the wavy seek bar with elapsed / total, the transport, and the Lyrics / Queue / About pills. + * - "Playing from" and the queue's name (a tap switches queues), with Connect and the collapse chevron; + * - the mode area: the big artwork (at its top) in [PlayerMode.Artwork], or Lyrics / Queue / About; + * - the title row, right under the artwork: a small thumbnail slot (non-artwork modes), title, + * "artist • album" links, and add to playlist and the More sheet (the rating and the sleep timer); + * - spread over what is left: notices (resume offer, a problem, autoplay's reason, remote playback, + * the sleep timer), the wavy seek bar with elapsed / total, the transport, and the Lyrics / Queue / + * About pills ([PlayerLayout]). * * There is ONE artwork ([PlayerArtwork]), drawn over the page and moved in a graphics layer between * the big slot and the thumbnail slot as [modeFraction] goes 0 (artwork) to 1 (another mode); both * slots are measured into [hero], which the sheet's flying artwork also lands on. * - * At least a screen tall: the area takes what the header and the controls leave (never less than - * [MIN_AREA]); on short screens or at large font sizes the whole page scrolls. [position] is read + * At least a screen tall; on short screens or at large font sizes the whole page scrolls. [position] is read * in the draw phase (the seek bar) and once a second (its labels), never here. */ @Composable @@ -165,8 +164,11 @@ internal fun FullPlayer( val track = entry?.track val density = LocalDensity.current var handoff by remember { mutableStateOf(false) } + var queues by remember { mutableStateOf(false) } var sleepSheet by remember { mutableStateOf(false) } - var more by remember { mutableStateOf(false) } + val songMenu = app.hocket.ui.components.rememberSongMenu() + val sleepLabel = stringResource(R.string.player_sleep_timer) + val resources = androidx.compose.ui.platform.LocalResources.current var addTo by remember { mutableStateOf(false) } hero.artInset = with(density) { PAGE_PADDING.toPx() } hero.artMax = with(density) { MAX_ART.toPx() } @@ -177,21 +179,35 @@ internal fun FullPlayer( Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).testTag("player.page")) { PlayerLayout( minHeight = viewport, - header = { PlayerHeader(onCollapse, onConnect = { handoff = true }) }, + hero = hero, + header = { PlayerHeader(onCollapse, onConnect = { handoff = true }, onSwitchQueue = { queues = true }) }, area = { ModeArea(mode, hero, lyricsVisible) }, - controls = { - PlayerControls( - mode = mode, - onMode = onMode, - onOpenAlbum = onOpenAlbum, - onOpenArtist = onOpenArtist, - position = position, - hero = hero, - pageWidth = pageWidth, - onMore = { more = true }, - onAddTo = { addTo = true }, - ) - }, + title = { PlayerTitle(mode, onOpenAlbum, onOpenArtist, hero, onMore = { + // The song menu is hosted at the app level, not in this (moving) sheet; + // the player adds the sleep timer as its extra. + val sleep = client.sleepTimer.value + val sleepState = sleep?.let { t -> t.endsAt?.let { resources.getString(R.string.sleep_active, formatClock((it - System.currentTimeMillis()).toLong().coerceAtLeast(0))) } ?: resources.getString(R.string.sleep_active_end_of_track) } + track?.let { songMenu.open(it, extras = listOf(app.hocket.ui.components.SongMenuExtra(sleepLabel, Icons.Filled.Bedtime, + onClick = { sleepSheet = true }, supporting = sleepState, highlighted = sleep != null, testTag = "player.sleep"))) } + }, onAddTo = { addTo = true }) }, + items = listOf( + { PlayerNoticeLines(onConnect = { handoff = true }) }, + { PlayerSeek(position) }, + { + val client = LocalCoreClient.current + val playing by client.isPlaying.collectAsStateWithLifecycle() + // Narrow screens (display size "largest" leaves ~320 dp): a smaller transport. + TransportRow( + playing = playing, + onPrevious = { client.dispatch(Command.Previous) }, + onToggle = { client.dispatch(Command.TogglePlay) }, + onNext = { client.dispatch(Command.Next) }, + height = if (pageWidth < 360.dp) 68.dp else 80.dp, + modifier = Modifier.padding(horizontal = PAGE_PADDING), + ) + }, + { Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 8.dp)) { ModeBar(mode, onMode, pageWidth = pageWidth) } }, + ), ) } PlayerArtwork( @@ -207,15 +223,8 @@ internal fun FullPlayer( ) } if (handoff) HandoffSheet(onDismiss = { handoff = false }) + if (queues) QueueSwitcherSheet(onDismiss = { queues = false }) if (sleepSheet) SleepTimerSheet(onDismiss = { sleepSheet = false }) - if (more && track != null) ActionSheet(Commands.tracks(listOf(track.id)), track.title, track.artist, onDismiss = { more = false }, - onGoToAlbum = track.albumId?.let { id -> { onOpenAlbum(id) } }, onGoToArtist = track.artistId?.let { id -> { onOpenArtist(id) } }, - extraTop = { - // As in Navic: the rating across the top of the song menu, then the sleep timer. - RatingStars(track.rating.toInt(), onRate = { client.dispatch(Commands.rateTrack(track.id, it)) }, starSize = 32.dp, tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.CenterHorizontally).padding(vertical = 4.dp)) - SleepTimerMenuRow(onClick = { more = false; sleepSheet = true }) - }) if (addTo && track != null) PlaylistPicker(Commands.tracks(listOf(track.id)), onDismiss = { addTo = false }) } @@ -224,45 +233,99 @@ private val PAGE_PADDING = 24.dp private val MAX_ART = 520.dp private val MIN_AREA = 220.dp +/** The least gap above each of the [PlayerLayout] items (notices, seek bar, transport, mode pills). */ +private val ITEM_GAPS = listOf(0.dp, 4.dp, 8.dp, 12.dp) + /** - * Header, mode area and controls stacked: the header and the controls take what they need, the - * area what is left of [minHeight] (at least [MIN_AREA]). Laid out inside the page's vertical - * scroll, so it can be taller than the screen. + * The page's layout. In artwork mode: the header, the artwork at the top of the mode area (as large + * as the width allows, up to [MAX_ART], shrinking on short screens but never below what [MIN_AREA] + * leaves), the title right below it, and the [items] spread evenly over what is left of + * [minHeight] (the same share above each and below the last, on top of [ITEM_GAPS]). In the other + * modes the area takes all that room (at least [MIN_AREA]) and the title and items stack at the + * bottom with just [ITEM_GAPS]. Between the two it follows [HeroGeometry.modeFraction], read here in + * the layout phase (the mode's animation relays out, never recomposes). The area's content is + * always laid out at its full (other-mode) height and clipped to the visible part, so lyrics and the + * queue do not relayout as it opens. Inside the page's vertical scroll, so it can be taller than + * the screen. The artwork's side goes to [HeroGeometry.artSide]. */ @Composable -private fun PlayerLayout(minHeight: Dp, header: @Composable () -> Unit, area: @Composable () -> Unit, controls: @Composable () -> Unit) { - Layout(contents = listOf(header, area, controls)) { (h, a, c), constraints -> - val loose = Constraints(maxWidth = constraints.maxWidth) - val hp = h.map { it.measure(loose) } - val cp = c.map { it.measure(loose) } +private fun PlayerLayout( + minHeight: Dp, + hero: HeroGeometry, + header: @Composable () -> Unit, + area: @Composable () -> Unit, + title: @Composable () -> Unit, + items: List<@Composable () -> Unit>, +) { + Layout(contents = listOf(header, area, title) + items) { slots, constraints -> + val w = constraints.maxWidth + val loose = Constraints(maxWidth = w) + val hp = slots[0].map { it.measure(loose) } + val tp = slots[2].map { it.measure(loose) } + val ip = slots.drop(3).map { s -> s.map { it.measure(loose) } } val hh = hp.maxOfOrNull { it.height } ?: 0 - val ch = cp.maxOfOrNull { it.height } ?: 0 - val areaH = maxOf(MIN_AREA.roundToPx(), minHeight.roundToPx() - hh - ch) - val ap = a.map { it.measure(Constraints.fixed(constraints.maxWidth, areaH)) } - layout(constraints.maxWidth, hh + areaH + ch) { + val th = tp.maxOfOrNull { it.height } ?: 0 + val ih = ip.map { p -> p.maxOfOrNull { it.height } ?: 0 } + // An empty item (no notices) takes no gap either. + val gaps = ih.mapIndexed { i, h -> if (h > 0) ITEM_GAPS.getOrElse(i) { 0.dp }.roundToPx() else 0 } + val rest = ih.sum() + gaps.sum() + val view = minHeight.roundToPx() + val pad = 8.dp.roundToPx() + val minArea = MIN_AREA.roundToPx() + // Artwork mode: the largest artwork that leaves room for the rest. + val maxSide = minOf(w - 2 * PAGE_PADDING.roundToPx(), MAX_ART.roundToPx()) + val side = (view - hh - th - rest - 2 * pad).coerceIn(minOf(minArea - 2 * pad, maxSide), maxSide).coerceAtLeast(0) + hero.artSide = side.toFloat() + val artArea = side + 2 * pad + val spare = (view - hh - artArea - th - rest).coerceAtLeast(0) + val shares = ih.count { it > 0 } + 1 + // Other modes: the area takes it all. + val fullArea = maxOf(minArea, view - hh - th - rest) + val f = hero.modeFraction().coerceIn(0f, 1f) + val areaH = lerpF(artArea.toFloat(), fullArea.toFloat(), f).roundToInt() + val share = spare * (1f - f) / shares + val ap = slots[1].map { it.measure(Constraints(minWidth = w, maxWidth = w, minHeight = areaH, maxHeight = maxOf(areaH, fullArea))) } + var y = hh + areaH + th + val tops = ih.mapIndexed { i, h -> if (h > 0) { y += gaps[i] + share.roundToInt(); val top = y; y += h; top } else y } + layout(w, maxOf(y + share.roundToInt(), view)) { hp.forEach { it.place(0, 0) } ap.forEach { it.place(0, hh) } - cp.forEach { it.place(0, hh + areaH) } + tp.forEach { it.place(0, hh + areaH) } + ip.forEachIndexed { i, p -> p.forEach { it.place(0, tops[i]) } } } } } -/** "Playing from" and the queue's source, and the collapse chevron. */ +/** + * "Playing from" and the queue's name (its context: the album, playlist, search...; "Autoplay" once + * autoplay has taken over a queue without one), Connect and the collapse chevron. Tapping the name + * opens the queue switcher (the recent and pinned queues). + */ @Composable -private fun PlayerHeader(onCollapse: () -> Unit, onConnect: () -> Unit) { +private fun PlayerHeader(onCollapse: () -> Unit, onConnect: () -> Unit, onSwitchQueue: () -> Unit) { val client = LocalCoreClient.current val owns by client.ownsTransport.collectAsStateWithLifecycle() val queue by client.queue.collectAsStateWithLifecycle() val entry by client.nowPlaying.collectAsStateWithLifecycle() - val source = when { - entry?.item?.source is QueueSource.Autoplay -> stringResource(R.string.player_playing_from_autoplay) - else -> queue.contextLabel?.takeIf { it.isNotBlank() } - } ?: stringResource(R.string.player_sheet_title) + val source = queue.contextLabel?.takeIf { it.isNotBlank() } + ?: (if (entry?.item?.source is QueueSource.Autoplay) stringResource(R.string.player_playing_from_autoplay) else null) + ?: stringResource(R.string.player_sheet_title) val sourceDesc = stringResource(R.string.player_playing_from_a11y, source) - Row(Modifier.fillMaxWidth().statusBarsPadding().padding(start = PAGE_PADDING, end = 12.dp, top = 12.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f).semantics(mergeDescendants = true) { contentDescription = sourceDesc }.testTag("player.source")) { - Text(stringResource(R.string.player_playing_from), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) - Text(source, style = MaterialTheme.typography.titleLargeEmphasized, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + val switchLabel = stringResource(R.string.player_switch_queue) + Row(Modifier.fillMaxWidth().statusBarsPadding().padding(start = PAGE_PADDING - 8.dp, end = 12.dp, top = 12.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Row( + Modifier.weight(1f).clip(RoundedCornerShape(12.dp)) + .clickable(onClickLabel = switchLabel, role = Role.Button, onClick = onSwitchQueue) + .semantics(mergeDescendants = true) { contentDescription = sourceDesc } + .padding(horizontal = 8.dp, vertical = 2.dp) + .testTag("player.source"), + verticalAlignment = Alignment.Bottom, + ) { + Column(Modifier.weight(1f, fill = false)) { + Text(stringResource(R.string.player_playing_from), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + Text(source, style = MaterialTheme.typography.titleLargeEmphasized, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Icon(Icons.Filled.ArrowDropDown, null, Modifier.padding(bottom = 2.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) } // Connect, highlighted while another device plays. Tap opens the picker; long-press pulls // playback straight to this device. @@ -297,12 +360,23 @@ private fun PlayerHeader(onCollapse: () -> Unit, onConnect: () -> Unit) { /** * The mode area: empty in artwork mode (the one artwork is drawn over it), else Lyrics, Queue or * About, crossfading with a slight grow as the artwork shrinks away. Its bounds go to [hero] (the - * big artwork is centred in them). Lists fade out at the area's top and bottom edges. + * big artwork sits at their top). Lists fade out at the area's top and bottom edges. + * + * [PlayerLayout] gives it its visible height as the least height and its full (other-mode) height + * as the most: the content is laid out at the full height and clipped to the visible part. */ @Composable private fun ModeArea(mode: PlayerMode, hero: HeroGeometry, lyricsVisible: Boolean) { val reduced = LocalReducedMotion.current - Box(Modifier.fillMaxSize().onGloballyPositioned { hero.area = hero.measure(it) }) { + Box( + Modifier + .onGloballyPositioned { hero.area = hero.measure(it) } + .clipToBounds() + .layout { measurable, constraints -> + val placeable = measurable.measure(Constraints.fixed(constraints.maxWidth, constraints.maxHeight)) + layout(constraints.maxWidth, constraints.minHeight) { placeable.place(0, 0) } + }, + ) { AnimatedContent( targetState = mode, transitionSpec = { @@ -334,70 +408,88 @@ private fun Modifier.fadingEdges(): Modifier = this drawRect(Brush.verticalGradient(0f to Color.Black, 1f to Color.Transparent, startY = size.height - bottom, endY = size.height), blendMode = BlendMode.DstIn, topLeft = Offset(0f, size.height - bottom), size = size.copy(height = bottom)) } +/** The title row ([TitleRow]), or what to do when nothing is queued. */ @Composable -private fun PlayerControls( - mode: PlayerMode, - onMode: (PlayerMode) -> Unit, - onOpenAlbum: (String) -> Unit, - onOpenArtist: (String) -> Unit, - position: () -> Long, - hero: HeroGeometry, - pageWidth: Dp, - onMore: () -> Unit, - onAddTo: () -> Unit, -) { +private fun PlayerTitle(mode: PlayerMode, onOpenAlbum: (String) -> Unit, onOpenArtist: (String) -> Unit, hero: HeroGeometry, onMore: () -> Unit, onAddTo: () -> Unit) { + val client = LocalCoreClient.current + val entry by client.nowPlaying.collectAsStateWithLifecycle() + val track = entry?.track + Column(Modifier.fillMaxWidth().padding(horizontal = PAGE_PADDING)) { + if (track == null) { + Column(Modifier.fillMaxWidth().padding(vertical = 16.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text(stringResource(R.string.empty_queue_title), style = MaterialTheme.typography.headlineSmall) + Text(stringResource(R.string.empty_queue_body), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + } + } else { + TitleRow(track, compact = mode != PlayerMode.Artwork, onOpenAlbum, onOpenArtist, hero, onMore, onAddTo) + } + } +} + +/** + * Notices: skipped-unavailable, resume offer, autoplay "why", remote playback (a tap opens the + * device picker), sleep timer. Nothing at all when there are none (the layout then gives it no gap). + */ +@Composable +private fun PlayerNoticeLines(onConnect: () -> Unit) { val client = LocalCoreClient.current val entry by client.nowPlaying.collectAsStateWithLifecycle() - val playing by client.isPlaying.collectAsStateWithLifecycle() - val transport by client.transport.collectAsStateWithLifecycle() val playerNotice by client.playerNotice.collectAsStateWithLifecycle() val notice = playerNoticeText(playerNotice) val resume by client.resumeOffer.collectAsStateWithLifecycle() val sleep by client.sleepTimer.collectAsStateWithLifecycle() val devices by client.devices.collectAsStateWithLifecycle() val owns by client.ownsTransport.collectAsStateWithLifecycle() - val reducedMotion = LocalReducedMotion.current - val track = entry?.track - Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 8.dp)) { - Column(Modifier.fillMaxWidth().padding(horizontal = PAGE_PADDING)) { - // Notices: skipped-unavailable, resume offer, autoplay "why", remote playback, sleep timer. - notice?.let { NoticeLine(it, MaterialTheme.colorScheme.error) } - resume?.let { offer -> - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.player_resume_offer, offer.deviceName, offer.track.title), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) - TextButton(onClick = { client.dispatch(Command.ResumeHere) }, modifier = Modifier.testTag("player.resumeHere")) { Text(stringResource(R.string.player_resume_here)) } - IconButton(onClick = { client.dispatch(Command.DismissResumeOffer) }) { Icon(Icons.Filled.Close, stringResource(R.string.action_dismiss)) } - } - } - (entry?.item?.source as? QueueSource.Autoplay)?.let { NoticeLine(stringResource(R.string.player_autoplay_reason, it.data.reason), MaterialTheme.colorScheme.tertiary) } - if (!owns) devices.firstOrNull { it.playing }?.let { NoticeLine(stringResource(R.string.player_playing_on, it.name), MaterialTheme.colorScheme.primary) } - sleep?.let { t -> - val label = t.endsAt?.let { stringResource(R.string.sleep_active, formatClock((it - System.currentTimeMillis()).toLong().coerceAtLeast(0))) } ?: stringResource(R.string.sleep_active_end_of_track) - NoticeLine(label, MaterialTheme.colorScheme.onSurfaceVariant) - } - if (track == null) { - Column(Modifier.fillMaxWidth().padding(vertical = 16.dp), horizontalAlignment = Alignment.CenterHorizontally) { - Text(stringResource(R.string.empty_queue_title), style = MaterialTheme.typography.headlineSmall) - Text(stringResource(R.string.empty_queue_body), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) - } - } else { - TitleRow(track, compact = mode != PlayerMode.Artwork, onOpenAlbum, onOpenArtist, hero, onMore, onAddTo) - Spacer(Modifier.height(4.dp)) - WavySeekBar(position = position, durationMs = track.durationMs.toLong(), playing = playing && !reducedMotion, onSeek = { client.dispatch(Commands.seekTo(it)) }) - if (transport.buffering) Text(stringResource(R.string.player_buffering), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Column(Modifier.fillMaxWidth().padding(horizontal = PAGE_PADDING)) { + notice?.let { NoticeLine(it, MaterialTheme.colorScheme.error) } + resume?.let { offer -> + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.player_resume_offer, offer.deviceName, offer.track.title), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + TextButton(onClick = { client.dispatch(Command.ResumeHere) }, modifier = Modifier.testTag("player.resumeHere")) { Text(stringResource(R.string.player_resume_here)) } + IconButton(onClick = { client.dispatch(Command.DismissResumeOffer) }) { Icon(Icons.Filled.Close, stringResource(R.string.action_dismiss)) } } - Spacer(Modifier.height(8.dp)) - // Narrow screens (display size "largest" leaves ~320 dp): a smaller transport. - TransportRow( - playing = playing, - onPrevious = { client.dispatch(Command.Previous) }, - onToggle = { client.dispatch(Command.TogglePlay) }, - onNext = { client.dispatch(Command.Next) }, - height = if (pageWidth < 360.dp) 68.dp else 80.dp, - ) - Spacer(Modifier.height(12.dp)) } - ModeBar(mode, onMode, pageWidth = pageWidth) + (entry?.item?.source as? QueueSource.Autoplay)?.let { NoticeLine(stringResource(R.string.player_autoplay_reason, it.data.reason), MaterialTheme.colorScheme.tertiary) } + if (!owns) devices.firstOrNull { it.playing }?.let { PlayingOnLine(it.name, onConnect) } + sleep?.let { t -> + val label = t.endsAt?.let { stringResource(R.string.sleep_active, formatClock((it - System.currentTimeMillis()).toLong().coerceAtLeast(0))) } ?: stringResource(R.string.sleep_active_end_of_track) + NoticeLine(label, MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +/** + * "Playing on " while another device plays: a tap (a 48 dp tall target) opens the device + * picker, as Connect in the header does. + */ +@Composable +private fun PlayingOnLine(device: String, onConnect: () -> Unit) { + val color = MaterialTheme.colorScheme.primary + Row( + Modifier.fillMaxWidth().heightIn(min = 48.dp).clip(RoundedCornerShape(12.dp)) + .clickable(onClickLabel = stringResource(R.string.player_connect), role = Role.Button, onClick = onConnect) + .testTag("player.playingOn"), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Filled.Cast, null, Modifier.size(18.dp), tint = color) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.player_playing_on, device), style = MaterialTheme.typography.labelLarge, color = color, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) + Icon(Icons.Filled.ArrowDropDown, null, tint = color) + } +} + +/** The wavy seek bar with elapsed / total (and "buffering" under it while it is). */ +@Composable +private fun PlayerSeek(position: () -> Long) { + val client = LocalCoreClient.current + val entry by client.nowPlaying.collectAsStateWithLifecycle() + val playing by client.isPlaying.collectAsStateWithLifecycle() + val transport by client.transport.collectAsStateWithLifecycle() + val reducedMotion = LocalReducedMotion.current + val track = entry?.track ?: return + Column(Modifier.fillMaxWidth().padding(horizontal = PAGE_PADDING)) { + WavySeekBar(position = position, durationMs = track.durationMs.toLong(), playing = playing && !reducedMotion, onSeek = { client.dispatch(Commands.seekTo(it)) }) + if (transport.buffering) Text(stringResource(R.string.player_buffering), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } @@ -448,8 +540,8 @@ private fun TitleRow(track: app.hocket.core.api.TrackSummary, compact: Boolean, val subColor = MaterialTheme.colorScheme.onSurfaceVariant Text(track.artist ?: stringResource(R.string.unknown_artist), style = sub, color = subColor, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false).then(track.artistId?.let { id -> Modifier.textLink(goArtist) { onOpenArtist(id) } } ?: Modifier).testTag("player.artist")) - // Beside the thumbnail there is room for the artist only (the album is in About). - track.album?.takeIf { !compact }?.let { album -> + // Beside the thumbnail too: both shrink to share the row, each ellipsised. + track.album?.let { album -> Text(stringResource(R.string.dot_separator), style = sub, color = subColor, modifier = Modifier.clearAndSetSemantics { }) Text(album, style = sub, color = subColor, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false).then(track.albumId?.let { id -> Modifier.textLink(goAlbum) { onOpenAlbum(id) } } ?: Modifier).testTag("player.album")) @@ -488,20 +580,6 @@ private fun ModeBar(mode: PlayerMode, onMode: (PlayerMode) -> Unit, pageWidth: D } } -/** The More sheet's sleep-timer entry: the remaining time when one is set. */ -@Composable -private fun SleepTimerMenuRow(onClick: () -> Unit) { - val client = LocalCoreClient.current - val sleep by client.sleepTimer.collectAsStateWithLifecycle() - androidx.compose.material3.ListItem( - headlineContent = { Text(stringResource(R.string.player_sleep_timer)) }, - supportingContent = sleep?.let { t -> { Text(t.endsAt?.let { stringResource(R.string.sleep_active, formatClock((it - System.currentTimeMillis()).toLong().coerceAtLeast(0))) } ?: stringResource(R.string.sleep_active_end_of_track)) } }, - leadingContent = { Icon(Icons.Filled.Bedtime, null, tint = if (sleep != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) }, - colors = androidx.compose.material3.ListItemDefaults.colors(containerColor = Color.Transparent), - modifier = Modifier.clickable(onClick = onClick).testTag("player.sleep"), - ) -} - private val PILL_PADDING = 12.dp private val PILL_ICON = 20.dp private val PILL_GAP = 6.dp diff --git a/android/app/src/main/java/app/hocket/ui/player/NowPlayingSheet.kt b/android/app/src/main/java/app/hocket/ui/player/NowPlayingSheet.kt index 556c60d..986f636 100644 --- a/android/app/src/main/java/app/hocket/ui/player/NowPlayingSheet.kt +++ b/android/app/src/main/java/app/hocket/ui/player/NowPlayingSheet.kt @@ -204,7 +204,7 @@ fun miniPlayerHeight(): Dp { /** * Where the artwork is, for the one artwork of the full player and the one that flies between the * players while the sheet moves. Measured relative to the sheet ([root]): the mini player's - * thumbnail ([thumb]), the full player's mode area ([area], the big artwork is centred in it) and + * thumbnail ([thumb]), the full player's mode area ([area], the big artwork sits at its top) and * the thumbnail slot beside the title ([small]). [playerArt] is where the full player's artwork is * for a mode fraction (0 = big, 1 = thumbnail); the sheet interpolates from [thumb] to it. */ @@ -218,6 +218,8 @@ internal class HeroGeometry { var artInset = 0f var artMax = Float.MAX_VALUE var artPad = 0f + /** The big artwork's side (px) as the full player's layout sized it for artwork mode; 0 until then. */ + var artSide by mutableStateOf(0f) /** The corners at both ends (px), set from the density. */ var bigCorner = 0f var smallCorner = 0f @@ -230,13 +232,17 @@ internal class HeroGeometry { return r.localBoundingBoxOf(coordinates, clipBounds = false) } - /** The big artwork's square: as large as its area allows (up to [artMax]), centred in it. */ + /** + * The big artwork's square, centred across the top of its area: [artSide] (the same in every + * mode, so the artwork does not resize while the area grows for lyrics or the queue), or before + * that is known as large as the area allows (up to [artMax]). + */ fun bigSlot(): Rect { val a = area if (a.width <= 0f || a.height <= 0f) return Rect.Zero - val side = minOf(a.width - 2 * artInset, a.height - 2 * artPad, artMax) + val side = if (artSide > 0f) minOf(artSide, a.width - 2 * artInset) else minOf(a.width - 2 * artInset, a.height - 2 * artPad, artMax) if (side <= 0f) return Rect.Zero - return Rect(Offset(a.center.x - side / 2, a.center.y - side / 2), Size(side, side)) + return Rect(Offset(a.center.x - side / 2, a.top + artPad), Size(side, side)) } /** Where the full player's artwork is at mode fraction [f], shrunk to [pausedScale] while big. */ @@ -334,7 +340,12 @@ fun NowPlayingSheet(state: NowPlayingSheetState, bottomInset: Dp, onOpenAlbum: ( val scrimShown by remember(state) { derivedStateOf { state.progress > 0f } } val scrimTappable by remember(state) { derivedStateOf { state.progress > 0.5f } } val settledOpen by remember(state) { derivedStateOf { state.progress > 0.9f } } - val flying by remember(state) { derivedStateOf { state.progress.let { it > FLY_EPSILON && it < 1f - FLY_EPSILON } } } + // Only the big artwork flies to and from the mini player. In lyrics / queue / about the + // artwork is a thumbnail beside the title near the bottom of the page: flown opaque from + // there to the mini player's thumbnail it would float over the fading, shrinking sheet and + // over the screen behind it, so instead it stays on the page (sliding and fading with it) + // while the mini player's own thumbnail fades in with the bar. + val flying by remember(state) { derivedStateOf { state.mode == PlayerMode.Artwork && state.progress.let { it > FLY_EPSILON && it < 1f - FLY_EPSILON } } } // Scrim behind the sheet (a pointer affordance only: the collapse button and the sheet's // collapse/dismiss actions are the accessible way out). @@ -474,9 +485,9 @@ fun NowPlayingSheet(state: NowPlayingSheetState, bottomInset: Dp, onOpenAlbum: ( MiniPlayerBar(onExpand = { scope.launch { state.expand() } }, position = readPosition, hero = hero, thumbHidden = { flying }) } } - // The one artwork that flies from the mini player's thumbnail to wherever the full - // player's artwork is (big, or small beside the title) while the sheet moves (both - // of those hide meanwhile). Decorative. + // The one artwork that flies from the mini player's thumbnail to the full player's + // big artwork while the sheet moves in artwork mode (both of those hide meanwhile; + // see `flying`). Decorative. FlyingArtwork(coverArt, hero, progress = { state.progress }, flying = { flying }, modeFraction = { modeAnim.value }, scale = { artScale.value }) } } @@ -505,8 +516,8 @@ private class SheetShape(private val inset: Float, private val corner: Float, pr /** * The artwork in flight: laid out at the big artwork's size and moved/scaled in a graphics layer - * from the mini player's thumbnail to wherever the full player's artwork is (the big slot, or the - * thumbnail beside the title in a non-artwork mode; both measured, so it lands exactly however the + * from the mini player's thumbnail to wherever the full player's artwork is (the big slot, or on + * its way to the thumbnail beside the title if the mode is changing; measured, so it lands exactly however the * page is scrolled or the status bar is sized). Its corners go from the thumbnail's to the target's, * and it gains the target's shadow. With nothing measured yet it just fades out from the thumbnail. */ diff --git a/android/app/src/main/java/app/hocket/ui/player/QueueSwitcherSheet.kt b/android/app/src/main/java/app/hocket/ui/player/QueueSwitcherSheet.kt new file mode 100644 index 0000000..9691570 --- /dev/null +++ b/android/app/src/main/java/app/hocket/ui/player/QueueSwitcherSheet.kt @@ -0,0 +1,34 @@ +package app.hocket.ui.player + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import app.hocket.R +import app.hocket.ui.queue.RecentQueuesList + +/** + * The queue switcher, from "Playing from" in the full player: the recent and pinned queues (the + * same list as the Recent queues screen); picking one restores it and closes the sheet. + */ +@Composable +fun QueueSwitcherSheet(onDismiss: () -> Unit) { + ModalBottomSheet(onDismissRequest = onDismiss, modifier = Modifier.testTag("queueSwitcher.sheet")) { + Column(Modifier.navigationBarsPadding()) { + Text(stringResource(R.string.home_saved_queues), style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(horizontal = 24.dp).semantics { heading() }) + RecentQueuesList(Modifier.fillMaxWidth().heightIn(min = 240.dp), contentPadding = PaddingValues(top = 8.dp, bottom = 16.dp), onRestore = onDismiss) + } + } +} diff --git a/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt b/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt index 161940c..cc3cc74 100644 --- a/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt +++ b/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AllInclusive import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.GraphicEq import androidx.compose.material.icons.filled.Repeat @@ -39,12 +38,9 @@ import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.SwipeToDismissBox -import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.ToggleButtonColors -import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalDensity @@ -59,7 +55,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType @@ -83,27 +78,38 @@ import app.hocket.core.ArtworkSizes import app.hocket.core.Commands import app.hocket.core.api.Command import app.hocket.core.api.QueueEntry +import app.hocket.core.api.QueueKey +import app.hocket.core.api.QueueView +import app.hocket.core.SwipeOptions import app.hocket.core.api.QueueSource import app.hocket.core.api.RepeatMode import app.hocket.core.client.SelectionKind import app.hocket.ui.LocalCoreClient -import app.hocket.ui.components.ActionSheet import app.hocket.ui.components.Artwork import app.hocket.ui.components.EmptyState import app.hocket.ui.components.ListArtCorner import app.hocket.ui.components.OfflineBadge +import app.hocket.ui.components.PlaylistPicker +import app.hocket.ui.components.SwipeActionBox +import app.hocket.ui.components.SwipeSurface +import app.hocket.ui.components.swipeActionIds +import app.hocket.ui.components.trackSwipeAction import app.hocket.ui.components.RatingDialog import app.hocket.ui.components.SelectionToolbar import app.hocket.ui.components.offlineStateText import app.hocket.ui.components.trackLabel import app.hocket.ui.components.trackRowActions import app.hocket.core.ActionIds +import kotlinx.coroutines.delay import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState /** How long after the user last scrolled the queue it stops following the current track. */ private const val USER_SCROLL_GRACE_MS = 8_000L +/** How long a dropped drag keeps its order on screen waiting for the core's queue. */ +private const val DROP_SETTLE_MS = 2_000L + private const val NOW_KEY = "hdr:c" private const val FOOTER_KEY = "footer" @@ -181,11 +187,59 @@ private sealed interface Line { enum class Clear { History, Next } } +/** + * A drag in progress (or just dropped): the movable keys (playing next + upcoming) in the order the + * finger has put them, over the queue as it was when the drag started. The core re-keys an item + * that moves (a context item's `ctx-` follows the context list; one moved into playing next + * gets a new key), so sending a move per slot lost the dragged item after the first one. The list + * reorders this local copy while the finger is down and the core gets ONE move when it lifts; + * [dropped] keeps the new order on screen until the core's queue arrives. + */ +private data class QueueDrag(val base: QueueView, val key: QueueKey, val order: List, val dropped: Boolean = false) + +/** Playing next + upcoming keys: the combined index `MoveQueueItem` takes. */ +private fun QueueView.movableKeys(): List = playingNext.map { it.item.key } + upcoming.map { it.item.key } + +private data class TimelineLabels(val history: String, val now: String, val next: String, val continuing: String, val from: String?, val autoplay: String) + +/** + * The timeline's rows for [queue]; with a [drag], its items in the drag's order. The dragged item + * takes the section the core will give it: playing next when it is among the insertions (the core + * then makes it one), else among the context / autoplay items. + */ +private fun timelineRows(queue: QueueView, drag: QueueDrag?, labels: TimelineLabels): List = buildList { + if (queue.history.isNotEmpty()) { + add(Line.Header("h", labels.history, clear = Line.Clear.History)) + queue.history.forEach { add(Line.Item(it, Line.Section.History)) } + } + queue.current?.let { add(Line.Header("c", labels.now)); add(Line.Item(it, Line.Section.Current)) } + var next = queue.playingNext + var rest = queue.upcoming + if (drag != null) { + val entries = (queue.playingNext + queue.upcoming).associateBy { it.item.key } + val ordered = drag.order.mapNotNull { entries[it] } + val otherInsertions = queue.playingNext.count { it.item.key != drag.key } + val at = drag.order.indexOf(drag.key) + val nextCount = otherInsertions + if (at in 0 until otherInsertions) 1 else 0 + next = ordered.take(nextCount) + rest = ordered.drop(nextCount) + } + if (next.isNotEmpty()) { + add(Line.Header("n", labels.next, clear = Line.Clear.Next)) + next.forEach { add(Line.Item(it, Line.Section.Next)) } + } + val (auto, ctx) = rest.partition { it.item.source is QueueSource.Autoplay } + if (ctx.isNotEmpty()) { add(Line.Header("u", labels.continuing, labels.from)); ctx.forEach { add(Line.Item(it, Line.Section.Upcoming)) } } + if (auto.isNotEmpty()) { add(Line.Header("a", labels.autoplay)); auto.forEach { add(Line.Item(it, Line.Section.Autoplay)) } } + if (isNotEmpty()) add(Line.Footer) +} + /** * The one scrollable list: history above (oldest first), the current item, then "Playing next" * (insertions) and "Continue playing · From " (the permuted context), then autoplay with * its "why". It opens at "Now playing" so history is only revealed by scrolling up. Drag handles - * reorder (haptics), swipe removes (undo toast from the core), tap jumps (history: play again). + * reorder (haptics; one move per drag), swipes run the queue's swipe actions (remove by default, + * undo toast from the core), tap jumps (history: play again). */ @Composable private fun QueueTimeline(modifier: Modifier, contentPadding: PaddingValues) { @@ -196,49 +250,59 @@ private fun QueueTimeline(modifier: Modifier, contentPadding: PaddingValues) { val selecting = selection.active && kind == SelectionKind.QueueItems val haptics = LocalHapticFeedback.current val density = LocalDensity.current - var sheetFor by remember { mutableStateOf(null) } + val songMenu = app.hocket.ui.components.rememberSongMenu() var ratingFor by remember { mutableStateOf(null) } - val historyLabel = stringResource(R.string.queue_section_history) - val nowLabel = stringResource(R.string.queue_now) - val nextLabel = stringResource(R.string.queue_playing_next) - val continueLabel = stringResource(R.string.queue_section_continue) - val fromLabel = queue.contextLabel?.let { stringResource(R.string.queue_section_from, it) } - val autoplayLabel = stringResource(R.string.queue_autoplay_section) + var pickerFor by remember { mutableStateOf(null) } + val labels = TimelineLabels( + history = stringResource(R.string.queue_section_history), + now = stringResource(R.string.queue_now), + next = stringResource(R.string.queue_playing_next), + continuing = stringResource(R.string.queue_section_continue), + from = queue.contextLabel?.let { stringResource(R.string.queue_section_from, it) }, + autoplay = stringResource(R.string.queue_autoplay_section), + ) val removeLabel = stringResource(R.string.action_remove_from_queue) val moveUpLabel = stringResource(R.string.a11y_move_up) val moveDownLabel = stringResource(R.string.a11y_move_down) - val rows: List = remember(queue, historyLabel, nowLabel, nextLabel, continueLabel, fromLabel, autoplayLabel) { - buildList { - if (queue.history.isNotEmpty()) { - add(Line.Header("h", historyLabel, clear = Line.Clear.History)) - queue.history.forEach { add(Line.Item(it, Line.Section.History)) } - } - queue.current?.let { add(Line.Header("c", nowLabel)); add(Line.Item(it, Line.Section.Current)) } - if (queue.playingNext.isNotEmpty()) { - add(Line.Header("n", nextLabel, clear = Line.Clear.Next)) - queue.playingNext.forEach { add(Line.Item(it, Line.Section.Next)) } - } - val (auto, ctx) = queue.upcoming.partition { it.item.source is QueueSource.Autoplay } - if (ctx.isNotEmpty()) { add(Line.Header("u", continueLabel, fromLabel)); ctx.forEach { add(Line.Item(it, Line.Section.Upcoming)) } } - if (auto.isNotEmpty()) { add(Line.Header("a", autoplayLabel)); auto.forEach { add(Line.Item(it, Line.Section.Autoplay)) } } - if (isNotEmpty()) add(Line.Footer) - } + val (swipeStart, swipeEnd) = swipeActionIds(SwipeSurface.Queue) + var drag by remember { mutableStateOf(null) } + // A dropped drag gives way to the core's queue as soon as that changes, or after a while if + // nothing changes (the core refused the move). + LaunchedEffect(queue, drag?.dropped) { + val d = drag?.takeIf { it.dropped } ?: return@LaunchedEffect + if (queue != d.base) { drag = null; return@LaunchedEffect } + delay(DROP_SETTLE_MS) + if (drag === d) drag = null } + // While a drag is under way the list shows the queue as it was when the drag started. + val shown = drag?.base ?: queue + val rows: List = remember(shown, drag, labels) { timelineRows(shown, drag, labels) } if (queue.current == null && rows.isEmpty()) { EmptyState(stringResource(R.string.empty_queue_title), stringResource(R.string.empty_queue_body), modifier) return } val listState = rememberLazyListState() // Combined index into playing-next + upcoming, which is what MoveQueueItem takes. - val movable = remember(queue) { queue.playingNext.map { it.item.key } + queue.upcoming.map { it.item.key } } + val movable = remember(queue) { queue.movableKeys() } val reorderable = rememberReorderableLazyListState(listState) { from, to -> - val fromKey = from.key as? String ?: return@rememberReorderableLazyListState - val toKey = to.key as? String ?: return@rememberReorderableLazyListState - val toIndex = movable.indexOf(toKey) - if (fromKey in movable && toIndex >= 0) { - client.dispatch(Commands.moveQueueItem(fromKey, toIndex)) - haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) - } + val d = drag?.takeIf { !it.dropped } ?: return@rememberReorderableLazyListState + val fromIndex = d.order.indexOf(from.key) + val toIndex = d.order.indexOf(to.key) + if (fromIndex < 0 || toIndex < 0) return@rememberReorderableLazyListState + drag = d.copy(order = d.order.toMutableList().apply { add(toIndex, removeAt(fromIndex)) }) + haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) + } + fun startDrag(key: QueueKey) { + drag = QueueDrag(queue, key, queue.movableKeys()) + haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + } + fun drop() { + val d = drag?.takeIf { !it.dropped } ?: return + haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) + val to = d.order.indexOf(d.key) + if (to < 0 || to == d.base.movableKeys().indexOf(d.key)) { drag = null; return } + drag = d.copy(dropped = true) + client.dispatch(Commands.moveQueueItem(d.key, to)) } // Follow the current track, but never fight the user: no auto-scroll while a finger is on the // list or for a while after they last scrolled it (Metrolist/Navic leave a browsed queue alone). @@ -302,22 +366,17 @@ private fun QueueTimeline(modifier: Modifier, contentPadding: PaddingValues) { val entry = row.entry val draggable = row.section == Line.Section.Next || row.section == Line.Section.Upcoming || row.section == Line.Section.Autoplay val removable = row.section != Line.Section.Current - val dismiss = rememberSwipeToDismissBoxState(positionalThreshold = { it * 0.45f }, confirmValueChange = { v -> - if (v != SwipeToDismissBoxValue.Settled) { client.dispatch(Commands.removeQueueItems(listOf(entry.item.key))); haptics.performHapticFeedback(HapticFeedbackType.Confirm); true } else false - }) + val target = Commands.queueItems(listOf(entry.item.key)) + // The current item is never swiped away (it is removed by skipping it). + fun swipeAction(id: String) = if (!removable && id == ActionIds.REMOVE_FROM_QUEUE) SwipeOptions.NONE else id ReorderableItem(reorderable, key = entry.item.key, enabled = draggable) { _ -> - SwipeToDismissBox( - state = dismiss, - enableDismissFromStartToEnd = removable, - enableDismissFromEndToStart = removable, - backgroundContent = { - // Only while a swipe is under way: the rows are transparent at rest, - // so a resting background would show straight through them. - if (dismiss.dismissDirection != SwipeToDismissBoxValue.Settled) Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.errorContainer).padding(horizontal = 24.dp), contentAlignment = if (dismiss.dismissDirection == SwipeToDismissBoxValue.StartToEnd) Alignment.CenterStart else Alignment.CenterEnd) { - // Decorative: the row's "Remove from queue" action is the accessible path. - Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.onErrorContainer) - } - }, + SwipeActionBox( + startToEnd = trackSwipeAction(swipeAction(swipeStart), entry.track, target, onAddToPlaylist = { pickerFor = entry }), + endToStart = trackSwipeAction(swipeAction(swipeEnd), entry.track, target, onAddToPlaylist = { pickerFor = entry }), + enabled = !selecting && drag == null, + // Transparent at rest (the player's backdrop shows through); an opaque + // surface only while swiped, to cover the action's background. + swipeSurface = MaterialTheme.colorScheme.surfaceContainerHighest, ) { // Accessible alternatives to the swipe (remove) and the drag handle (move). val index = movable.indexOf(entry.item.key) @@ -326,9 +385,6 @@ private fun QueueTimeline(modifier: Modifier, contentPadding: PaddingValues) { if (draggable && index > 0) add(CustomAccessibilityAction(moveUpLabel) { client.dispatch(Commands.moveQueueItem(entry.item.key, index - 1)); true }) if (draggable && index >= 0 && index < movable.lastIndex) add(CustomAccessibilityAction(moveDownLabel) { client.dispatch(Commands.moveQueueItem(entry.item.key, index + 1)); true }) } - // Transparent at rest (the player's backdrop shows through); an opaque - // surface only while swiped, to cover the remove background. - val swipeSurface = MaterialTheme.colorScheme.surfaceContainerHighest QueueRow( entry = entry, section = row.section, @@ -337,14 +393,14 @@ private fun QueueTimeline(modifier: Modifier, contentPadding: PaddingValues) { extraActions = extra, onClick = { client.dispatch(Commands.jumpToQueueItem(entry.item.key)) }, onToggleSelect = { client.toggleSelected(SelectionKind.QueueItems, entry.item.key) }, - onMore = { sheetFor = entry }, + onMore = { songMenu.open(entry.track, Commands.queueItems(listOf(entry.item.key))) }, onRate = { ratingFor = entry }, - modifier = Modifier.drawBehind { if (dismiss.dismissDirection != SwipeToDismissBoxValue.Settled) drawRect(swipeSurface) }, + modifier = Modifier.testTag("queue.row." + entry.item.key), handle = if (draggable) ({ Box( - Modifier.size(48.dp).draggableHandle( - onDragStarted = { haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) }, - onDragStopped = { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }, + Modifier.size(48.dp).testTag("queue.handle." + entry.item.key).draggableHandle( + onDragStarted = { startDrag(entry.item.key) }, + onDragStopped = { drop() }, ), contentAlignment = Alignment.Center, ) { Icon(Icons.Filled.DragHandle, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) } @@ -359,7 +415,7 @@ private fun QueueTimeline(modifier: Modifier, contentPadding: PaddingValues) { } SelectionToolbar(Modifier.align(Alignment.BottomCenter)) } - sheetFor?.let { e -> ActionSheet(Commands.queueItems(listOf(e.item.key)), e.track.title, e.track.artist, onDismiss = { sheetFor = null }) } + pickerFor?.let { e -> PlaylistPicker(target = Commands.queueItems(listOf(e.item.key)), onDismiss = { pickerFor = null }) } ratingFor?.let { e -> RatingDialog(current = e.track.rating.toInt(), onRate = { stars -> client.dispatch(Commands.runAction(ActionIds.rate(stars), Commands.queueItems(listOf(e.item.key)))); ratingFor = null }, onDismiss = { ratingFor = null }) } diff --git a/android/app/src/main/java/app/hocket/ui/queue/RecentQueues.kt b/android/app/src/main/java/app/hocket/ui/queue/RecentQueues.kt index 9641986..09d1032 100644 --- a/android/app/src/main/java/app/hocket/ui/queue/RecentQueues.kt +++ b/android/app/src/main/java/app/hocket/ui/queue/RecentQueues.kt @@ -62,9 +62,12 @@ import app.hocket.ui.components.EmptyState import app.hocket.ui.components.SectionHeader import app.hocket.ui.components.formatAgo -/** Saved queues: pinned first, then recent; restore / pin / delete / save as playlist. */ +/** + * Saved queues: pinned first, then recent; restore / pin / delete / save as playlist. [onRestore] + * runs after a queue is restored (the player's queue switcher closes itself). + */ @Composable -fun RecentQueuesList(modifier: Modifier = Modifier) { +fun RecentQueuesList(modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(bottom = app.hocket.ui.nav.BottomContentInset), onRestore: () -> Unit = {}) { val client = LocalCoreClient.current val saved by client.savedQueues.collectAsStateWithLifecycle() val connection by client.connection.collectAsStateWithLifecycle() @@ -76,9 +79,9 @@ fun RecentQueuesList(modifier: Modifier = Modifier) { } val pinned = saved.filter { it.pinned } val recent = saved.filter { !it.pinned }.sortedByDescending { it.lastInteractedAt } - LazyColumn(modifier, contentPadding = PaddingValues(bottom = app.hocket.ui.nav.BottomContentInset)) { - if (pinned.isNotEmpty()) { item { SectionHeader(stringResource(R.string.saved_pinned)) }; items(pinned, key = { it.id }) { SavedQueueRow(it, onDelete = { deleting = it }, onSaveAs = { naming = it }) } } - if (recent.isNotEmpty()) { item { SectionHeader(stringResource(R.string.saved_recent)) }; items(recent, key = { it.id }) { SavedQueueRow(it, onDelete = { deleting = it }, onSaveAs = { naming = it }) } } + LazyColumn(modifier, contentPadding = contentPadding) { + if (pinned.isNotEmpty()) { item { SectionHeader(stringResource(R.string.saved_pinned)) }; items(pinned, key = { it.id }) { SavedQueueRow(it, onDelete = { deleting = it }, onSaveAs = { naming = it }, onRestore = onRestore) } } + if (recent.isNotEmpty()) { item { SectionHeader(stringResource(R.string.saved_recent)) }; items(recent, key = { it.id }) { SavedQueueRow(it, onDelete = { deleting = it }, onSaveAs = { naming = it }, onRestore = onRestore) } } } deleting?.let { sq -> ConfirmDialog(stringResource(R.string.playlist_delete_confirm, sq.label), stringResource(R.string.action_delete), onConfirm = { client.dispatch(Commands.deleteSavedQueue(sq.id)) }, onDismiss = { deleting = null }) } naming?.let { sq -> @@ -91,9 +94,9 @@ fun RecentQueuesList(modifier: Modifier = Modifier) { } @Composable -private fun SavedQueueRow(sq: SavedQueue, onDelete: () -> Unit, onSaveAs: () -> Unit) { +private fun SavedQueueRow(sq: SavedQueue, onDelete: () -> Unit, onSaveAs: () -> Unit, onRestore: () -> Unit) { val client = LocalCoreClient.current - Row(Modifier.fillMaxWidth().clickable { client.dispatch(Commands.restoreSavedQueue(sq.id)) }.padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.fillMaxWidth().clickable { client.dispatch(Commands.restoreSavedQueue(sq.id)); onRestore() }.testTag("savedQueue.${sq.id}").padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { Artwork(sq.coverArt, ArtworkSizes.THUMB, null, Modifier.size(48.dp), RoundedCornerShape(8.dp)) Spacer(Modifier.width(14.dp)) Column(Modifier.weight(1f)) { diff --git a/android/app/src/main/java/app/hocket/ui/screens/detail/DetailScreens.kt b/android/app/src/main/java/app/hocket/ui/screens/detail/DetailScreens.kt index 0417539..afef2cd 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/detail/DetailScreens.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/detail/DetailScreens.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.PlaylistRemove import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FavoriteBorder @@ -224,7 +225,7 @@ fun PrimeAlbumOnDwell(albumId: String) { fun AlbumDetailScreen(nav: NavHostController, id: String, embedded: Boolean = false) { val client = LocalCoreClient.current PrimeAlbumOnDwell(id) - val libraryGen by client.libraryChanged.collectAsStateWithLifecycle(initialValue = null) + val libraryGen by client.libraryGeneration.collectAsStateWithLifecycle() var album by remember { mutableStateOf(null) } var tracks by remember { mutableStateOf?>(null) } LaunchedEffect(id, libraryGen) { @@ -243,7 +244,7 @@ fun AlbumDetailScreen(nav: NavHostController, id: String, embedded: Boolean = fa val selection by client.selection.collectAsStateWithLifecycle() val kind by client.selectionKind.collectAsStateWithLifecycle() val selecting = selection.active && kind == SelectionKind.Tracks - var sheetFor by remember { mutableStateOf(null) } + val songMenu = app.hocket.ui.components.rememberSongMenu() var albumSheet by remember { mutableStateOf(false) } val context = Commands.albumContext(a.serverId, a.id, a.name) DetailScaffold(nav, embedded, a.name, a.artist, a.coverArt, actions = { IconButton(onClick = { albumSheet = true }) { Icon(Icons.Filled.MoreVert, stringResource(R.string.action_more)) } }, header = { @@ -262,12 +263,11 @@ fun AlbumDetailScreen(nav: NavHostController, id: String, embedded: Boolean = fa val list = tracks if (list == null) items(8, key = { "skeleton.row$it" }, contentType = { "skeletonRow" }) { TrackRowSkeleton(showArtwork = false) } else itemsIndexed(list, key = { _, t -> t.id }, contentType = { _, _ -> "track" }) { i, t -> - TrackRow(t.toSummary(), onClick = { client.dispatch(Commands.playContext(context, startIndex = i)) }, onMore = { sheetFor = t }, showArtwork = false, + TrackRow(t.toSummary(), onClick = { client.dispatch(Commands.playContext(context, startIndex = i)) }, onMore = { songMenu.open(t.toSummary()) }, showArtwork = false, leading = { Text((t.trackNumber?.toInt() ?: (i + 1)).toString(), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.width(32.dp)) }, selected = selecting && selection.contains(t.id), selectionActive = selecting, onToggleSelect = { client.toggleSelected(SelectionKind.Tracks, t.id) }, nowPlaying = nowPlaying?.track?.id == t.id) } } - sheetFor?.let { t -> ActionSheet(Commands.tracks(listOf(t.id)), t.title, t.artist, onDismiss = { sheetFor = null }, onGoToArtist = t.artistId?.let { aid -> { nav.navigate(Route.Artist(aid)) } }) } if (albumSheet) ActionSheet(Commands.albums(listOf(a.id)), a.name, a.artist, onDismiss = { albumSheet = false }, onGoToArtist = a.artistId?.let { aid -> { nav.navigate(Route.Artist(aid)) } }) } @@ -282,7 +282,7 @@ fun PlayShuffleRow(onPlay: () -> Unit, onShuffle: () -> Unit) { @Composable fun ArtistDetailScreen(nav: NavHostController, id: String, embedded: Boolean = false) { val client = LocalCoreClient.current - val libraryGen by client.libraryChanged.collectAsStateWithLifecycle(initialValue = null) + val libraryGen by client.libraryGeneration.collectAsStateWithLifecycle() val server by client.server.collectAsStateWithLifecycle() var artist by remember { mutableStateOf(null) } var albums by remember { mutableStateOf>(emptyList()) } @@ -302,7 +302,7 @@ fun ArtistDetailScreen(nav: NavHostController, id: String, embedded: Boolean = f return } val context = Commands.artistContext(ar.serverId, ar.id, ar.name) - var sheetFor by remember { mutableStateOf(null) } + val songMenu = app.hocket.ui.components.rememberSongMenu() DetailScaffold(nav, embedded, ar.name, stringResource(R.string.library_count_albums, ar.albumCount.toInt()), ar.coverArt, roundArtwork = true, header = { IconToggleButton(checked = ar.loved, onCheckedChange = { client.dispatch(Commands.setArtistLoved(ar.id, it)) }) { Icon(if (ar.loved) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, stringResource(if (ar.loved) R.string.action_unlove else R.string.action_love), tint = if (ar.loved) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.onSurfaceVariant) @@ -313,7 +313,7 @@ fun ArtistDetailScreen(nav: NavHostController, id: String, embedded: Boolean = f if (top.isNotEmpty()) { item(key = "h.top") { SectionHeader(stringResource(R.string.artist_top_songs)) } itemsIndexed(top, key = { _, t -> "top" + t.id }, contentType = { _, _ -> "track" }) { i, t -> - TrackRow(t.toSummary(), onClick = { client.dispatch(Commands.playTracks(t.serverId, top.map { it.id }, i, ar.name)) }, onMore = { sheetFor = t }) + TrackRow(t.toSummary(), onClick = { client.dispatch(Commands.playTracks(t.serverId, top.map { it.id }, i, ar.name)) }, onMore = { songMenu.open(t.toSummary()) }) } } if (albums.isNotEmpty()) { @@ -321,7 +321,6 @@ fun ArtistDetailScreen(nav: NavHostController, id: String, embedded: Boolean = f item(key = "albums") { AlbumStrip(albums) { nav.navigate(Route.Album(it.id)) } } } } - sheetFor?.let { t -> ActionSheet(Commands.tracks(listOf(t.id)), t.title, t.artist, onDismiss = { sheetFor = null }, onGoToAlbum = t.albumId?.let { aid -> { nav.navigate(Route.Album(aid)) } }) } } /** One playlist row: its track and a key that stays with it while the list is reordered. */ @@ -345,7 +344,7 @@ internal fun playlistEntries(tracks: List): List { @Composable fun PlaylistDetailScreen(nav: NavHostController, id: String, embedded: Boolean = false) { val client = LocalCoreClient.current - val libraryGen by client.libraryChanged.collectAsStateWithLifecycle(initialValue = null) + val libraryGen by client.libraryGeneration.collectAsStateWithLifecycle() var playlist by remember { mutableStateOf(null) } var entries by remember { mutableStateOf?>(null) } var dragKey by remember { mutableStateOf(null) } @@ -364,7 +363,8 @@ fun PlaylistDetailScreen(nav: NavHostController, id: String, embedded: Boolean = val selection by client.selection.collectAsStateWithLifecycle() val kind by client.selectionKind.collectAsStateWithLifecycle() val selecting = selection.active && kind == SelectionKind.Tracks - var sheetFor by remember { mutableStateOf?>(null) } + val songMenu = app.hocket.ui.components.rememberSongMenu() + val removeFromPlaylist = stringResource(R.string.action_remove_from_playlist) var playlistSheet by remember { mutableStateOf(false) } val listState = rememberLazyListState() val reorderable = rememberReorderableLazyListState(listState) { from, to -> @@ -429,7 +429,12 @@ fun PlaylistDetailScreen(nav: NavHostController, id: String, embedded: Boolean = val t = e.track val editable = !p.isSmart && p.isMine ReorderableItem(reorderable, key = e.key, enabled = editable) { dragging -> - TrackRow(t.toSummary(), onClick = { client.dispatch(Commands.playContext(context, startIndex = i)) }, onMore = { sheetFor = i to t }, + TrackRow(t.toSummary(), onClick = { client.dispatch(Commands.playContext(context, startIndex = i)) }, onMore = { + // The playlist's own extra: remove this entry (by its position). + val extras = if (editable) listOf(app.hocket.ui.components.SongMenuExtra(removeFromPlaylist, Icons.Filled.PlaylistRemove, + onClick = { client.dispatch(Commands.playlistRemove(p.id, listOf(i))) }, destructive = true, testTag = "songMenu.removeFromPlaylist")) else emptyList() + songMenu.open(t.toSummary(), extras = extras) + }, selected = selecting && selection.contains(t.id), selectionActive = selecting, onToggleSelect = { client.toggleSelected(SelectionKind.Tracks, t.id) }, nowPlaying = nowPlaying?.track?.id == t.id, trailing = if (editable) ({ @@ -451,12 +456,6 @@ fun PlaylistDetailScreen(nav: NavHostController, id: String, embedded: Boolean = } } if (p != null) { - sheetFor?.let { (i, t) -> - ActionSheet(Commands.tracks(listOf(t.id)), t.title, t.artist, onDismiss = { sheetFor = null }, onGoToAlbum = t.albumId?.let { aid -> { nav.navigate(Route.Album(aid)) } }, - extraTop = if (!p.isSmart && p.isMine) ({ - androidx.compose.material3.TextButton(onClick = { client.dispatch(Commands.playlistRemove(p.id, listOf(i))); sheetFor = null }, modifier = Modifier.padding(horizontal = 12.dp)) { Text(stringResource(R.string.action_remove_from_playlist)) } - }) else null) - } if (playlistSheet) ActionSheet(Commands.playlists(listOf(p.id)), p.name, p.owner, onDismiss = { playlistSheet = false }) } } @@ -466,7 +465,7 @@ fun GenreDetailScreen(nav: NavHostController, name: String, embedded: Boolean = val client = LocalCoreClient.current val server by client.server.collectAsStateWithLifecycle() val serverId = server?.id ?: return - val libraryGen by client.libraryChanged.collectAsStateWithLifecycle(initialValue = null) + val libraryGen by client.libraryGeneration.collectAsStateWithLifecycle() var albums by remember { mutableStateOf?>(null) } var tracks by remember { mutableStateOf?>(null) } LaunchedEffect(name, libraryGen) { @@ -476,7 +475,7 @@ fun GenreDetailScreen(nav: NavHostController, name: String, embedded: Boolean = tracks = t ?: tracks ?: emptyList() } val context = Commands.genreContext(serverId, name) - var sheetFor by remember { mutableStateOf(null) } + val songMenu = app.hocket.ui.components.rememberSongMenu() val al = albums val tr = tracks // The genre's name is known up front: the bar shows it at once, the rest loads under it. @@ -494,8 +493,7 @@ fun GenreDetailScreen(nav: NavHostController, name: String, embedded: Boolean = } item(key = "h.songs") { SectionHeader(stringResource(R.string.genre_songs)) } itemsIndexed(tr, key = { _, t -> t.id }, contentType = { _, _ -> "track" }) { i, t -> - TrackRow(t.toSummary(), onClick = { client.dispatch(Commands.playContext(context, startIndex = i)) }, onMore = { sheetFor = t }) + TrackRow(t.toSummary(), onClick = { client.dispatch(Commands.playContext(context, startIndex = i)) }, onMore = { songMenu.open(t.toSummary()) }) } } - sheetFor?.let { t -> ActionSheet(Commands.tracks(listOf(t.id)), t.title, t.artist, onDismiss = { sheetFor = null }, onGoToAlbum = t.albumId?.let { aid -> { nav.navigate(Route.Album(aid)) } }) } } diff --git a/android/app/src/main/java/app/hocket/ui/screens/home/HomeScreen.kt b/android/app/src/main/java/app/hocket/ui/screens/home/HomeScreen.kt index fa256fa..dc931ab 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/home/HomeScreen.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/home/HomeScreen.kt @@ -77,7 +77,7 @@ fun HomeScreen(nav: NavHostController) { val client = LocalCoreClient.current val server by client.server.collectAsStateWithLifecycle() val serverId = server?.id ?: return - val libraryGen by client.libraryChanged.collectAsStateWithLifecycle(initialValue = null) + val libraryGen by client.libraryGeneration.collectAsStateWithLifecycle() // History changes when a new track starts, not on every now-playing update (rating, position, // state): key the re-query on the track id only. null until the first answer, so loading // shows skeletons instead of a false "nothing here yet". diff --git a/android/app/src/main/java/app/hocket/ui/screens/library/LibraryScreen.kt b/android/app/src/main/java/app/hocket/ui/screens/library/LibraryScreen.kt index 229cf98..cbd0f0e 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/library/LibraryScreen.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/library/LibraryScreen.kt @@ -306,7 +306,7 @@ internal fun ArtistsTab(serverId: String, open: (DetailTarget) -> Unit, topPaddi @Composable internal fun PlaylistsTab(serverId: String, open: (DetailTarget) -> Unit, topPadding: Dp = 0.dp) { val client = LocalCoreClient.current - val libraryGen by client.libraryChanged.collectAsStateWithLifecycle(initialValue = null) + val libraryGen by client.libraryGeneration.collectAsStateWithLifecycle() var playlists by remember { mutableStateOf?>(null) } LaunchedEffect(serverId, libraryGen) { playlists = (client.query(Queries.playlists(serverId)) as? QueryResult.Playlists)?.data ?: playlists ?: emptyList() } val selection by client.selection.collectAsStateWithLifecycle() @@ -347,7 +347,7 @@ internal fun SongsTab(serverId: String, sort: SortOrder, descending: Boolean, na val kind by client.selectionKind.collectAsStateWithLifecycle() val nowPlaying by client.nowPlaying.collectAsStateWithLifecycle() val selecting = selection.active && kind == SelectionKind.Tracks - var sheetFor by remember { mutableStateOf(null) } + val songMenu = app.hocket.ui.components.rememberSongMenu() val listLabel = stringResource(if (offlineOnly) R.string.available_offline else R.string.library_songs) LaunchedEffect(key, state.generation) { client.trackPages.ensure(key, 0) } LaunchedEffect(state.total) { if (state.total >= 0 && kind == SelectionKind.Tracks) client.setSelectionTotal(state.total) } @@ -383,21 +383,17 @@ internal fun SongsTab(serverId: String, sort: SortOrder, descending: Boolean, na // Play the sorted list as an ad-hoc context starting here: the visible page's ids are known, the rest resolve in the core. val ctx = Commands.adHocContext(serverId, listLabel, state.pages.toSortedMap().values.flatten().map { it.id }, effectiveSort) client.dispatch(Commands.playContext(ctx, startIndex = state.pages.toSortedMap().values.flatten().indexOfFirst { it.id == track.id }.coerceAtLeast(0))) - }, onMore = { sheetFor = track }, selected = selecting && selection.contains(track.id), selectionActive = selecting, + }, onMore = { songMenu.open(track.toSummary()) }, selected = selecting && selection.contains(track.id), selectionActive = selecting, onToggleSelect = { client.toggleSelected(SelectionKind.Tracks, track.id) }, nowPlaying = nowPlaying?.track?.id == track.id) } else TrackRowSkeleton() } } - sheetFor?.let { t -> - ActionSheet(Commands.tracks(listOf(t.id)), t.title, t.artist, onDismiss = { sheetFor = null }, - onGoToAlbum = t.albumId?.let { id -> { nav.navigate(Route.Album(id)) } }, onGoToArtist = t.artistId?.let { id -> { nav.navigate(Route.Artist(id)) } }) - } } @Composable internal fun GenresTab(serverId: String, open: (DetailTarget) -> Unit, topPadding: Dp = 0.dp) { val client = LocalCoreClient.current - val libraryGen by client.libraryChanged.collectAsStateWithLifecycle(initialValue = null) + val libraryGen by client.libraryGeneration.collectAsStateWithLifecycle() var genres by remember { mutableStateOf?>(null) } LaunchedEffect(serverId, libraryGen) { genres = (client.query(Queries.genres(serverId)) as? QueryResult.Genres)?.data ?: genres ?: emptyList() } val list = genres diff --git a/android/app/src/main/java/app/hocket/ui/screens/search/SearchScreen.kt b/android/app/src/main/java/app/hocket/ui/screens/search/SearchScreen.kt index f02c057..a1fa303 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/search/SearchScreen.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/search/SearchScreen.kt @@ -52,8 +52,8 @@ import app.hocket.core.Commands import app.hocket.core.Queries import app.hocket.core.api.QueryResult import app.hocket.core.api.SearchResults +import app.hocket.core.client.LibraryPatches import app.hocket.ui.LocalCoreClient -import app.hocket.ui.components.ActionSheet import app.hocket.ui.screens.home.AlbumStrip import app.hocket.ui.components.ArtistRow import app.hocket.ui.components.EmptyState @@ -82,7 +82,7 @@ fun SearchScreen(nav: NavHostController) { var requestCounter by remember { mutableIntStateOf(0) } var minHeightPx by remember { mutableIntStateOf(0) } val focus = remember { FocusRequester() } - var sheetFor by remember { mutableStateOf(null) } + val songMenu = app.hocket.ui.components.rememberSongMenu() LaunchedEffect(Unit) { snapshotFlow { query }.debounce(120).collect { q -> @@ -98,6 +98,14 @@ fun SearchScreen(nav: NavHostController) { LaunchedEffect(Unit) { client.searchResults.collect { r -> if (r.fromServer && r.query == query.trim()) { remote = r; pendingRemote = false } } } + // Results are a one-off answer, not a refetching list: a rating or love set anywhere (a menu here, + // another signed-in device) is patched into them so the rows show it at once. + LaunchedEffect(Unit) { + client.libraryItemsChanged.collect { c -> + local = local?.let { LibraryPatches.search(it, c.items) } + remote = remote?.let { LibraryPatches.search(it, c.items) } + } + } // The keyboard comes up on the first visit only; coming back to the tab keeps the results in view. var focusedOnce by rememberSaveable { mutableStateOf(false) } LaunchedEffect(Unit) { @@ -126,7 +134,7 @@ fun SearchScreen(nav: NavHostController) { if (results.tracks.isNotEmpty()) { item { SectionHeader(stringResource(R.string.search_songs)) } items(results.tracks, key = { "t" + it.id }) { t -> - TrackRow(t, onClick = { client.dispatch(Commands.playTracks(serverId, results.tracks.map { it.id }, results.tracks.indexOf(t), query)) }, onMore = { sheetFor = t }) + TrackRow(t, onClick = { client.dispatch(Commands.playTracks(serverId, results.tracks.map { it.id }, results.tracks.indexOf(t), query)) }, onMore = { songMenu.open(t) }) } } if (results.albums.isNotEmpty()) { @@ -151,14 +159,13 @@ fun SearchScreen(nav: NavHostController) { if (pendingRemote) Text(stringResource(R.string.search_server_pending), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp)) } remote?.let { r -> - items(r.tracks, key = { "rt" + it.id }) { t -> TrackRow(t, onClick = { client.dispatch(Commands.playTracks(serverId, r.tracks.map { it.id }, r.tracks.indexOf(t), query)) }, onMore = { sheetFor = t }) } + items(r.tracks, key = { "rt" + it.id }) { t -> TrackRow(t, onClick = { client.dispatch(Commands.playTracks(serverId, r.tracks.map { it.id }, r.tracks.indexOf(t), query)) }, onMore = { songMenu.open(t) }) } if (r.albums.isNotEmpty()) item(key = "ralbums") { AlbumStrip(r.albums, Modifier.animateItem().padding(vertical = 8.dp)) { a -> nav.navigate(Route.Album(a.id)) } } items(r.artists, key = { "rar" + it.id }) { ar -> ArtistRow(ar, onClick = { nav.navigate(Route.Artist(ar.id)) }) } } } } } - sheetFor?.let { t -> ActionSheet(Commands.tracks(listOf(t.id)), t.title, t.artist, onDismiss = { sheetFor = null }, onGoToAlbum = t.albumId?.let { id -> { nav.navigate(Route.Album(id)) } }, onGoToArtist = t.artistId?.let { id -> { nav.navigate(Route.Artist(id)) } }) } } diff --git a/android/app/src/main/java/app/hocket/ui/screens/settings/SettingsScreen.kt b/android/app/src/main/java/app/hocket/ui/screens/settings/SettingsScreen.kt index 4e4b509..f383968 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/settings/SettingsScreen.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/settings/SettingsScreen.kt @@ -221,7 +221,7 @@ private fun SettingsCategory.summary(): String { ConnectionTier.Coordinator -> stringResource(R.string.connection_coordinator, connection.peerCount.toInt()) } } - SettingsCategory.Customise -> stringResource(R.string.settings_bottom_bar) + dot + stringResource(R.string.settings_context_menu) + dot + stringResource(R.string.settings_media_buttons) + SettingsCategory.Customise -> stringResource(R.string.settings_bottom_bar) + dot + stringResource(R.string.settings_context_menu) + dot + stringResource(R.string.settings_media_buttons) + dot + stringResource(R.string.settings_swipe_actions) SettingsCategory.Backup -> stringResource(R.string.settings_summary_backup) SettingsCategory.About -> stringResource(R.string.settings_licence) } diff --git a/android/app/src/main/java/app/hocket/ui/screens/settings/SubSettingsScreens.kt b/android/app/src/main/java/app/hocket/ui/screens/settings/SubSettingsScreens.kt index e91b0d3..69413cc 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/settings/SubSettingsScreens.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/settings/SubSettingsScreens.kt @@ -81,6 +81,11 @@ import kotlinx.serialization.builtins.serializer import sh.calvin.reorderable.ReorderableColumn import androidx.compose.runtime.key import androidx.compose.ui.platform.testTag +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import app.hocket.core.SwipeOptions +import app.hocket.ui.components.SwipeSurface +import app.hocket.ui.components.swipeActionIds import app.hocket.ui.nav.BottomBarEditor /** ReplayGain, preamp, normalisation, gapless, EQ with draggable bands, output device. */ @@ -248,6 +253,33 @@ fun CustomiseSettingsScreen(nav: NavHostController) { val allMedia = ActionIds.MEDIA_SESSION val mediaIds = mediaSetting.raw?.let { runCatching { HocketJson.json.decodeFromString(ListSerializer(String.serializer()), it) }.getOrNull() }?.ifEmpty { null } ?: allMedia ChooseAndOrder(allMedia, mediaIds, minEnabled = 0, label = { actionLabel(it) }, tag = "customise.mediaSession") { ids -> client.dispatch(Commands.setActionOrder("mediaSession", ids)) } + SettingsSection(stringResource(R.string.settings_swipe_actions)) + Text(stringResource(R.string.settings_swipe_hint), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) + SwipeChoices(stringResource(R.string.settings_swipe_queue), SwipeOptions.QUEUE, SwipeSurface.Queue) + SwipeChoices(stringResource(R.string.settings_swipe_lists), SwipeOptions.LIST, SwipeSurface.List) + } +} + +/** + * One surface's two swipe settings (core registry keys, synced): a choice of action per direction. + * The directions are named as the finger moves on screen, so right-to-left layouts swap them. + */ +@Composable +private fun SwipeChoices(title: String, options: List, surface: SwipeSurface) { + val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl + val (startToEnd, endToStart) = swipeActionIds(surface) + val none = stringResource(R.string.settings_swipe_none) + Text(title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp)) + for ((key, current) in listOf(surface.startToEndKey to startToEnd, surface.endToStartKey to endToStart)) { + val handle = setting(key) + val right = (key == surface.startToEndKey) != rtl + Text(stringResource(if (right) R.string.settings_swipe_right else R.string.settings_swipe_left), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)) + ChoiceRow( + options.map { id -> id to if (id == SwipeOptions.NONE) none else actionLabel(id) }, + isSelected = { it == current }, + onSelect = { handle.setString(it) }, + modifier = Modifier.padding(horizontal = 16.dp).testTag("customise.$key"), + ) } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 75881ba..eb0602e 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -474,6 +474,13 @@ Context menu actions Media notification buttons Drag to reorder, toggle to show or hide. + Swipe actions + What swiping a song sideways does. Every action is also in the song\'s menu. + In the queue + In albums, playlists, search and the library + Swipe right + Swipe left + None Backup Export configuration Include secrets diff --git a/android/app/src/main/res/values/strings_player.xml b/android/app/src/main/res/values/strings_player.xml index 4a0bce0..3fd2261 100644 --- a/android/app/src/main/res/values/strings_player.xml +++ b/android/app/src/main/res/values/strings_player.xml @@ -4,6 +4,7 @@ Playing from Autoplay Playing from %1$s + Switch queue Lyrics Queue About diff --git a/android/app/src/test/java/app/hocket/ui/FullPlayerLayoutTest.kt b/android/app/src/test/java/app/hocket/ui/FullPlayerLayoutTest.kt new file mode 100644 index 0000000..b891b6b --- /dev/null +++ b/android/app/src/test/java/app/hocket/ui/FullPlayerLayoutTest.kt @@ -0,0 +1,115 @@ +package app.hocket.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performSemanticsAction +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.hocket.core.Commands +import app.hocket.ui.nav.AppRoot +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * The full player's page: the artwork at the top with the title right under it and the rest spread + * over the remaining height; the album beside the thumbnail in the other modes; "Playing from" + * opening the queue switcher and "Playing on" the device picker. + */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35], application = android.app.Application::class, qualifiers = "w411dp-h891dp") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class FullPlayerLayoutTest { + @get:Rule + val compose = createAndroidComposeRule() + + private val core = TestCore() + + private fun exists(tag: String) = compose.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty() + private fun bounds(tag: String): Rect = compose.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot + private fun click(tag: String) = compose.onNodeWithTag(tag).performSemanticsAction(SemanticsActions.OnClick) + private val dp get() = compose.activity.resources.displayMetrics.density + + private fun startExpanded() { + compose.setThemedContent(core) { AppRoot(core.client) } + core.start() + compose.waitUntil(5_000) { core.client.started.value } + compose.waitUntil(5_000) { exists("miniPlayer") } + compose.onNodeWithTag("nowPlaying.sheet").performSemanticsAction(SemanticsActions.Expand) + compose.waitUntil(5_000) { exists("player.playPause") } + compose.waitForIdle() + } + + @Test + fun theArtworkIsAtTheTopWithTheTitleUnderItAndTheRestSpreadOut() { + startExpanded() + val source = bounds("player.source") + val art = bounds("player.artwork") + val title = bounds("player.title") + val play = bounds("player.playPause") + val pills = bounds("player.mode.lyrics") + assertTrue("artwork right under the header: $source / $art", art.top - source.bottom in 0f..24f * dp) + assertTrue("title right under the artwork: $art / $title", title.top - art.bottom in 0f..40f * dp) + // The spare height goes between the controls (and under the last), not into one band: + // the transport and the pills are well apart but nothing hangs off the bottom. + val root = compose.onNodeWithTag("nowPlaying.sheet").fetchSemanticsNode().boundsInRoot + val gap = pills.top - play.bottom + assertTrue("the rest spread out (gap $gap)", gap > 16f * dp) + assertTrue("the pills clear of the screen's bottom (${pills.bottom} in $root)", pills.bottom < root.bottom) + assertTrue("and not all pushed to the bottom", root.bottom - pills.bottom > 12f * dp) + } + + @Test + fun theAlbumStaysBesideTheThumbnailInTheOtherModes() { + startExpanded() + val album = core.client.nowPlaying.value!!.track.album + // The lyrics animate every frame while shown: step the clock by hand instead of waiting for idle. + compose.mainClock.autoAdvance = false + try { + for (mode in listOf("lyrics", "queue", "about")) { + click("player.mode.$mode") + compose.mainClock.advanceTimeBy(1_000) + assertTrue("album shown in $mode", exists("player.album")) + if (album != null) assertEquals(album, compose.onNodeWithTag("player.album").fetchSemanticsNode().config[SemanticsProperties.Text].joinToString { it.text }) + click("player.mode.$mode") + compose.mainClock.advanceTimeBy(1_000) + } + } finally { + compose.mainClock.autoAdvance = true + } + } + + @Test + fun playingFromOpensTheQueueSwitcherAndPickingAQueueRestoresItAndClosesIt() { + startExpanded() + click("player.source") + compose.waitUntil(5_000) { exists("queueSwitcher.sheet") } + val saved = core.client.savedQueues.value.first { it.pinned } + compose.waitUntil(5_000) { exists("savedQueue.${saved.id}") } + click("savedQueue.${saved.id}") + compose.waitUntil(5_000) { !exists("queueSwitcher.sheet") } + compose.waitUntil(5_000) { core.client.queue.value.contextLabel == saved.label } + compose.waitForIdle() + assertEquals("Playing from ${saved.label}", compose.onNodeWithTag("player.source").fetchSemanticsNode().config[SemanticsProperties.ContentDescription].single()) + } + + @Test + fun playingOnAnotherDeviceOpensTheDevicePicker() { + startExpanded() + core.client.dispatch(Commands.handoffTo("laptop")) + // The client reads who owns the transport from snapshots. + core.client.requestSnapshot() + compose.waitUntil(5_000) { !core.client.ownsTransport.value } + compose.waitUntil(5_000) { exists("player.playingOn") } + click("player.playingOn") + compose.waitUntil(5_000) { exists("handoff.sheet") } + } +} diff --git a/android/app/src/test/java/app/hocket/ui/NavBarCustomisationTest.kt b/android/app/src/test/java/app/hocket/ui/NavBarCustomisationTest.kt index ce643a3..5d939ec 100644 --- a/android/app/src/test/java/app/hocket/ui/NavBarCustomisationTest.kt +++ b/android/app/src/test/java/app/hocket/ui/NavBarCustomisationTest.kt @@ -87,8 +87,10 @@ class NavBarCustomisationTest { return core } - private fun openEditorByLongPress() { - compose.onNodeWithTag("navBar").performTouchInput { longClick(center) } + /** The editor sheet, from the account sheet's "Customise bottom bar" (holding the bar does not open it). */ + private fun openEditor() { + click("account.button") + click("account.editBar") await("bottomBar.editor") } @@ -130,7 +132,7 @@ class NavBarCustomisationTest { } core.start() await("navBar") - openEditorByLongPress() + openEditor() // Add Albums, move it up once (the TalkBack action), remove Search. click("bottomBar.add") click("bottomBar.addItem.albums") @@ -158,7 +160,7 @@ class NavBarCustomisationTest { fun theBarHoldsTwoToFiveItemsAndSaysWhy() { start(InMemoryNavBarPrefs(listOf("home", "search", "library", "albums", "songs"))) compose.waitUntil(5_000) { bar().size == 5 } - openEditorByLongPress() + openEditor() click("bottomBar.add") await("bottomBar.message") assertTrue(compose.onNodeWithTag("bottomBar.message").fetchSemanticsNode().config[SemanticsProperties.Text].joinToString { it.text }.contains("at most 5")) @@ -170,7 +172,7 @@ class NavBarCustomisationTest { fun removingBelowTwoIsRefusedWithAMessage() { start(InMemoryNavBarPrefs(listOf("home", "search"))) compose.waitUntil(5_000) { bar() == listOf("home", "search") } - openEditorByLongPress() + openEditor() click("bottomBar.remove.home") await("bottomBar.message") assertTrue(compose.onNodeWithTag("bottomBar.message").fetchSemanticsNode().config[SemanticsProperties.Text].joinToString { it.text }.contains("at least 2")) @@ -248,9 +250,19 @@ class NavBarCustomisationTest { @Test fun theEditorPassesTheAccessibilityChecks() { start() - openEditorByLongPress() + openEditor() A11yChecks.assertAccessible(compose, "bottom bar editor") - // Long-press did not also navigate. - assertEquals(listOf("home"), selected()) + } + + @Test + fun holdingTheBarDoesNotOpenTheEditor() { + start() + compose.waitUntil(5_000) { bar().size == 3 } + compose.onNodeWithTag("navBar").performTouchInput { longClick(center) } + compose.waitForIdle() + assertFalse(exists("bottomBar.editor")) + // Nor does an item offer an "edit" accessibility action any more. + val actions = compose.onNodeWithTag("navBar.home").fetchSemanticsNode().config.getOrNull(SemanticsActions.CustomActions).orEmpty() + assertTrue(actions.none { it.label == "Customise bottom bar" }) } } diff --git a/android/app/src/test/java/app/hocket/ui/PredictiveBackTest.kt b/android/app/src/test/java/app/hocket/ui/PredictiveBackTest.kt new file mode 100644 index 0000000..4541f84 --- /dev/null +++ b/android/app/src/test/java/app/hocket/ui/PredictiveBackTest.kt @@ -0,0 +1,81 @@ +package app.hocket.ui + +import androidx.activity.BackEventCompat +import androidx.activity.ComponentActivity +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performSemanticsAction +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.hocket.ui.nav.AppRoot +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Predictive back between screens: the gesture reveals the previous screen while the current one + * shrinks into a card that moves away from the swipe's edge; cancelling restores it, releasing + * finishes the pop. + */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35], application = android.app.Application::class, qualifiers = "w411dp-h891dp") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class PredictiveBackTest { + @get:Rule + val compose = createAndroidComposeRule() + + private fun exists(tag: String) = compose.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty() + + private fun back(block: (androidx.activity.OnBackPressedDispatcher) -> Unit) { + compose.activityRule.scenario.onActivity { block(it.onBackPressedDispatcher) } + compose.waitForIdle() + } + + private fun event(progress: Float, edge: Int = BackEventCompat.EDGE_LEFT) = BackEventCompat(touchX = 10f + progress * 400f, touchY = 800f, progress = progress, swipeEdge = edge) + + private fun openSubPage() { + val core = TestCore(startPlaying = false) + compose.setThemedContent(core) { AppRoot(core.client) } + core.start() + compose.openSettingsFromAccount() + compose.onNodeWithTag("settings.category.appearance").performScrollTo().performSemanticsAction(SemanticsActions.OnClick) + compose.waitUntil(5_000) { exists("settings.screen") && !exists("settings.categories") } + compose.waitForIdle() + } + + @Test + fun theGestureShrinksThePageOverThePreviousOneAndCancelRestoresIt() { + openSubPage() + val full = compose.onRoot().fetchSemanticsNode().boundsInRoot + back { it.dispatchOnBackStarted(event(0f)) } + back { it.dispatchOnBackProgressed(event(0.3f)) } + // Both screens are composed: the settings list is revealed behind the sub-page. + assertTrue(exists("settings.categories")) + val card = compose.onNodeWithTag("settings.screen").fetchSemanticsNode().boundsInRoot + assertTrue("the page shrinks: $card in $full", card.width < full.width * 0.95f && card.height < full.height * 0.95f) + assertTrue("and moves right, away from the left edge: $card", card.left > full.left + 1f) + back { it.dispatchOnBackCancelled() } + compose.waitUntil(5_000) { !exists("settings.categories") } + val restored = compose.onNodeWithTag("settings.screen").fetchSemanticsNode().boundsInRoot + assertEquals(full.width, restored.width, 1f) + } + + @Test + fun fromTheRightEdgeThePageMovesLeftAndReleasingPops() { + openSubPage() + val full = compose.onRoot().fetchSemanticsNode().boundsInRoot + back { it.dispatchOnBackStarted(event(0f, BackEventCompat.EDGE_RIGHT)) } + back { it.dispatchOnBackProgressed(event(0.5f, BackEventCompat.EDGE_RIGHT)) } + val card = compose.onNodeWithTag("settings.screen").fetchSemanticsNode().boundsInRoot + assertTrue("moves left, away from the right edge: $card", card.right < full.right - 1f) + back { it.onBackPressed() } + compose.waitUntil(5_000) { exists("settings.categories") && !exists("settings.screen") } + } +} diff --git a/android/app/src/test/java/app/hocket/ui/QueueScreenTest.kt b/android/app/src/test/java/app/hocket/ui/QueueScreenTest.kt index 72c22e6..552f006 100644 --- a/android/app/src/test/java/app/hocket/ui/QueueScreenTest.kt +++ b/android/app/src/test/java/app/hocket/ui/QueueScreenTest.kt @@ -8,7 +8,12 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.swipeUp +import androidx.compose.ui.geometry.Offset import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.hocket.core.Commands import app.hocket.core.api.Command import app.hocket.core.api.RepeatMode import app.hocket.ui.queue.QueuePanel @@ -144,6 +149,64 @@ class QueueScreenTest { compose.onNodeWithText("Nothing to undo").assertIsDisplayed() } + /** + * The core re-keys a context item that moves (`ctx-` follows the context list), so a + * drag that sent a move per slot lost its item after the first one. The drag now reorders a + * local copy and sends ONE move when the finger lifts. + */ + @Test + fun draggingTheHandleReordersTheQueueWithOneMove() { + val core = TestCore() + compose.setThemedContent(core) { QueuePanel() } + core.start() + compose.waitUntil(5_000) { core.client.started.value } + compose.waitForIdle() + val q0 = core.client.queue.value + fun tracks() = core.client.queue.value.let { q -> (q.playingNext + q.upcoming).map { it.track.id } } + val before = tracks() + val entry = q0.upcoming.first() + val from = before.indexOf(entry.track.id) + assertTrue(q0.upcoming.size >= 4) + val undoBefore = core.client.undo.value.history.size + compose.onNodeWithTag("queue.list").performScrollToNode(hasTestTag("queue.row.${entry.item.key}")) + compose.waitForIdle() + val rowHeight = compose.onNodeWithTag("queue.row.${entry.item.key}").fetchSemanticsNode().size.height + compose.onNodeWithTag("queue.handle.${entry.item.key}", useUnmergedTree = true).performTouchInput { + down(center) + // Past the touch slop, then about two and a half rows down in small steps, as a finger would. + repeat(25) { moveBy(Offset(0f, rowHeight / 10f), delayMillis = 16) } + up() + } + compose.waitForIdle() + compose.waitUntil(5_000) { tracks().indexOf(entry.track.id) >= from + 2 } + assertEquals("only the dragged item moved", before - entry.track.id, tracks() - entry.track.id) + assertEquals("one drag is one move (one undo entry)", undoBefore + 1, core.client.undo.value.history.size) + // The moved row is on screen under its new key, and the rows still follow the queue. + compose.waitUntil(5_000) { core.client.queue.value.upcoming.any { it.track.id == entry.track.id } } + val key = core.client.queue.value.upcoming.first { it.track.id == entry.track.id }.item.key + compose.onNodeWithTag("queue.row.$key").assertIsDisplayed() + } + + @Test + fun scrollingTheListStillWorksWithHandles() { + val core = TestCore() + compose.setThemedContent(core) { QueuePanel() } + core.start() + compose.waitUntil(5_000) { core.client.started.value } + // A queue longer than the screen. + val ids = core.fake.library.tracks.take(30).map { it.id } + core.client.dispatch(Commands.playLater(core.fake.library.serverId, ids)) + compose.waitUntil(5_000) { core.client.queue.value.playingNext.size >= 30 } + compose.waitForIdle() + val before = core.client.queue.value.let { q -> (q.playingNext + q.upcoming).map { it.item.key } } + compose.onNodeWithText("Now playing").assertIsDisplayed() + // A vertical swipe over the rows (not on a handle) scrolls and never reorders or swipes a row. + compose.onNodeWithTag("queue.list").performTouchInput { swipeUp(startY = bottom - 10f, endY = top + 10f) } + compose.waitForIdle() + compose.onNodeWithText("Now playing").assertIsNotDisplayed() + assertEquals(before, core.client.queue.value.let { q -> (q.playingNext + q.upcoming).map { it.item.key } }) + } + private fun hasTestTagPrefix(prefix: String) = SemanticsMatcher("tag starts with $prefix") { it.config.getOrNull(SemanticsProperties.TestTag)?.startsWith(prefix) == true } private fun androidx.compose.ui.test.SemanticsNodeInteractionCollection.onFirst() = this[0] diff --git a/android/app/src/test/java/app/hocket/ui/SongMenuTest.kt b/android/app/src/test/java/app/hocket/ui/SongMenuTest.kt new file mode 100644 index 0000000..ef58799 --- /dev/null +++ b/android/app/src/test/java/app/hocket/ui/SongMenuTest.kt @@ -0,0 +1,177 @@ +package app.hocket.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipe +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.hocket.core.ActionIds +import app.hocket.core.api.ActionDescriptor +import app.hocket.core.api.OfflineState +import app.hocket.core.api.TrackSummary +import app.hocket.ui.components.songMenuRows +import app.hocket.ui.nav.AppRoot +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * The one song menu (hosted at the app level): swiping it away from the full player dismisses the + * menu and only the menu, it opens at its final height, and it is the same menu from the queue. + */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35], application = android.app.Application::class, qualifiers = "w411dp-h891dp") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class SongMenuTest { + @get:Rule + val compose = createAndroidComposeRule() + + private fun displayed(tag: String): Boolean = runCatching { compose.onNodeWithTag(tag).assertIsDisplayed() }.isSuccess + private fun present(tag: String): Boolean = compose.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty() + private fun sheetTop(): Float = compose.onNodeWithTag("nowPlaying.sheet").fetchSemanticsNode().boundsInRoot.top + private fun contentTop(): Float = runCatching { compose.onNodeWithTag("debug.fakeCoreBanner").fetchSemanticsNode().boundsInRoot.bottom }.getOrDefault(0f) + + private fun openPlayer(core: TestCore) { + compose.setThemedContent(core) { AppRoot(core.client) } + core.start() + compose.waitUntil(5_000) { core.client.started.value } + compose.onNodeWithTag("nowPlaying.sheet").performSemanticsAction(SemanticsActions.Expand) + compose.waitUntil(5_000) { displayed("player.playPause") } + } + + private fun openMenu() { + compose.onNodeWithTag("player.more").performSemanticsAction(SemanticsActions.OnClick) + compose.waitUntil(5_000) { present("songMenu.header") } + compose.waitForIdle() + } + + private fun swipeMenu(by: Float, millis: Long) { + compose.onNodeWithTag("songMenu.header").performTouchInput { swipe(start = center, end = center + Offset(0f, by), durationMillis = millis) } + } + + @Test + fun swipingTheMenuAwayFromThePlayerDismissesOnlyTheMenu() { + val core = TestCore() + openPlayer(core) + val openTop = sheetTop() + assertTrue(openTop <= contentTop() + 1f) + repeat(3) { round -> + openMenu() + // A fling on odd rounds, a slow drag past half the screen on even ones. + if (round % 2 == 0) swipeMenu(1200f, 1_500) else swipeMenu(600f, 80) + compose.waitUntil(5_000) { !present("songMenu.header") } + compose.waitForIdle() + // The player stayed where it was: fully open, not dragged along with the menu. + assertTrue("player moved on round $round: ${sheetTop()} vs $openTop", sheetTop() <= openTop + 1f) + assertTrue(displayed("player.playPause")) + } + } + + private fun top(tag: String): Float = compose.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot.top + + /** + * The player's menu and a queue row's menu are one menu: the same header and star row, the + * common actions, and the context's own rows (the queue's remove, the player's sleep timer) + * after them. Removing from the menu removes that entry and closes only the menu. + */ + @Test + fun theQueueAndThePlayerOpenTheSameMenuWithTheirExtrasLast() { + val core = TestCore() + openPlayer(core) + openMenu() + assertTrue(present("rating")) + assertTrue(top("player.sleep") > top("songMenu.action.${ActionIds.PLAY_NEXT}")) + assertTrue(!present("songMenu.action.${ActionIds.REMOVE_FROM_QUEUE}")) + compose.onNodeWithTag("songMenu.header").performTouchInput { swipe(start = center, end = center + Offset(0f, 1200f), durationMillis = 100) } + compose.waitUntil(5_000) { !present("songMenu.header") } + + compose.onNodeWithTag("player.mode.queue").performSemanticsAction(SemanticsActions.OnClick) + compose.waitUntil(5_000) { present("queue.list") } + val before = core.client.queue.value.let { it.history.size + it.playingNext.size + it.upcoming.size } + val more = SemanticsMatcher("offers More options") { n -> n.config.getOrNull(SemanticsActions.CustomActions)?.any { it.label == "More options" } == true } + val row = compose.onAllNodes(more and hasAnyAncestor(hasTestTag("queue.list"))).fetchSemanticsNodes() + .first { n -> n.config.getOrNull(SemanticsActions.CustomActions)?.any { it.label == "Remove from queue" } == true } + row.config[SemanticsActions.CustomActions].first { it.label == "More options" }.action() + compose.waitUntil(5_000) { present("songMenu.header") } + compose.waitForIdle() + assertTrue(present("rating")) + assertTrue(!present("player.sleep")) + val remove = "songMenu.action.${ActionIds.REMOVE_FROM_QUEUE}" + assertTrue(top(remove) > top("songMenu.action.${ActionIds.PLAY_NEXT}")) + compose.onNodeWithTag(remove).performSemanticsAction(SemanticsActions.OnClick) + compose.waitUntil(5_000) { !present("songMenu.header") } + compose.waitUntil(5_000) { core.client.queue.value.let { it.history.size + it.playingNext.size + it.upcoming.size } == before - 1 } + assertTrue(displayed("player.playPause")) + } + + /** From a list row the menu is the same sheet: header, stars, the common actions, go-to that navigates. */ + @Test + fun aListRowOpensTheSameMenu() { + val core = TestCore(startPlaying = false) + val album = core.fake.library.albums[2] + val track = core.fake.library.albumTracks(album.id)[0] + val opened = mutableListOf() + core.start() + compose.setThemedContent(core) { + androidx.compose.runtime.CompositionLocalProvider(LocalDetailNavigator provides DetailNavigator({ opened += "album:$it" }, { opened += "artist:$it" })) { + app.hocket.ui.screens.detail.AlbumDetailScreen(androidx.navigation.compose.rememberNavController(), album.id) + } + } + compose.waitUntil(5_000) { compose.onAllNodesWithContentDescription("More options for ${track.title}").fetchSemanticsNodes().isNotEmpty() } + compose.onAllNodesWithContentDescription("More options for ${track.title}")[0].performSemanticsAction(SemanticsActions.OnClick) + compose.waitUntil(5_000) { present("songMenu.header") } + compose.waitForIdle() + assertTrue(present("rating")) + assertTrue(present("songMenu.action.${ActionIds.PLAY_NEXT}")) + assertTrue(!present("songMenu.action.${ActionIds.REMOVE_FROM_QUEUE}")) + compose.onNodeWithTag("songMenu.action.${ActionIds.GO_TO_ARTIST}").performSemanticsAction(SemanticsActions.OnClick) + compose.waitUntil(5_000) { !present("songMenu.header") } + assertEquals(listOf("artist:${track.artistId}"), opened) + } + + @Test + fun theRowsKeepTheRegistryOrderAndSplitOffTheContextActions() { + fun d(id: String) = ActionDescriptor(id, id, "x", "c", true, null, true, false) + val track = TrackSummary("t", "s", "T", albumId = "al", artistId = null, durationMs = 1u, rating = 0u, loved = false, offline = OfflineState.None) + val ids = listOf(ActionIds.REMOVE_FROM_QUEUE, ActionIds.PLAY_NEXT, ActionIds.rate(5), ActionIds.rate(0), ActionIds.GO_TO_ALBUM, ActionIds.GO_TO_ARTIST, ActionIds.DOWNLOAD) + val rows = songMenuRows(ids.map(::d), track, canNavigate = true) + assertEquals(listOf(ActionIds.PLAY_NEXT, ActionIds.GO_TO_ALBUM, ActionIds.DOWNLOAD), rows.common.map { it.id }) + assertEquals(listOf(ActionIds.REMOVE_FROM_QUEUE), rows.context.map { it.id }) + assertEquals(listOf(ActionIds.PLAY_NEXT, ActionIds.DOWNLOAD), songMenuRows(ids.map(::d), track, canNavigate = false).common.map { it.id }) + } + + /** The sheet's height from the frame it first shows: it opens at its final size, never grows under the finger. */ + @Test + fun theMenuOpensAtItsFinalHeight() { + val core = TestCore() + openPlayer(core) + compose.mainClock.autoAdvance = false + try { + compose.onNodeWithTag("player.more").performSemanticsAction(SemanticsActions.OnClick) + val heights = mutableListOf() + repeat(60) { + compose.mainClock.advanceTimeByFrame() + compose.onAllNodesWithTag("songMenu").fetchSemanticsNodes().firstOrNull()?.let { heights += it.size.height } + } + assertTrue("never shown", heights.isNotEmpty()) + assertTrue("the sheet changed height while opening: ${heights.distinct()}", heights.distinct().size == 1) + } finally { + compose.mainClock.autoAdvance = true + } + } +} diff --git a/android/app/src/test/java/app/hocket/ui/SwipeActionsTest.kt b/android/app/src/test/java/app/hocket/ui/SwipeActionsTest.kt new file mode 100644 index 0000000..5ab9cf5 --- /dev/null +++ b/android/app/src/test/java/app/hocket/ui/SwipeActionsTest.kt @@ -0,0 +1,149 @@ +package app.hocket.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onChildren +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft +import androidx.compose.ui.test.swipeRight +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.hocket.core.ActionIds +import app.hocket.core.Commands +import app.hocket.core.SettingKeys +import app.hocket.core.SwipeOptions +import app.hocket.core.toSummary +import app.hocket.ui.components.TrackRow +import app.hocket.ui.queue.QueuePanel +import app.hocket.ui.screens.settings.CustomiseSettingsScreen +import androidx.navigation.compose.rememberNavController +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Swipe actions on song rows: the queue's and every other list's, as set in Customise. */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35], application = android.app.Application::class, qualifiers = "w411dp-h891dp") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class SwipeActionsTest { + @get:Rule + val compose = createComposeRule() + + private fun TestCore.set(key: String, id: String) { + client.dispatch(Commands.setSetting(key, "\"$id\"")) + compose.waitUntil(5_000) { client.settings.value[key]?.value == "\"$id\"" } + } + + @Test + fun songRowsSwipeToPlayNextAndLaterByDefault() { + val core = TestCore() + val (a, b) = core.fake.library.tracks.takeLast(2) + compose.setThemedContent(core) { + Column { + TrackRow(a.toSummary(), onClick = {}, onMore = {}, modifier = Modifier.testTag("row.a")) + TrackRow(b.toSummary(), onClick = {}, onMore = {}, modifier = Modifier.testTag("row.b")) + } + } + core.start() + compose.waitUntil(5_000) { core.client.started.value } + compose.onNodeWithTag("row.a").performTouchInput { swipeRight() } + compose.waitUntil(5_000) { core.client.queue.value.playingNext.firstOrNull()?.track?.id == a.id } + compose.onNodeWithTag("row.b").performTouchInput { swipeLeft() } + compose.waitUntil(5_000) { core.client.queue.value.playingNext.lastOrNull()?.track?.id == b.id } + } + + @Test + fun aShortSwipeOrNoneDoesNothingAndLoveToggles() { + val core = TestCore() + val track = core.fake.library.tracks.first { !it.loved } + compose.setThemedContent(core) { + TrackRow(track.toSummary(), onClick = {}, onMore = {}, modifier = Modifier.testTag("row")) + } + core.start() + compose.waitUntil(5_000) { core.client.started.value } + val next0 = core.client.queue.value.playingNext.size + // Short of the threshold: springs back, nothing runs. + compose.onNodeWithTag("row").performTouchInput { swipeRight(startX = left + 10f, endX = left + width * 0.15f) } + compose.waitForIdle() + assertEquals(next0, core.client.queue.value.playingNext.size) + // "None" on a side: that side does not move at all. + core.set(SettingKeys.SWIPE_LIST_START_TO_END, SwipeOptions.NONE) + compose.onNodeWithTag("row").performTouchInput { swipeRight() } + compose.waitForIdle() + assertEquals(next0, core.client.queue.value.playingNext.size) + // Love through the registry. + core.set(SettingKeys.SWIPE_LIST_END_TO_START, ActionIds.LOVE) + compose.onNodeWithTag("row").performTouchInput { swipeLeft() } + compose.waitUntil(5_000) { core.fake.library.track(track.id)!!.loved } + assertEquals(next0, core.client.queue.value.playingNext.size) + } + + @Test + fun queueRowsSwipeToRemoveByDefaultAndFollowTheirOwnSetting() { + val core = TestCore() + compose.setThemedContent(core) { QueuePanel() } + core.start() + compose.waitUntil(5_000) { core.client.started.value } + compose.waitForIdle() + val gone = core.client.queue.value.upcoming.first() + compose.onNodeWithTag("queue.list").performScrollToNode(hasTestTag("queue.row.${gone.item.key}")) + compose.onNodeWithTag("queue.row.${gone.item.key}").performTouchInput { swipeLeft() } + compose.waitUntil(5_000) { core.client.queue.value.upcoming.none { it.item.key == gone.item.key } } + // The queue's own setting: swiping right now loves, and the list's setting does not apply here. + core.set(SettingKeys.SWIPE_QUEUE_START_TO_END, ActionIds.LOVE) + core.set(SettingKeys.SWIPE_LIST_START_TO_END, ActionIds.PLAY_NEXT) + compose.waitForIdle() + val loved = core.client.queue.value.upcoming.first { !it.track.loved } + val count = core.client.queue.value.let { it.playingNext.size + it.upcoming.size } + compose.onNodeWithTag("queue.list").performScrollToNode(hasTestTag("queue.row.${loved.item.key}")) + compose.onNodeWithTag("queue.row.${loved.item.key}").performTouchInput { swipeRight() } + compose.waitUntil(5_000) { core.fake.library.track(loved.track.id)!!.loved } + assertEquals(count, core.client.queue.value.let { it.playingNext.size + it.upcoming.size }) + } + + @Test + fun theCurrentTrackIsNeverSwipedAway() { + val core = TestCore() + compose.setThemedContent(core) { QueuePanel() } + core.start() + compose.waitUntil(5_000) { core.client.started.value } + compose.waitForIdle() + val current = core.client.queue.value.current!! + compose.onNodeWithTag("queue.row.${current.item.key}").performTouchInput { swipeLeft() } + compose.waitForIdle() + assertEquals(current.item.key, core.client.queue.value.current?.item?.key) + } + + @Test + fun customiseSetsEachDirectionPerSurface() { + val core = TestCore(startPlaying = false) + compose.setThemedContent(core) { CustomiseSettingsScreen(rememberNavController()) } + core.start() + compose.waitUntil(5_000) { core.client.started.value } + compose.onNodeWithText("Swipe actions").assertExists() + // Lists never offer "Remove from queue"; the queue does. + val list = compose.onNodeWithTag("customise.${SettingKeys.SWIPE_LIST_END_TO_START}").performScrollTo() + assertFalse(list.onChildren().fetchSemanticsNodes().any { it.config.getOrNull(SemanticsProperties.Text)?.joinToString() == "Remove from queue" }) + compose.onNodeWithTag("customise.${SettingKeys.SWIPE_QUEUE_END_TO_START}").performScrollTo().onChildren().filterToOne(hasText("Remove from queue")).assertIsOn() + list.onChildren().filterToOne(hasText("Add to playlist")).performClick() + compose.waitUntil(5_000) { core.client.settings.value[SettingKeys.SWIPE_LIST_END_TO_START]?.value == "\"${ActionIds.ADD_TO_PLAYLIST}\"" } + compose.onNodeWithTag("customise.${SettingKeys.SWIPE_QUEUE_START_TO_END}").performScrollTo().onChildren().filterToOne(hasText("None")).performClick() + compose.waitUntil(5_000) { core.client.settings.value[SettingKeys.SWIPE_QUEUE_START_TO_END]?.value == "\"${SwipeOptions.NONE}\"" } + } +} diff --git a/android/core/src/main/java/app/hocket/core/SettingKeys.kt b/android/core/src/main/java/app/hocket/core/SettingKeys.kt index dc93bce..61209af 100644 --- a/android/core/src/main/java/app/hocket/core/SettingKeys.kt +++ b/android/core/src/main/java/app/hocket/core/SettingKeys.kt @@ -46,6 +46,11 @@ object SettingKeys { const val LIBRARY_SYNC_INTERVAL_MINUTES = "library.syncIntervalMinutes" const val LIBRARY_FULL_RECONCILE_DAYS = "library.fullReconcileDays" const val SEARCH_INCLUDE_SERVER = "search.includeServer" + /** Swipe actions on song rows: an [ActionIds] id or [SwipeOptions.NONE] (see [SwipeOptions]). */ + const val SWIPE_QUEUE_START_TO_END = "swipe.queue.startToEnd" + const val SWIPE_QUEUE_END_TO_START = "swipe.queue.endToStart" + const val SWIPE_LIST_START_TO_END = "swipe.list.startToEnd" + const val SWIPE_LIST_END_TO_START = "swipe.list.endToStart" fun actionOrder(surface: String) = "actions.order.$surface" } @@ -100,3 +105,19 @@ object ActionIds { SAVE_QUEUE_AS_PLAYLIST, DELETE_SAVED_QUEUE, DELETE_PLAYLIST) val MEDIA_SESSION = listOf(PREVIOUS, TOGGLE_PLAY, NEXT, SHUFFLE, REPEAT, LOVE) } + +/** + * The choices of the swipe settings (`SWIPE_QUEUE_ACTIONS` / `SWIPE_LIST_ACTIONS` in the core's + * settings registry) and their defaults. "love" toggles: the row runs love or unlove by its state. + */ +object SwipeOptions { + const val NONE = "none" + val QUEUE = listOf(NONE, ActionIds.REMOVE_FROM_QUEUE, ActionIds.PLAY_NEXT, ActionIds.LOVE, ActionIds.ADD_TO_PLAYLIST, ActionIds.DOWNLOAD) + val LIST = listOf(NONE, ActionIds.PLAY_NEXT, ActionIds.PLAY_LATER, ActionIds.LOVE, ActionIds.ADD_TO_PLAYLIST, ActionIds.DOWNLOAD) + val DEFAULTS = mapOf( + SettingKeys.SWIPE_QUEUE_START_TO_END to ActionIds.REMOVE_FROM_QUEUE, + SettingKeys.SWIPE_QUEUE_END_TO_START to ActionIds.REMOVE_FROM_QUEUE, + SettingKeys.SWIPE_LIST_START_TO_END to ActionIds.PLAY_NEXT, + SettingKeys.SWIPE_LIST_END_TO_START to ActionIds.PLAY_LATER, + ) +} diff --git a/android/core/src/main/java/app/hocket/core/client/CoreClient.kt b/android/core/src/main/java/app/hocket/core/client/CoreClient.kt index ccf4caf..a3c185d 100644 --- a/android/core/src/main/java/app/hocket/core/client/CoreClient.kt +++ b/android/core/src/main/java/app/hocket/core/client/CoreClient.kt @@ -123,6 +123,23 @@ class CoreClient( val errors: SharedFlow = _errors.asSharedFlow() private val _libraryChanged = MutableSharedFlow(extraBufferCapacity = 16) val libraryChanged: SharedFlow = _libraryChanged.asSharedFlow() + private val _libraryItemsChanged = MutableSharedFlow(extraBufferCapacity = 16) + /** + * Ratings and loves as they stand now (set here, undone, or set on another signed-in device): + * patch held copies with [LibraryPatches]. A `libraryChanged` follows for lists. + */ + val libraryItemsChanged: SharedFlow = _libraryItemsChanged.asSharedFlow() + private val _playlistChanged = MutableSharedFlow(extraBufferCapacity = 16) + /** A playlist's row now (`null` = deleted), from here or another device. */ + val playlistChanged: SharedFlow = _playlistChanged.asSharedFlow() + private val _libraryGeneration = MutableStateFlow(0) + /** + * Bumped on every `LibraryChanged` (which the core also sends after each `LibraryItemsChanged` + * and `PlaylistChanged`). Key refetches on this rather than on [libraryChanged]'s last value: + * two identical payloads in a row (rating the same track twice, the same change arriving from + * another device) are equal and would not restart a `LaunchedEffect`. + */ + val libraryGeneration: StateFlow = _libraryGeneration.asStateFlow() private val _exports = MutableSharedFlow(extraBufferCapacity = 4) /** `NspExported` and `ConfigExported`, for the share sheet. */ val exports: SharedFlow = _exports.asSharedFlow() @@ -315,8 +332,22 @@ class CoreClient( albumPages.invalidate { it.serverId == sid } artistPages.invalidate { it.serverId == sid } playlistTrackPages.invalidate() + _libraryGeneration.update { it + 1 } _libraryChanged.tryEmit(event.data) } + is Event.LibraryItemsChanged -> { + val items = event.data.items + // The core re-emits these when the item is queued; patching now keeps the player + // and the queue in step with the rows that change below. + _queue.update { LibraryPatches.queue(it, items) } + _nowPlaying.update { e -> e?.let { LibraryPatches.entry(it, items) } } + _libraryItemsChanged.tryEmit(event.data) + } + is Event.PlaylistChanged -> { + val id = event.data.playlist_id + playlistTrackPages.invalidate { it.playlistId == id } + _playlistChanged.tryEmit(event.data) + } is Event.SearchResults -> _searchResults.tryEmit(event.data.results) is Event.SessionChanged -> { _session.value = event.data.document diff --git a/android/core/src/main/java/app/hocket/core/client/LibraryPatches.kt b/android/core/src/main/java/app/hocket/core/client/LibraryPatches.kt new file mode 100644 index 0000000..c01b28a --- /dev/null +++ b/android/core/src/main/java/app/hocket/core/client/LibraryPatches.kt @@ -0,0 +1,49 @@ +package app.hocket.core.client + +import app.hocket.core.api.Album +import app.hocket.core.api.Artist +import app.hocket.core.api.LibraryItemKind +import app.hocket.core.api.LibraryItemState +import app.hocket.core.api.QueueEntry +import app.hocket.core.api.QueueView +import app.hocket.core.api.SearchResults +import app.hocket.core.api.Track +import app.hocket.core.api.TrackSummary + +/** + * In-place updates for copies of library items a screen already holds, from `Event.LibraryItemsChanged` + * (a rating or love set here, undone, or set on another signed-in device). The core has already + * written these values to its mirror; this only saves a screen a refetch (search results, a menu's + * track, the now-playing entry until the core's own `NowPlayingChanged` lands). No decisions here: + * the values are the core's. + */ +object LibraryPatches { + private fun find(items: List, kind: LibraryItemKind, id: String) = items.firstOrNull { it.kind == kind && it.id == id } + + fun track(t: TrackSummary, items: List): TrackSummary = + find(items, LibraryItemKind.Track, t.id)?.let { s -> if (s.rating == t.rating && s.loved == t.loved) t else t.copy(rating = s.rating, loved = s.loved) } ?: t + + fun track(t: Track, items: List): Track = + find(items, LibraryItemKind.Track, t.id)?.let { s -> if (s.rating == t.rating && s.loved == t.loved) t else t.copy(rating = s.rating, loved = s.loved) } ?: t + + fun album(a: Album, items: List): Album = + find(items, LibraryItemKind.Album, a.id)?.let { s -> if (s.rating == a.rating && s.loved == a.loved) a else a.copy(rating = s.rating, loved = s.loved) } ?: a + + fun artist(a: Artist, items: List): Artist = + find(items, LibraryItemKind.Artist, a.id)?.let { s -> if (s.loved == a.loved) a else a.copy(loved = s.loved) } ?: a + + fun entry(e: QueueEntry, items: List): QueueEntry = track(e.track, items).let { if (it === e.track) e else e.copy(track = it) } + + fun search(r: SearchResults, items: List): SearchResults = r.copy( + tracks = r.tracks.map { track(it, items) }, + albums = r.albums.map { album(it, items) }, + artists = r.artists.map { artist(it, items) }, + ) + + fun queue(q: QueueView, items: List): QueueView = q.copy( + history = q.history.map { entry(it, items) }, + current = q.current?.let { entry(it, items) }, + playingNext = q.playingNext.map { entry(it, items) }, + upcoming = q.upcoming.map { entry(it, items) }, + ) +} diff --git a/android/core/src/main/java/app/hocket/core/fake/FakeCore.kt b/android/core/src/main/java/app/hocket/core/fake/FakeCore.kt index 435be73..f2482ce 100644 --- a/android/core/src/main/java/app/hocket/core/fake/FakeCore.kt +++ b/android/core/src/main/java/app/hocket/core/fake/FakeCore.kt @@ -5,6 +5,7 @@ import app.hocket.core.CoreKind import app.hocket.core.ActionIds import app.hocket.core.HocketJson import app.hocket.core.SettingKeys +import app.hocket.core.SwipeOptions import app.hocket.core.api.* import app.hocket.core.toSummary import kotlinx.coroutines.CoroutineDispatcher @@ -164,6 +165,7 @@ class FakeCore( def(SettingKeys.LIBRARY_SYNC_INTERVAL_MINUTES, "60", SettingScope.DeviceLocal) def(SettingKeys.LIBRARY_FULL_RECONCILE_DAYS, "7", SettingScope.DeviceLocal) def(SettingKeys.SEARCH_INCLUDE_SERVER, "true", SettingScope.AccountSynced) + SwipeOptions.DEFAULTS.forEach { (key, id) -> def(key, "\"$id\"", SettingScope.AccountSynced) } settings.forEach { (k, v) -> defaults[k] = v.value } filters += Filter("f-loved", "Loved, not played lately", FilterNode.All(listOf( FilterNode.Rule(FilterRule(FilterField.Loved, FilterOp.IsTrue, FilterValue.Bool(true))), @@ -989,10 +991,10 @@ class FakeCore( pushUndo(label) { var changed = 0 prior.forEach { (t, r) -> when (t) { is RatingTarget.Track -> library.updateTrack(t.data.id) { tr -> if (tr.rating.toInt() == rating) tr.copy(rating = r.toUInt()) else { changed++; tr } }; is RatingTarget.Album -> library.updateAlbum(t.data.id) { al -> al.copy(rating = r.toUInt()) } } } - emitLibrary(prior.keys.map { idOf(it) }); emitQueue() + emitItems(prior.keys.toList()); emitLibrary(prior.keys.map { idOf(it) }); emitQueue() } if (targets.size > 20) startJob(JobKind.BulkRating, "Rating ${targets.size} tracks", targets.size) - emitLibrary(targets.map { idOf(it) }); emitQueue() + emitItems(targets); emitLibrary(targets.map { idOf(it) }); emitQueue() if (targets.size > 1) toast("Rated ${targets.size} items", "Undo", Command.Undo) } @@ -1006,13 +1008,23 @@ class FakeCore( is RatingTarget.Track -> library.updateTrack(t.data.id) { it.copy(loved = !loved) } is RatingTarget.Album -> library.updateAlbum(t.data.id) { it.copy(loved = !loved) } } - emitLibrary(targets.map { idOf(it) }); emitQueue() + emitItems(targets); emitLibrary(targets.map { idOf(it) }); emitQueue() } if (targets.size > 20) startJob(JobKind.BulkLove, "${if (loved) "Loving" else "Unloving"} ${targets.size} tracks", targets.size) - emitLibrary(targets.map { idOf(it) }); emitQueue() + emitItems(targets); emitLibrary(targets.map { idOf(it) }); emitQueue() } private fun idOf(t: RatingTarget) = when (t) { is RatingTarget.Track -> t.data.id; is RatingTarget.Album -> t.data.id } + /** What the core emits ahead of `LibraryChanged` for a rating or love: the values as they stand now. */ + private fun emitItems(targets: List) { + val items = targets.mapNotNull { t -> + when (t) { + is RatingTarget.Track -> library.track(t.data.id)?.let { LibraryItemState(LibraryItemKind.Track, it.id, it.rating, it.loved) } + is RatingTarget.Album -> library.album(t.data.id)?.let { LibraryItemState(LibraryItemKind.Album, it.id, it.rating, it.loved) } + } + } + if (items.isNotEmpty()) emit(Event.LibraryItemsChanged(EventLibraryItemsChangedInner(serverId, items, null))) + } private fun createPlaylist(name: String, ids: List, smart: Boolean = false) { val id = newId("pl") diff --git a/android/core/src/test/java/app/hocket/core/client/LibraryPatchesTest.kt b/android/core/src/test/java/app/hocket/core/client/LibraryPatchesTest.kt new file mode 100644 index 0000000..a621dc6 --- /dev/null +++ b/android/core/src/test/java/app/hocket/core/client/LibraryPatchesTest.kt @@ -0,0 +1,67 @@ +package app.hocket.core.client + +import app.hocket.core.api.* +import app.hocket.core.fake.FakeCore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class LibraryPatchesTest { + private var clock = 5_000_000.0 + + private fun summary(id: String, rating: UInt = 0u, loved: Boolean = false) = + TrackSummary(id, "s", "T $id", null, null, null, null, 1000u, null, rating, loved, OfflineState.None) + + private fun state(kind: LibraryItemKind, id: String, rating: UInt, loved: Boolean) = LibraryItemState(kind, id, rating, loved) + + @Test + fun patchesOnlyTheNamedItemsOfTheirKind() { + val items = listOf(state(LibraryItemKind.Track, "t1", 4u, true), state(LibraryItemKind.Album, "t2", 5u, true)) + val t1 = LibraryPatches.track(summary("t1"), items) + assertEquals(4u, t1.rating) + assertTrue(t1.loved) + // Same id, other kind: untouched (and the same instance, so Compose skips it). + val t2 = summary("t2") + assertSame(t2, LibraryPatches.track(t2, items)) + val results = SearchResults("r", "q", listOf(summary("t0"), summary("t1")), emptyList(), emptyList(), emptyList(), false) + val patched = LibraryPatches.search(results, items) + assertEquals(listOf(0u, 4u), patched.tracks.map { it.rating }) + } + + @Test + fun itemChangesPatchNowPlayingAndEveryLibraryChangeBumpsTheGeneration() = runTest { + val core = FakeCore(seed = 3, timers = false, now = { clock }, dispatcher = Dispatchers.Unconfined) + val client = CoreClient(core, backgroundScope, now = { clock }) + runCurrent() + client.requestSnapshot() + runCurrent() + val np = client.nowPlaying.value + assertNotNull(np) + val id = np!!.track.id + val gen = client.libraryGeneration.value + val received = mutableListOf() + backgroundScope.launch(Dispatchers.Unconfined) { client.libraryItemsChanged.collect { received += it } } + runCurrent() + + client.onEvent(Event.LibraryItemsChanged(EventLibraryItemsChangedInner(np.track.serverId, listOf(state(LibraryItemKind.Track, id, 3u, !np.track.loved)), "other-device"))) + assertEquals(3u, client.nowPlaying.value!!.track.rating) + assertEquals(!np.track.loved, client.nowPlaying.value!!.track.loved) + client.queue.value.current?.let { assertEquals(3u, it.track.rating) } + assertEquals("other-device", received.single().from_device) + assertEquals("patched in place; the LibraryChanged that follows refetches", gen, client.libraryGeneration.value) + + // Two identical LibraryChanged payloads still count as two changes. + val same = Event.LibraryChanged(EventLibraryChangedInner(np.track.serverId, listOf("tracks"), listOf(id))) + client.onEvent(same) + client.onEvent(same) + assertEquals(gen + 2, client.libraryGeneration.value) + } +} diff --git a/android/playback/src/main/AndroidManifest.xml b/android/playback/src/main/AndroidManifest.xml index ff3447c..f32ef52 100644 --- a/android/playback/src/main/AndroidManifest.xml +++ b/android/playback/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -22,6 +23,17 @@ + + + + + +