Add nicer play/pause button on WearOS (#8564)

### Description

Add nicer play/pause button on WearOS. During playback (and shortly
after), shows a large playback button instead of a simple "play on
phone" button.

<img width="200" alt="watch_media_2026-07-02_18_49_28"
src="https://github.com/user-attachments/assets/a7fa0734-8317-47bc-afd0-6e1ab7be89e1"
/> <img width="200" alt="watch_media_2026-07-02_18_49_54"
src="https://github.com/user-attachments/assets/f935d805-e018-4137-809f-dc5c4690d82d"
/>

### Checklist
<!-- 
To help us keep the issue tracker clean and work as efficient as
possible,
  please make sure that you have done all of the following.
You can tick the boxes below by placing an x inside the brackets like
this: [x]
-->
- [x] I have read the contribution guidelines:
https://github.com/AntennaPod/AntennaPod/blob/develop/CONTRIBUTING.md#submit-a-pull-request
- [x] I have performed a self-review of my code, going through my
changes line by line and carefully considering why this line change is
necessary
- [x] I have run the automated code checks using `./gradlew checkstyle
lint`
- [x] My code follows the style guidelines of the AntennaPod project:
https://antennapod.org/contribute/develop/app/code-style
- [x] I have mentioned the corresponding issue and the relevant keyword
(e.g., "Closes: #xy") in the description (see
https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue)
- [x] If it is a core feature, I have added automated tests
This commit is contained in:
Hans-Peter Lehmann
2026-07-03 18:06:34 +02:00
committed by GitHub
parent 43e4f64faf
commit b8f8426c8c
5 changed files with 86 additions and 11 deletions

View File

@ -63,6 +63,7 @@ afterEvaluate {
dependencies {
implementation project(":model")
implementation project(":ui:common")
implementation project(":ui:notifications")
implementation project(":net:sync:wear-interface")
// NOTE: DO NOT INCLUDE a dependency on androidx.compose.material:material, the watch version is a replacement

View File

@ -13,6 +13,7 @@ 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.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@ -22,6 +23,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.Hyphens
import androidx.compose.ui.text.style.LineBreak
@ -32,6 +34,9 @@ import androidx.core.content.IntentCompat
import androidx.lifecycle.ViewModelProvider
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
import androidx.wear.compose.material3.FilledIconButton
import androidx.wear.compose.material3.FilledTonalIconButton
import androidx.wear.compose.material3.Icon
import androidx.wear.compose.material3.LinearProgressIndicator
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ScrollIndicator
@ -41,6 +46,7 @@ import de.danoeh.antennapod.model.feed.FeedItem
import de.danoeh.antennapod.ui.common.Converter
import de.danoeh.antennapod.ui.common.DateFormatter
import de.danoeh.antennapod.ui.common.R as CommonR
import de.danoeh.antennapod.ui.notifications.R as NotificationsR
import de.danoeh.antennapod.wearos.composable.ListItem
class EpisodeDetailActivity : ComponentActivity() {
@ -60,6 +66,8 @@ class EpisodeDetailActivity : ComponentActivity() {
uiState = uiState,
onPlay = { viewModel.play() },
onPause = { viewModel.pause() },
onSkipForward = { viewModel.skipForward() },
onSkipBackward = { viewModel.skipBackward() },
onOpenOnPhone = { viewModel.openOnPhone() }
)
}
@ -76,6 +84,8 @@ fun EpisodeDetailScreen(
uiState: EpisodeDetailUiState,
onPlay: () -> Unit,
onPause: () -> Unit,
onSkipForward: () -> Unit,
onSkipBackward: () -> Unit,
onOpenOnPhone: () -> Unit
) {
val item = uiState.item
@ -112,7 +122,7 @@ fun EpisodeDetailScreen(
lineBreak = LineBreak.Paragraph
),
textAlign = TextAlign.Center,
maxLines = if (titleExpanded) Int.MAX_VALUE else 3,
maxLines = if (titleExpanded) Int.MAX_VALUE else 2,
overflow = if (titleExpanded) TextOverflow.Clip else TextOverflow.Ellipsis
)
}
@ -131,7 +141,12 @@ fun EpisodeDetailScreen(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp, top = 2.dp, end = 8.dp, bottom = 16.dp),
.padding(
start = 8.dp,
top = 2.dp,
end = 8.dp,
bottom = if (uiState.hasStartedPlaying) 8.dp else 16.dp
),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
@ -149,12 +164,47 @@ fun EpisodeDetailScreen(
}
item {
if (uiState.isCurrentlyPlaying) {
ListItem(
text = stringResource(CommonR.string.pause_label),
iconRes = CommonR.drawable.ic_pause_black,
onClick = onPause
)
if (uiState.hasStartedPlaying) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)
) {
FilledTonalIconButton(onClick = onSkipBackward, modifier = Modifier.size(48.dp)) {
Icon(
painter = painterResource(NotificationsR.drawable.ic_notification_fast_rewind),
contentDescription = stringResource(CommonR.string.rewind_label),
modifier = Modifier.size(24.dp)
)
}
val playIcon = if (uiState.isCurrentlyPlaying) {
CommonR.drawable.ic_pause_black
} else {
CommonR.drawable.ic_play_48dp_black
}
val playLabel = if (uiState.isCurrentlyPlaying) {
CommonR.string.pause_label
} else {
CommonR.string.play_label
}
FilledIconButton(
onClick = if (uiState.isCurrentlyPlaying) onPause else onPlay,
modifier = Modifier.size(60.dp)
) {
Icon(
painter = painterResource(playIcon),
contentDescription = stringResource(playLabel),
modifier = Modifier.size(24.dp)
)
}
FilledTonalIconButton(onClick = onSkipForward, modifier = Modifier.size(48.dp)) {
Icon(
painter = painterResource(NotificationsR.drawable.ic_notification_fast_forward),
contentDescription = stringResource(CommonR.string.fast_forward_label),
modifier = Modifier.size(24.dp)
)
}
}
} else {
ListItem(
text = stringResource(CommonR.string.wearos_play_on_phone),

View File

@ -22,7 +22,8 @@ data class EpisodeDetailUiState(
val item: FeedItem,
val position: Int = 0,
val duration: Int = 0,
val isCurrentlyPlaying: Boolean = false
val isCurrentlyPlaying: Boolean = false,
val hasStartedPlaying: Boolean = false
)
class EpisodeDetailViewModel(application: Application, private val episode: FeedItem) : AndroidViewModel(application) {
@ -30,7 +31,9 @@ class EpisodeDetailViewModel(application: Application, private val episode: Feed
EpisodeDetailUiState(
item = episode,
position = episode.media?.position ?: 0,
duration = episode.media?.duration ?: 0
duration = episode.media?.duration ?: 0,
hasStartedPlaying = WearDataRepository.nowPlaying.value
?.takeIf { it.item.id == episode.id }?.isPlaying == true
)
)
val uiState: StateFlow<EpisodeDetailUiState> = _uiState
@ -50,7 +53,12 @@ class EpisodeDetailViewModel(application: Application, private val episode: Feed
val duration = liveData?.item?.media?.duration?.takeIf { it > 0 } ?: episode.media?.duration ?: 0
val isCurrentlyPlaying = liveData?.isPlaying == true
_uiState.update {
it.copy(position = position, duration = duration, isCurrentlyPlaying = isCurrentlyPlaying)
it.copy(
position = position,
duration = duration,
isCurrentlyPlaying = isCurrentlyPlaying,
hasStartedPlaying = it.hasStartedPlaying || isCurrentlyPlaying
)
}
}
}
@ -66,6 +74,14 @@ class EpisodeDetailViewModel(application: Application, private val episode: Feed
viewModelScope.launch(Dispatchers.IO) { WearMessageSender.send(getApplication(), WearDataPaths.PAUSE) }
}
fun skipForward() {
viewModelScope.launch(Dispatchers.IO) { WearMessageSender.send(getApplication(), WearDataPaths.SKIP_FORWARD) }
}
fun skipBackward() {
viewModelScope.launch(Dispatchers.IO) { WearMessageSender.send(getApplication(), WearDataPaths.SKIP_BACKWARD) }
}
fun openOnPhone() {
viewModelScope.launch(Dispatchers.IO) {
WearMessageSender.send(getApplication(), WearDataPaths.openOnPhonePath(episode.id))