Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ class QueueRepository @Inject constructor(
* advancing to it shouldn't also dequeue it the way the old pop-and-remove semantics did. */
suspend fun peekFront(): String? = queueDao.firstItemId()

/** All queued item ids in position order (issue #285), for a "next episode" transport control
* to find whatever comes after the currently-playing (front) entry. */
suspend fun orderedItemIds(): List<String> = queueDao.orderedItemIds()

/**
* Evicts this feed's oldest *auto-queued* episodes (earliest added, not earliest published)
* down to [maxCount] (issue #68) -- manually-queued entries are never evicted by this, so a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,20 @@ class QueueRepositoryTest {
assertNull(queueRepository.peekFront())
}

@Test
fun orderedItemIds_returnsQueueInPositionOrder() = runTest {
queueRepository.addToEnd("ep-1")
queueRepository.addToEnd("ep-2")
queueRepository.addToEnd("ep-3")

assertEquals(listOf("ep-1", "ep-2", "ep-3"), queueRepository.orderedItemIds())
}

@Test
fun orderedItemIds_emptyQueue_returnsEmptyList() = runTest {
assertEquals(emptyList<String>(), queueRepository.orderedItemIds())
}

@Test
fun moveToFront_alreadyQueued_movesExistingEntryToFront() = runTest {
queueRepository.addToEnd("ep-1")
Expand Down
1 change: 1 addition & 0 deletions wear/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ dependencies {
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material.icons.extended)
implementation(libs.androidx.wear.compose.material)
implementation(libs.androidx.wear.compose.foundation)
implementation(libs.androidx.wear.compose.navigation)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,50 +1,209 @@
package com.bugzapperlabs.mycasts.wear.nowplaying

import android.content.Context
import android.media.AudioManager
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FastForward
import androidx.compose.material.icons.filled.FastRewind
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.rotary.onRotaryScrollEvent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.wear.compose.material.Chip
import androidx.wear.compose.material.ChipDefaults
import androidx.wear.compose.material.Button
import androidx.wear.compose.material.ButtonDefaults
import androidx.wear.compose.material.CompactChip
import androidx.wear.compose.material.Icon
import androidx.wear.compose.material.Text
import java.util.concurrent.TimeUnit
import kotlin.math.absoluteValue
import kotlin.math.roundToLong
import kotlin.math.sign

/** The watch's transport screen (issue #276) -- play/pause and a position readout, trimmed from
* `:app`'s in-page player: no seek bar/chapters/speed controls, since [WearPlaybackUiState] (and
* the synced [com.bugzapperlabs.mycasts.data.local.Feed] it's built from) doesn't carry those. */
// Accumulated scroll pixels needed before firing one AudioManager.ADJUST_RAISE/LOWER step.
// Calibrated against real Pixel Watch 4 hardware (issue #285 follow-up): a single unhurried
// crown turn accumulates roughly 150-250 scroll pixels total across its burst of events, so this
// is low enough that one turn produces several perceptible volume steps, not just one.
private const val ROTARY_PIXELS_PER_VOLUME_STEP = 15f

/** The watch's transport screen (issue #276/#285): play/pause, skip forward/backward, a
* draggable seek bar, next/previous-episode, and a speed toggle -- trimmed from `:app`'s in-page
* player only in that there's no chapters UI, since chapters aren't part of the synced
* [com.bugzapperlabs.mycasts.data.local.Feed]/[com.bugzapperlabs.mycasts.data.local.FeedItem]
* snapshot on the watch.
*
* The rotary input (crown/bezel) drives system media volume here, not seeking -- that's the
* Wear OS convention, and it isn't automatic: a media app has to explicitly forward rotary
* events to [AudioManager.adjustStreamVolume] itself. No `FLAG_SHOW_UI` (issue #285 follow-up):
* that flag launches the system's own `VolumeActivity` as a separate, focus-stealing Activity on
* Wear OS (unlike the lightweight overlay `FLAG_SHOW_UI` produces on phones), which took window
* focus away from this screen after the first crown tick and silently ate every tick after that. */
@Composable
fun NowPlayingScreen(viewModel: NowPlayingViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
var accumulatedScrollPixels by remember { mutableFloatStateOf(0f) }

Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp),
.padding(horizontal = 12.dp)
.onRotaryScrollEvent { event ->
accumulatedScrollPixels += event.verticalScrollPixels
if (accumulatedScrollPixels.absoluteValue >= ROTARY_PIXELS_PER_VOLUME_STEP) {
val direction = if (accumulatedScrollPixels.sign > 0) AudioManager.ADJUST_RAISE else AudioManager.ADJUST_LOWER
audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, direction, 0)
accumulatedScrollPixels = 0f
}
true
}
.focusRequester(focusRequester)
.focusable(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(uiState.title ?: "Nothing playing", maxLines = 2)
// basicMarquee (issue #285 follow-up): matches how Wear OS's own system media control
// card scrolls a title too long to fit, rather than truncating it.
Text(
uiState.title ?: "Nothing playing",
maxLines = 1,
modifier = Modifier.basicMarquee(),
)
uiState.feedTitle?.let { Text(it, maxLines = 1) }

SeekBar(
positionMs = uiState.positionMs,
durationMs = uiState.durationMs,
onSeek = viewModel::seekTo,
)
Text("${formatDuration(uiState.positionMs)} / ${formatDuration(uiState.durationMs)}")
Chip(
onClick = viewModel::togglePlayPause,
label = {
Text(
when {
uiState.isBuffering -> "Buffering…"
uiState.isPlaying -> "Pause"
else -> "Play"
if (uiState.isBuffering) Text("Buffering…")

Row(verticalAlignment = Alignment.CenterVertically) {
Button(
onClick = viewModel::skipBackward,
colors = ButtonDefaults.secondaryButtonColors(),
modifier = Modifier.size(ButtonDefaults.SmallButtonSize),
) {
Icon(Icons.Filled.FastRewind, contentDescription = "Back 15 seconds")
}
Button(
onClick = viewModel::togglePlayPause,
modifier = Modifier
.padding(horizontal = 8.dp)
.size(ButtonDefaults.DefaultButtonSize),
) {
Icon(
if (uiState.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (uiState.isPlaying) "Pause" else "Play",
)
}
Button(
onClick = viewModel::skipForward,
colors = ButtonDefaults.secondaryButtonColors(),
modifier = Modifier.size(ButtonDefaults.SmallButtonSize),
) {
Icon(Icons.Filled.FastForward, contentDescription = "Forward 30 seconds")
}
}

Row(verticalAlignment = Alignment.CenterVertically) {
Button(
onClick = viewModel::previousEpisode,
colors = ButtonDefaults.secondaryButtonColors(),
modifier = Modifier.size(ButtonDefaults.SmallButtonSize),
) {
Icon(Icons.Filled.SkipPrevious, contentDescription = "Restart episode")
}
CompactChip(
onClick = viewModel::cycleSpeed,
label = { Text("${uiState.speed}x") },
modifier = Modifier.padding(horizontal = 8.dp),
)
Button(
onClick = viewModel::nextEpisode,
colors = ButtonDefaults.secondaryButtonColors(),
modifier = Modifier.size(ButtonDefaults.SmallButtonSize),
) {
Icon(Icons.Filled.SkipNext, contentDescription = "Next episode")
}
}
}
}

/** A draggable progress indicator (issue #285) -- position updates live while dragging and the
* actual seek fires once on release, so a slow drag doesn't spam the player with intermediate
* seeks. */
@Composable
private fun SeekBar(positionMs: Long, durationMs: Long, onSeek: (Long) -> Unit) {
var dragFraction by remember { mutableStateOf<Float?>(null) }
val fraction = dragFraction
?: if (durationMs > 0L) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f

Box(
modifier = Modifier
.fillMaxWidth()
.height(6.dp)
.pointerInput(durationMs) {
if (durationMs <= 0L) return@pointerInput
detectHorizontalDragGestures(
onDragEnd = {
dragFraction?.let { onSeek((it * durationMs).roundToLong()) }
dragFraction = null
},
onDragCancel = { dragFraction = null },
onHorizontalDrag = { change, _ ->
change.consume()
dragFraction = (change.position.x / size.width.toFloat()).coerceIn(0f, 1f)
},
)
},
colors = ChipDefaults.primaryChipColors(),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.DarkGray, RoundedCornerShape(3.dp)),
)
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(fraction)
.background(Color.White, RoundedCornerShape(3.dp)),
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package com.bugzapperlabs.mycasts.wear.nowplaying

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.bugzapperlabs.mycasts.wear.playback.WearPlaybackController
import com.bugzapperlabs.mycasts.wear.playback.WearPlaybackUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject

/** Backs [com.bugzapperlabs.mycasts.wear.nowplaying.NowPlayingScreen] (issue #276) -- a thin
/** Backs [com.bugzapperlabs.mycasts.wear.nowplaying.NowPlayingScreen] (issue #276/#285) -- a thin
* pass-through to [WearPlaybackController], the same relationship `:app`'s in-page player has to
* [com.bugzapperlabs.mycasts.playback.PlaybackController]. */
@HiltViewModel
Expand All @@ -21,4 +23,16 @@ class NowPlayingViewModel @Inject constructor(
}

fun seekTo(positionMs: Long) = playbackController.seekTo(positionMs)

fun skipForward() = playbackController.skipForward()

fun skipBackward() = playbackController.skipBackward()

fun cycleSpeed() = playbackController.cycleSpeed()

fun nextEpisode() {
viewModelScope.launch { playbackController.nextEpisode() }
}

fun previousEpisode() = playbackController.previousEpisode()
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,19 @@ import javax.inject.Singleton

private const val POSITION_TICK_MS = 500L

// Shared with WearPlaybackController's skip buttons (issue #285) -- matches :app's
// PlaybackController.SKIP_FORWARD_MS/SKIP_BACKWARD_MS amounts for a consistent skip feel.
internal const val SKIP_FORWARD_MS = 30_000L
internal const val SKIP_BACKWARD_MS = 15_000L

/** Speed presets cycled by the now-playing screen's speed control (issue #285), matching
* `:app`'s `NOTIFICATION_PLAYBACK_SPEEDS`. */
internal val PLAYBACK_SPEEDS = listOf(1.0f, 1.25f, 1.5f, 1.75f, 2.0f)

/** UI-facing playback state on the watch (issue #276) -- trimmed from `:app`'s
* [com.bugzapperlabs.mycasts.playback.PlaybackUiState]: no chapters/volume-boost/speed
* cycling, none of which the synced [com.bugzapperlabs.mycasts.data.local.Feed] snapshot carries
* or the watch UI (issue #276's step 6) is scoped to expose. */
* [com.bugzapperlabs.mycasts.playback.PlaybackUiState]: no chapters/volume-boost, neither of
* which the synced [com.bugzapperlabs.mycasts.data.local.Feed] snapshot carries or the watch UI
* is scoped to expose. */
data class WearPlaybackUiState(
val currentItemId: String? = null,
val title: String? = null,
Expand All @@ -37,6 +46,7 @@ data class WearPlaybackUiState(
val isBuffering: Boolean = false,
val positionMs: Long = 0L,
val durationMs: Long = 0L,
val speed: Float = 1.0f,
val artworkUrl: String? = null,
)

Expand Down Expand Up @@ -67,6 +77,7 @@ class WearPlaybackController @Inject constructor(
isBuffering = player.playbackState == Player.STATE_BUFFERING,
positionMs = player.currentPosition,
durationMs = player.duration.coerceAtLeast(0L),
speed = player.playbackParameters.speed,
artworkUrl = player.currentMediaItem?.mediaMetadata?.artworkUri?.toString(),
)

Expand Down Expand Up @@ -138,4 +149,44 @@ class WearPlaybackController @Inject constructor(
fun stop() {
connect { it.stop() }
}

/** Issue #285: skip amounts mirror `:app`'s [com.bugzapperlabs.mycasts.playback.PlaybackController.skipForward]. */
fun skipForward() {
val playback = uiState.value
seekTo((playback.positionMs + SKIP_FORWARD_MS).coerceAtMost(playback.durationMs))
}

fun skipBackward() {
val playback = uiState.value
seekTo((playback.positionMs - SKIP_BACKWARD_MS).coerceAtLeast(0L))
}

/** Cycles through [PLAYBACK_SPEEDS] (issue #285) -- a manual, session-only override, not
* persisted anywhere (unlike `:app`'s per-feed [com.bugzapperlabs.mycasts.data.local.Feed.playbackSpeed],
* which isn't part of the synced feed snapshot on the watch). */
fun cycleSpeed() {
val current = uiState.value.speed
val currentIndex = PLAYBACK_SPEEDS.indexOfFirst { kotlin.math.abs(it - current) < 0.01f }
val nextSpeed = PLAYBACK_SPEEDS[(currentIndex + 1).mod(PLAYBACK_SPEEDS.size)]
connect { it.setPlaybackSpeed(nextSpeed) }
}

/** Plays whatever's queued directly after the current front entry (issue #285) -- there's no
* play-history stack on the watch, so unlike a real "next track" jump this is really "advance
* the queue by one," the same effect [WearPlaybackService]'s own auto-advance has on
* completion, just user-triggered early. A no-op if nothing else is queued. */
suspend fun nextEpisode() {
val orderedIds = queueRepository.orderedItemIds()
val currentIndex = orderedIds.indexOf(uiState.value.currentItemId)
val nextId = orderedIds.getOrNull(currentIndex + 1) ?: return
val item = feedRepository.getItem(nextId) ?: return
play(item)
}

/** Restarts the current episode from the beginning (issue #285) -- with no play-history stack
* to jump back into, this is the only thing "previous" can sensibly mean here, matching how
* most media players treat "previous" once already at the start of the list. */
fun previousEpisode() {
seekTo(0L)
}
}
Loading
Loading