From dc4a7dd45fd9895bf4952c95fdc93c1b0786d52b Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 1 Nov 2026 08:47:20 +0000 Subject: A transcript line says when it was said Reviewing the transcript screen on a device: a wall of paragraphs with no times in it. That undercuts the whole reason the screen exists -- finding the bit you half-heard at twelve minutes -- and leaves a reader no sign that a line can be tapped at all, since plain text suggests nothing. A fixed gutter so the times form a column instead of ragging in and out with the words beside them, wide enough for "1:02:03" so an hour-long episode does not shunt the text sideways. Quieter than the words, because it orients rather than being the thing read. Tapping it seeks, like tapping the words: the line is one target. formatClock rather than a fourth time formatter -- this app already had three. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B --- .../org/terst/listen/ui/TranscriptCueListTest.kt | 39 +++++++++++++ .../java/org/terst/listen/ui/TranscriptScreen.kt | 66 ++++++++++++++++------ 2 files changed, 89 insertions(+), 16 deletions(-) (limited to 'app/src') diff --git a/app/src/androidTest/java/org/terst/listen/ui/TranscriptCueListTest.kt b/app/src/androidTest/java/org/terst/listen/ui/TranscriptCueListTest.kt index ba1cbde..5c2c7b6 100644 --- a/app/src/androidTest/java/org/terst/listen/ui/TranscriptCueListTest.kt +++ b/app/src/androidTest/java/org/terst/listen/ui/TranscriptCueListTest.kt @@ -69,4 +69,43 @@ class TranscriptCueListTest { compose.onNodeWithText("Welcome back to the show.").assertDoesNotExist() } + + /* + * The time each line was said. + * + * The reason a transcript is worth scrolling: without it there is no way to find the bit + * you half-heard at twelve minutes, which is what people open a transcript for. It is also + * the only sign a line can be tapped -- plain paragraphs suggest nothing. + */ + + @Test + fun everyLineSaysWhenItWasSaid() { + compose.setContent { CueList(cues, currentIndex = null) {} } + + compose.onNodeWithText("0:00").assertIsDisplayed() + compose.onNodeWithText("0:03").assertIsDisplayed() + compose.onNodeWithText("0:20").assertIsDisplayed() + } + + /** An hour-long episode's times must not shunt the words sideways. */ + @Test + fun anHourLongEpisodeShowsHoursInTheTime() { + val late = listOf( + TranscriptCueEntity("e1", startMs = 3_723_000, endMs = 3_726_000, text = "Late on."), + ) + compose.setContent { CueList(late, currentIndex = null) {} } + + compose.onNodeWithText("1:02:03").assertIsDisplayed() + } + + /** Tapping the time seeks, the same as tapping the words: the whole line is the target. */ + @Test + fun tappingTheTimeSeeksToo() { + var sought: Long? = null + compose.setContent { CueList(cues, currentIndex = null) { sought = it } } + + compose.onNodeWithText("0:03").performClick() + + assertEquals(3500L, sought) + } } diff --git a/app/src/main/java/org/terst/listen/ui/TranscriptScreen.kt b/app/src/main/java/org/terst/listen/ui/TranscriptScreen.kt index 81c1b84..9a32a7e 100644 --- a/app/src/main/java/org/terst/listen/ui/TranscriptScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/TranscriptScreen.kt @@ -3,7 +3,9 @@ package org.terst.listen.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -103,6 +105,12 @@ fun TranscriptScreen( /** Advertising is dimmed, not hidden: a wrong call has to stay visible to be disagreed with. */ private const val ADVERTISING_ALPHA = 0.45f +/** Quieter than the words it points at -- it orients, it is not the thing being read. */ +private const val TIME_ALPHA = 0.7f + +/** Wide enough for "1:02:03", so an hour-long episode does not shunt the text sideways. */ +private val TIME_GUTTER = 64.dp + @Composable internal fun CueList( cues: List, @@ -118,25 +126,51 @@ internal fun CueList( LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { items(cues, key = { it.startMs }) { cue -> val speaking = currentIndex != null && cues[currentIndex].startMs == cue.startMs - Text( - cue.text, - style = MaterialTheme.typography.bodyMedium, - fontWeight = if (speaking) FontWeight.Bold else FontWeight.Normal, + TranscriptLine(cue = cue, speaking = speaking, onSeek = onSeek) + } + } +} + +/** + * One line, with the time it was said. + * + * The time is the whole reason a transcript is worth scrolling: without it there is no way to + * find the bit you half-heard at twelve minutes, which is the thing people actually open a + * transcript for. It doubles as the only sign that a line can be tapped at all -- plain + * paragraphs give a reader nothing to suggest they seek. + * + * A fixed gutter so the times form a column rather than ragging in and out with the text + * beside them. + */ +@Composable +private fun TranscriptLine(cue: TranscriptCueEntity, speaking: Boolean, onSeek: (Long) -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clickable { onSeek(cue.startMs) } + .padding(horizontal = 16.dp, vertical = 6.dp), + ) { + Text( + formatClock(cue.startMs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = TIME_ALPHA), + modifier = Modifier.width(TIME_GUTTER), + ) + Text( + cue.text, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (speaking) FontWeight.Bold else FontWeight.Normal, // Advertising is dimmed rather than hidden or struck through. The reader can // see what the server thinks is an advertisement and disagree with it, which // matters because nothing has ever checked this detector against anything -- // and hiding the lines would make a wrong call invisible. - color = when { - cue.advertising -> MaterialTheme.colorScheme.onSurfaceVariant - .copy(alpha = ADVERTISING_ALPHA) - speaking -> MaterialTheme.colorScheme.onSurface - else -> MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier - .fillMaxWidth() - .clickable { onSeek(cue.startMs) } - .padding(horizontal = 16.dp, vertical = 6.dp), - ) - } + color = when { + cue.advertising -> MaterialTheme.colorScheme.onSurfaceVariant + .copy(alpha = ADVERTISING_ALPHA) + speaking -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.weight(1f), + ) } } -- cgit v1.2.3