feat(android,ios): read each library tile once, with its long press as an action

A poster's artwork carried the title's name and so did the text under
it, so a screen reader said the name twice. The artwork is now left out
where the text names it, and a folded franchise tile says how many more
it stands for. The long press that opens a tile's menu is offered as an
action, labelled on Android and as More on iOS, where tapAndHold also
makes the whole tile one button. On iOS the app behind a full-screen
player is hidden from VoiceOver, which walked the library behind the
picture.
This commit is contained in:
nvrl committed 2026-10-13 16:51:11 +02:00
1 parent 01eee78317
commit bf3fa19c70
4 files changed
+26 -5

No files matched your search

@@ -73,6 +73,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -681,13 +684,14 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
Modifier Modifier
.width(220.dp) .width(220.dp)
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
.combinedClickable(onClick = onClick, onLongClick = onLongClick), .combinedClickable(onClick = onClick, onLongClick = onLongClick, onLongClickLabel = "More for this episode"),
) { ) {
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium)) { Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium)) {
// Named by the text under it; read as well, the name came twice.
RemoteArtwork( RemoteArtwork(
entry.episode.stillUrl ?: entry.title.backdropUrl, entry.episode.stillUrl ?: entry.title.backdropUrl,
entry.title.displayName, entry.title.displayName,
Modifier.fillMaxSize(), Modifier.fillMaxSize().clearAndSetSemantics {},
fallback = entry.episode.thumbnailSource, fallback = entry.episode.thumbnailSource,
) )
entry.episode.progress?.let { progress -> entry.episode.progress?.let { progress ->
@@ -738,7 +742,7 @@ private fun PosterTile(
Column( Column(
modifier modifier
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
.combinedClickable(onClick = onClick, onLongClick = onLongClick), .combinedClickable(onClick = onClick, onLongClick = onLongClick, onLongClickLabel = "More for this title"),
) { ) {
Box { Box {
RemoteArtwork( RemoteArtwork(
@@ -747,7 +751,8 @@ private fun PosterTile(
(if (shared) Modifier.sharedArt(title.key) else Modifier) (if (shared) Modifier.sharedArt(title.key) else Modifier)
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(2f / 3f) .aspectRatio(2f / 3f)
.clip(MaterialTheme.shapes.medium), .clip(MaterialTheme.shapes.medium)
.clearAndSetSemantics {},
fallback = title.thumbnailSource, fallback = title.thumbnailSource,
) )
if (folded > 0) { if (folded > 0) {
@@ -760,7 +765,8 @@ private fun PosterTile(
.padding(6.dp) .padding(6.dp)
.clip(MaterialTheme.shapes.small) .clip(MaterialTheme.shapes.small)
.background(Color.Black.copy(alpha = 0.7f)) .background(Color.Black.copy(alpha = 0.7f))
.padding(horizontal = 6.dp, vertical = 2.dp), .padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = "and $folded more" },
) )
} }
} }
@@ -107,6 +107,9 @@ struct RootView: View {
.animation(.easeInOut(duration: 0.2), value: model.activity) .animation(.easeInOut(duration: 0.2), value: model.activity)
} }
.background(scheme.background.ignoresSafeArea()) .background(scheme.background.ignoresSafeArea())
// Covered by the player, but still there to VoiceOver, which
// would otherwise walk the library behind the picture.
.accessibilityHidden(host.isOpen && !host.minimized)
} }
.overlay { .overlay {
// Over the shell rather than instead of it: the page behind stays // Over the shell rather than instead of it: the page behind stays
+4
View File
@@ -541,7 +541,9 @@ private struct ContinueCard: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Named by the text under it; read as well, the name came twice.
RemoteArtwork(entry.episode.stillUrl ?? entry.title.backdropUrl, entry.title.displayName, fallback: entry.episode.thumbnailSource) RemoteArtwork(entry.episode.stillUrl ?? entry.title.backdropUrl, entry.title.displayName, fallback: entry.episode.thumbnailSource)
.accessibilityHidden(true)
.aspectRatio(16 / 9, contentMode: .fit) .aspectRatio(16 / 9, contentMode: .fit)
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
if let progress = entry.episode.progress { AccentProgress(progress: progress) } if let progress = entry.episode.progress { AccentProgress(progress: progress) }
@@ -571,6 +573,7 @@ struct PosterTile: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
RemoteArtwork(title.posterUrl ?? title.backdropUrl, title.displayName, fallback: title.thumbnailSource) RemoteArtwork(title.posterUrl ?? title.backdropUrl, title.displayName, fallback: title.thumbnailSource)
.accessibilityHidden(true)
.aspectRatio(2 / 3, contentMode: .fit) .aspectRatio(2 / 3, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: Corner.standard)) .clipShape(RoundedRectangle(cornerRadius: Corner.standard))
.overlay(alignment: .topTrailing) { .overlay(alignment: .topTrailing) {
@@ -581,6 +584,7 @@ struct PosterTile: View {
.padding(.horizontal, 6).padding(.vertical, 2) .padding(.horizontal, 6).padding(.vertical, 2)
.background(.black.opacity(0.7), in: RoundedRectangle(cornerRadius: Corner.standard)) .background(.black.opacity(0.7), in: RoundedRectangle(cornerRadius: Corner.standard))
.padding(6) .padding(6)
.accessibilityLabel("and \(folded) more")
} }
} }
Text(title.displayName).textStyle(.bodyMedium, semibold: true).foregroundStyle(scheme.onSurface) Text(title.displayName).textStyle(.bodyMedium, semibold: true).foregroundStyle(scheme.onSurface)
+8
View File
@@ -115,6 +115,14 @@ private struct TapAndHold: ViewModifier {
.animation(.easeOut(duration: 0.1), value: pressed) .animation(.easeOut(duration: 0.1), value: pressed)
.onTapGesture(perform: onTap) .onTapGesture(perform: onTap)
.onLongPressGesture(perform: { onHold?() }, onPressingChanged: { pressed = $0 }) .onLongPressGesture(perform: { onHold?() }, onPressingChanged: { pressed = $0 })
// One element to VoiceOver, a button, with the long press as an
// action: a gesture is not something it can perform.
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.isButton)
.accessibilityAction(.default, onTap)
.accessibilityActions {
if let onHold { Button("More", action: onHold) }
}
} }
} }