diff options
Diffstat (limited to 'app/src')
26 files changed, 1307 insertions, 27 deletions
diff --git a/app/src/androidTest/java/org/terst/listen/ui/TranscriptCueListTest.kt b/app/src/androidTest/java/org/terst/listen/ui/TranscriptCueListTest.kt new file mode 100644 index 0000000..ba1cbde --- /dev/null +++ b/app/src/androidTest/java/org/terst/listen/ui/TranscriptCueListTest.kt @@ -0,0 +1,72 @@ +package org.terst.listen.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.terst.listen.data.TranscriptCueEntity + +/** + * Reading along, and jumping to a line. + * + * Driven as a composable because the thing that goes wrong here is wiring: a line that seeks to + * a neighbour's timestamp looks identical to one that works, and the mistake only shows up as + * audio starting in the wrong place. + */ +@RunWith(AndroidJUnit4::class) +class TranscriptCueListTest { + + @get:Rule + val compose = createComposeRule() + + private val cues = listOf( + TranscriptCueEntity("e1", startMs = 0, endMs = 3000, text = "Welcome back to the show."), + TranscriptCueEntity("e1", startMs = 3500, endMs = 7000, text = "Our sponsor this week."), + TranscriptCueEntity("e1", startMs = 20_000, endMs = 24_000, text = "And we are back."), + ) + + @Test + fun everyLineIsShown() { + compose.setContent { CueList(cues, currentIndex = null) {} } + + compose.onNodeWithText("Welcome back to the show.").assertIsDisplayed() + compose.onNodeWithText("And we are back.").assertIsDisplayed() + } + + /** The point of a transcript: finding the bit you half-heard and going back to it. */ + @Test + fun tappingALineSeeksToIt() { + var sought: Long? = null + compose.setContent { CueList(cues, currentIndex = null) { sought = it } } + + compose.onNodeWithText("Our sponsor this week.").performClick() + + assertEquals(3500L, sought) + } + + /** + * It seeks to the line tapped, not to the one highlighted. Those differ whenever somebody + * scrolls away from the playhead to find something, which is most of the time. + */ + @Test + fun tappingSeeksToTheLineTappedRatherThanTheOnePlaying() { + var sought: Long? = null + compose.setContent { CueList(cues, currentIndex = 0) { sought = it } } + + compose.onNodeWithText("And we are back.").performClick() + + assertEquals(20_000L, sought) + } + + @Test + fun anEmptyTranscriptRendersNothingAndDoesNotFall() { + compose.setContent { CueList(emptyList(), currentIndex = null) {} } + + compose.onNodeWithText("Welcome back to the show.").assertDoesNotExist() + } +} diff --git a/app/src/main/java/org/terst/listen/data/AppDatabase.kt b/app/src/main/java/org/terst/listen/data/AppDatabase.kt index 636dd9f..96a25b9 100644 --- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt +++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt @@ -31,8 +31,10 @@ class Converters { EpisodeMidrollEntity::class, BreakVerdictEntity::class, AdReportEntity::class, + EpisodeTranscriptEntity::class, + TranscriptCueEntity::class, ], - version = 16, + version = 17, exportSchema = true, ) @TypeConverters(Converters::class) @@ -46,6 +48,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun midrolls(): MidrollDao abstract fun breakVerdicts(): BreakVerdictDao abstract fun adReports(): AdReportDao + abstract fun transcripts(): TranscriptDao companion object { @Volatile private var instance: AppDatabase? = null @@ -427,6 +430,45 @@ abstract class AppDatabase : RoomDatabase() { } } + /** + * An episode's words, and when each run of them was said. + * + * One row per cue rather than a JSON column on the episode. Android reads every Room + * row through a CursorWindow that refuses a row over 2MB *on the way out* -- the insert + * succeeds and the next read throws -- which is exactly how `pendingClipPcm` made intro + * detection crash the app the moment a show's second episode was fingerprinted. A long + * episode's transcript runs to thousands of cues, so a column holding all of them is + * the same mistake with a different name. + * + * Both tables cascade from the episode: a transcript of a row nobody has any more is + * dead weight, and this is the biggest table in the database by row count. + */ + val MIGRATION_16_17 = object : Migration(16, 17) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """CREATE TABLE IF NOT EXISTS `episode_transcripts` ( + `episodeId` TEXT NOT NULL, `source` TEXT NOT NULL, + `cues` INTEGER NOT NULL, `note` TEXT NOT NULL, + `fetchedAtMs` INTEGER NOT NULL, + PRIMARY KEY(`episodeId`), + FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`) + ON UPDATE NO ACTION ON DELETE CASCADE )""" + ) + db.execSQL( + """CREATE TABLE IF NOT EXISTS `transcript_cues` ( + `episodeId` TEXT NOT NULL, `startMs` INTEGER NOT NULL, + `endMs` INTEGER NOT NULL, `text` TEXT NOT NULL, + PRIMARY KEY(`episodeId`, `startMs`), + FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`) + ON UPDATE NO ACTION ON DELETE CASCADE )""" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_transcript_cues_episodeId` " + + "ON `transcript_cues` (`episodeId`)" + ) + } + } + fun get(context: Context): AppDatabase = instance ?: synchronized(this) { instance ?: build(context.applicationContext).also { instance = it } } @@ -439,7 +481,7 @@ abstract class AppDatabase : RoomDatabase() { MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, - MIGRATION_14_15, MIGRATION_15_16, + MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, ) .addCallback(SEED_DEFAULT_FILTERS) // No fallbackToDestructiveMigration. A listener's playback positions and play diff --git a/app/src/main/java/org/terst/listen/data/Daos.kt b/app/src/main/java/org/terst/listen/data/Daos.kt index 74c8ad5..43f713f 100644 --- a/app/src/main/java/org/terst/listen/data/Daos.kt +++ b/app/src/main/java/org/terst/listen/data/Daos.kt @@ -582,3 +582,45 @@ interface AdReportDao { @Query("DELETE FROM ad_reports WHERE episodeId = :episodeId AND startMs = :startMs") suspend fun delete(episodeId: String, startMs: Long) } + +/** + * An episode's words. + * + * The cues and the verdict are separate tables because they answer different questions at + * different moments: every episode screen asks whether there is a transcript, and only the one + * somebody opens asks for the thousand rows. + */ +@Dao +interface TranscriptDao { + + @Query("SELECT * FROM episode_transcripts WHERE episodeId = :episodeId") + fun observe(episodeId: String): Flow<EpisodeTranscriptEntity?> + + @Query("SELECT * FROM episode_transcripts WHERE episodeId = :episodeId") + suspend fun get(episodeId: String): EpisodeTranscriptEntity? + + @Query("SELECT * FROM transcript_cues WHERE episodeId = :episodeId ORDER BY startMs") + fun observeCues(episodeId: String): Flow<List<TranscriptCueEntity>> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(transcript: EpisodeTranscriptEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCues(cues: List<TranscriptCueEntity>) + + @Query("DELETE FROM transcript_cues WHERE episodeId = :episodeId") + suspend fun clearCues(episodeId: String) + + /** + * Replaces everything known about one episode's words, in one transaction. + * + * Not two calls from the caller: a cleared cue table with the new rows not yet written is a + * transcript that claims cues it cannot show, and the screen reading it would render empty. + */ + @Transaction + suspend fun replace(transcript: EpisodeTranscriptEntity, cues: List<TranscriptCueEntity>) { + clearCues(transcript.episodeId) + upsert(transcript) + if (cues.isNotEmpty()) insertCues(cues) + } +} diff --git a/app/src/main/java/org/terst/listen/data/Entities.kt b/app/src/main/java/org/terst/listen/data/Entities.kt index 6deaef3..2cfd15f 100644 --- a/app/src/main/java/org/terst/listen/data/Entities.kt +++ b/app/src/main/java/org/terst/listen/data/Entities.kt @@ -563,3 +563,70 @@ data class QueueEntity( @PrimaryKey val episodeId: String, val position: Int, ) + +/** + * What is known about one episode's transcript: where the words came from, how many there are, + * or why there are none. + * + * Three states, kept apart deliberately. A row with cues means the server supplied words; a row + * with a note and no cues means it looked and would not use what the publisher offered -- most + * often because this copy of the episode carries advertising the publisher's transcript does not + * describe; and no row at all means nobody has looked yet. Collapsing the first and third is the + * readout mistake this app has already made twice. + */ +@Entity( + tableName = "episode_transcripts", + primaryKeys = ["episodeId"], + foreignKeys = [ + ForeignKey( + entity = EpisodeEntity::class, + parentColumns = ["id"], + childColumns = ["episodeId"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +data class EpisodeTranscriptEntity( + val episodeId: String, + /** + * "published" for the publisher's own transcript, "asr" for speech recognition over the + * audio the server mirrored. Not decoration: the first is exact words and the second is a + * machine's guess at the same audio, and somebody reading along should know which. + */ + val source: String, + val cues: Int, + /** Why there are no words, or empty. */ + val note: String, + val fetchedAtMs: Long, +) { + val hasWords: Boolean get() = cues > 0 +} + +/** + * One run of speech and when it was said, in milliseconds into the episode. + * + * A row each rather than a column holding the lot. Android reads every Room row through a + * CursorWindow that refuses a row over 2MB on the way *out*, which is how the oversized + * `pendingClipPcm` crashed the app on every show's second fingerprinted episode -- the insert + * succeeded and the read threw. A three-hour episode is thousands of cues, so one column for all + * of them is that bug waiting to be rewritten. + */ +@Entity( + tableName = "transcript_cues", + primaryKeys = ["episodeId", "startMs"], + foreignKeys = [ + ForeignKey( + entity = EpisodeEntity::class, + parentColumns = ["id"], + childColumns = ["episodeId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index(value = ["episodeId"])], +) +data class TranscriptCueEntity( + val episodeId: String, + val startMs: Long, + val endMs: Long, + val text: String, +) diff --git a/app/src/main/java/org/terst/listen/data/Repositories.kt b/app/src/main/java/org/terst/listen/data/Repositories.kt index f56fdc6..1dc5bad 100644 --- a/app/src/main/java/org/terst/listen/data/Repositories.kt +++ b/app/src/main/java/org/terst/listen/data/Repositories.kt @@ -70,6 +70,7 @@ class AppGraph(private val context: Context) { val ruleDao: RuleDao get() = db.rules() val fingerprints: ShowFingerprintDao get() = db.fingerprints() val midrolls: MidrollDao get() = db.midrolls() + val transcripts: TranscriptDao get() = db.transcripts() val breakVerdicts: BreakVerdictDao get() = db.breakVerdicts() val adReports: AdReportDao get() = db.adReports() @@ -196,7 +197,7 @@ class AppGraph(private val context: Context) { /** Shared between the download path and [skipRefresher], which write the server's answer * into exactly the same columns. */ val serverSkips: ServerSkips by lazy { - ServerSkips(skipServer, subscriptions, fingerprints, playbackState, midrolls) + ServerSkips(skipServer, subscriptions, fingerprints, playbackState, midrolls, transcripts) } /** diff --git a/app/src/main/java/org/terst/listen/server/ServerSkips.kt b/app/src/main/java/org/terst/listen/server/ServerSkips.kt index bb8d2e1..d7fb4f5 100644 --- a/app/src/main/java/org/terst/listen/server/ServerSkips.kt +++ b/app/src/main/java/org/terst/listen/server/ServerSkips.kt @@ -6,6 +6,9 @@ import org.terst.listen.data.MidrollDao import org.terst.listen.data.PlaybackStateDao import org.terst.listen.data.ShowFingerprintDao import org.terst.listen.data.SubscriptionDao +import org.terst.listen.data.TranscriptCueEntity +import org.terst.listen.data.TranscriptDao +import org.terst.listen.data.EpisodeTranscriptEntity import org.terst.listen.download.ServerAudio /** @@ -21,6 +24,7 @@ class ServerSkips( private val fingerprints: ShowFingerprintDao, private val playbackState: PlaybackStateDao, private val midrolls: MidrollDao, + private val transcripts: TranscriptDao, ) : ServerAudio { override suspend fun canAsk(episode: EpisodeEntity): Boolean = @@ -60,6 +64,58 @@ class ServerSkips( info.durationMs?.let { playbackState.updateDuration(episode.id, it) } recordBreaks(episode, info) + recordTranscript(episode, info) + } + + /** + * Stores the episode's words, fetched now rather than when somebody opens them. + * + * The point of downloading an episode is that it works with no network. A transcript + * fetched on opening would be blank on a plane or in a car park, which is where this app is + * actually used -- and it is tens of kilobytes beside tens of megabytes of audio. + * + * Three outcomes, kept apart. Words stored; a refusal recorded, which means the server + * looked at what the publisher offered and would not use it for this copy; and nothing at + * all, which means nobody has looked. A download that fails writes nothing rather than a + * refusal: the next refresh should try again, and recording it as refused would stop that + * for ever. + */ + private suspend fun recordTranscript(episode: EpisodeEntity, info: ServerEpisode) { + val note = info.transcript ?: return + if (!note.hasWords || note.url == null) { + if (note.note != null) { + transcripts.replace( + EpisodeTranscriptEntity( + episodeId = episode.id, + source = note.source, + cues = 0, + note = note.note, + fetchedAtMs = System.currentTimeMillis(), + ), + emptyList(), + ) + } + return + } + + val fetched = server.transcript(note.url) ?: return + transcripts.replace( + EpisodeTranscriptEntity( + episodeId = episode.id, + source = fetched.source.ifBlank { note.source }, + cues = fetched.cues.size, + note = "", + fetchedAtMs = System.currentTimeMillis(), + ), + fetched.cues.map { + TranscriptCueEntity( + episodeId = episode.id, + startMs = it.startMs, + endMs = it.endMs, + text = it.text, + ) + }, + ) } /** diff --git a/app/src/main/java/org/terst/listen/server/SkipServer.kt b/app/src/main/java/org/terst/listen/server/SkipServer.kt index 5237461..0980aff 100644 --- a/app/src/main/java/org/terst/listen/server/SkipServer.kt +++ b/app/src/main/java/org/terst/listen/server/SkipServer.kt @@ -64,6 +64,47 @@ data class ServerBreak( val partners: Int = 0, ) +/** + * Whether an episode has words, whose they are, and where to get them. + * + * The source is not decoration. A published transcript is the publisher's exact words, and the + * server uses one only where the publisher's master is the copy that was mirrored -- for a show + * inserting advertising it is minutes out of step with the audio being played. Recognition is a + * machine's guess at exactly the bytes the phone has. Somebody reading along should know which + * one they are reading. + */ +data class ServerTranscriptNote( + /** "published" or "asr", or empty when the server did not say. */ + val source: String = "", + val cues: Int = 0, + /** + * Why there are no words. Present when the server looked at what the publisher offered and + * would not use it, which is a different state from nobody having looked -- in that case + * there is no note at all. + */ + val note: String? = null, + /** Absolute and token-carrying, usable as given. Null when there is nothing to fetch. */ + val url: String? = null, +) { + val hasWords: Boolean get() = cues > 0 +} + +/** One run of speech and when it was said, in milliseconds into the episode. */ +@Serializable +data class ServerCue( + @SerialName("startMs") val startMs: Long = 0, + @SerialName("endMs") val endMs: Long = 0, + val text: String = "", +) + +/** An episode's words as the server holds them. */ +@Serializable +data class ServerTranscript( + val source: String = "", + @SerialName("languageCode") val languageCode: String? = null, + val cues: List<ServerCue> = emptyList(), +) + /** What the server knows about one episode. */ data class ServerEpisode( /** Whether the audio can be played from the server now. */ @@ -106,6 +147,12 @@ data class ServerEpisode( * what made the app's skip readout useless. */ val adsRefused: Boolean = false, + /** + * Whether this episode has a transcript, and where. Null when nobody has looked yet, which + * is distinct from having looked and refused -- the same three-state distinction the breaks + * readout needed. + */ + val transcript: ServerTranscriptNote? = null, ) /** @@ -243,10 +290,42 @@ class SkipServer( foundAdMs = parsed.foundAdMs, insertedAdMs = parsed.insertedAdMs, adsRefused = parsed.adsRefused, + transcript = parsed.transcript?.let { + ServerTranscriptNote( + source = it.source, + cues = it.cues, + note = it.note?.takeIf(String::isNotBlank), + url = it.url?.takeIf(String::isNotBlank)?.let { path -> absolute(base(target), path) }, + ) + }, )) } /** + * Fetches one episode's words from the URL its episode response gave. + * + * Separate from [ask] because the two are wanted at different moments: every episode lookup + * needs to know whether there is a transcript, and only an episode somebody opens needs the + * thousand cues themselves. + * + * Null for anything that did not work -- not configured, unreachable, or a body that did + * not parse. A transcript is an enrichment; there is nothing for a caller to do differently + * about the reason it is missing. + */ + suspend fun transcript(url: String): ServerTranscript? { + val target = config() ?: return null + val body = observe(target, transport.get(url, target.token)) ?: return null + val parsed = runCatching { lenient.decodeFromString<ServerTranscript>(body) }.getOrNull() + ?: return null + return parsed.copy( + // A cue that says nothing is nothing to show, and one running backwards is nothing + // to seek to. Same filtering the breaks get, because the player acts on these + // numbers too -- a tap on a cue seeks to its start. + cues = parsed.cues.filter { it.endMs > it.startMs && it.text.isNotBlank() }, + ) + } + + /** * The body of a successful reply, or null; and the one place a reply updates [rejected]. * * Only a refusal sets it and only a success clears it. No answer says nothing about the @@ -282,6 +361,15 @@ class SkipServer( @SerialName("foundAdMs") val foundAdMs: Long? = null, @SerialName("insertedAdMs") val insertedAdMs: Long? = null, @SerialName("adsRefused") val adsRefused: Boolean = false, + val transcript: TranscriptResponse? = null, + ) + + @Serializable + private data class TranscriptResponse( + val source: String = "", + val cues: Int = 0, + val note: String? = null, + val url: String? = null, ) diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt b/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt index 18385d1..48ab23f 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt @@ -30,4 +30,6 @@ data class EpisodeDetailActions( /** Stops playback after this many minutes, fading over the last twenty seconds. */ val armSleepTimer: (minutes: Long) -> Unit = {}, val cancelSleepTimer: () -> Unit = {}, + /** Opens the episode's words, or the reason there are none. */ + val openTranscript: () -> Unit = {}, ) diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt b/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt index 3b3b949..93294ba 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt @@ -1,6 +1,7 @@ package org.terst.listen.ui import org.terst.listen.data.EpisodeEntity +import org.terst.listen.data.EpisodeTranscriptEntity import org.terst.listen.download.WaitingForServerText import org.terst.listen.feed.Chapter @@ -13,10 +14,13 @@ data class OverflowContent( val positionMs: Long, /** Milliseconds left on the sleep timer, or null when none is running. */ val sleepRemainingMs: Long? = null, + /** What is known about this episode's words, or null when nobody has looked. */ + val transcript: EpisodeTranscriptEntity? = null, ) enum class EpisodeMenuAction { - QUEUE, DOWNLOAD, PUBLISHER_COPY, TOGGLE_PLAYED, WATCH, MARK_OLDER_PLAYED, CHAPTERS, SLEEP + QUEUE, DOWNLOAD, PUBLISHER_COPY, TOGGLE_PLAYED, WATCH, MARK_OLDER_PLAYED, CHAPTERS, + TRANSCRIPT, SLEEP } data class EpisodeMenuItem(val action: EpisodeMenuAction, val label: String) @@ -42,6 +46,7 @@ object EpisodeMenu { episode: EpisodeEntity, watchLink: String?, sleepRemainingMs: Long? = null, + transcript: EpisodeTranscriptEntity? = null, ): List<EpisodeMenuItem> = buildList { add(EpisodeMenuItem(EpisodeMenuAction.QUEUE, "Queue")) add(EpisodeMenuItem(EpisodeMenuAction.DOWNLOAD, "Download")) @@ -57,6 +62,11 @@ object EpisodeMenu { if (watchLink != null) add(EpisodeMenuItem(EpisodeMenuAction.WATCH, "Watch")) add(EpisodeMenuItem(EpisodeMenuAction.MARK_OLDER_PLAYED, "Mark this and older as played")) if (episode.chaptersUrl != null) add(EpisodeMenuItem(EpisodeMenuAction.CHAPTERS, "Chapters")) + // Offered whenever the server has looked, including when it looked and would not use + // what the publisher offered -- the screen gives a different answer in each case. + // Hiding the refusal would make "no transcript" and "a transcript that does not + // describe this copy" look identical, which is the distinction this app keeps losing. + if (transcript != null) add(EpisodeMenuItem(EpisodeMenuAction.TRANSCRIPT, "Transcript")) // Not a setting. It is something you do once, while listening, usually in the dark, so it // belongs with the other secondary playback actions rather than three screens away. add( diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt b/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt index 6380b51..0622bb2 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt @@ -52,7 +52,12 @@ fun EpisodeOverflow(content: OverflowContent, actions: EpisodeDetailActions) { Icon(Icons.Default.MoreVert, contentDescription = "More actions") } DropdownMenu(expanded = open, onDismissRequest = { open = false }) { - EpisodeMenu.items(content.episode, content.watchLink, content.sleepRemainingMs).forEach { item -> + EpisodeMenu.items( + content.episode, + content.watchLink, + content.sleepRemainingMs, + content.transcript, + ).forEach { item -> DropdownMenuItem( text = { Text(item.label) }, onClick = { @@ -89,6 +94,7 @@ private fun perform(action: EpisodeMenuAction, actions: EpisodeDetailActions, wa EpisodeMenuAction.WATCH -> watchLink?.let { runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, it.toUri())) } } + EpisodeMenuAction.TRANSCRIPT -> actions.openTranscript() EpisodeMenuAction.CHAPTERS, EpisodeMenuAction.PUBLISHER_COPY, EpisodeMenuAction.SLEEP -> Unit } } diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt index cb36d64..fec8d4e 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -95,6 +96,7 @@ fun EpisodeScreen( onSpeed = { speed = it }, onEpisode = { episode = it }, reload = { scope.launch { episode = vm.episode(shownId) } }, + openTranscript = { nav.openTranscript(shownId) }, ) Scaffold( topBar = { @@ -140,6 +142,7 @@ private class EpisodeHooks( val onSpeed: (Float) -> Unit, val onEpisode: (EpisodeEntity) -> Unit, val reload: () -> Unit, + val openTranscript: () -> Unit, ) /** @@ -189,6 +192,7 @@ private fun episodeActions( } }, reportAd = player::reportAd, + openTranscript = hooks.openTranscript, armSleepTimer = player::armSleepTimer, cancelSleepTimer = player::cancelSleepTimer, setSpeed = { chosen -> @@ -356,8 +360,15 @@ private fun Overflow( ) { val positionMs = if (nowPlaying.episodeId == shown.id) nowPlaying.positionMs else shown.positionMs val chapters: suspend () -> List<Chapter> = { EpisodeOpening.chapters(shown, source) } + // Collected here rather than passed in: the menu is the only thing that needs it, and this + // is the only state the overflow has of its own. + val transcript by viewModel<TranscriptViewModel>().reads(shown.id).note + .collectAsState(initial = null) EpisodeOverflow( - OverflowContent(shown, chapters, WatchLink.at(shown.mediaUrl, positionMs), positionMs, sleepRemainingMs), + OverflowContent( + shown, chapters, WatchLink.at(shown.mediaUrl, positionMs), positionMs, + sleepRemainingMs, transcript, + ), episodeActions(source.vm, source.player, shown, nowPlaying, hooks), ) } diff --git a/app/src/main/java/org/terst/listen/ui/MainActivity.kt b/app/src/main/java/org/terst/listen/ui/MainActivity.kt index b2879cb..63aead7 100644 --- a/app/src/main/java/org/terst/listen/ui/MainActivity.kt +++ b/app/src/main/java/org/terst/listen/ui/MainActivity.kt @@ -134,6 +134,7 @@ private fun ListenApp( openEpisode = { nav.navigate("episode/$it") }, openQueue = { nav.navigate("queue") }, back = { nav.popBackStack() }, + openTranscript = { nav.navigate("transcript/$it") }, ), ) } @@ -169,6 +170,14 @@ private fun androidx.navigation.NavGraphBuilder.episodeRoute( viewModel: ListenViewModel, player: PlayerViewModel, ) { + composable("transcript/{id}") { entry -> + val id = entry.arguments?.getString("id") + if (id == null) { + nav.popBackStack() + } else { + TranscriptScreen(viewModel, player, id) { nav.popBackStack() } + } + } composable("episode/{id}") { entry -> val id = entry.arguments?.getString("id") if (id == null) { @@ -182,6 +191,7 @@ private fun androidx.navigation.NavGraphBuilder.episodeRoute( openEpisode = { nav.navigate("episode/$it") }, openQueue = { nav.navigate("queue") }, back = { nav.popBackStack() }, + openTranscript = { nav.navigate("transcript/$it") }, ), ) } diff --git a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt index 7712bf1..3d80fea 100644 --- a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt @@ -265,6 +265,8 @@ data class SubscriptionNav( val openEpisode: (String) -> Unit, val openQueue: () -> Unit, val back: () -> Unit, + /** Opens an episode's words, or the recorded reason it has none. */ + val openTranscript: (String) -> Unit, ) /** The top bar's four callbacks, bundled so the signature stays readable. */ diff --git a/app/src/main/java/org/terst/listen/ui/TranscriptReads.kt b/app/src/main/java/org/terst/listen/ui/TranscriptReads.kt new file mode 100644 index 0000000..545c4ae --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/TranscriptReads.kt @@ -0,0 +1,18 @@ +package org.terst.listen.ui + +import kotlinx.coroutines.flow.Flow +import org.terst.listen.data.EpisodeTranscriptEntity +import org.terst.listen.data.TranscriptCueEntity + +/** + * What a screen reads about an episode's words. + * + * Two flows rather than one: every episode screen wants to know whether there is a transcript, + * and only the screen somebody opens wants the thousand cues. Handed over together so there is + * one named seam for both rather than two functions on an already-crowded view model. + */ +data class TranscriptReads( + /** The verdict: whose words, how many, or why there are none. Null when nobody has looked. */ + val note: Flow<EpisodeTranscriptEntity?>, + val cues: Flow<List<TranscriptCueEntity>>, +) diff --git a/app/src/main/java/org/terst/listen/ui/TranscriptScreen.kt b/app/src/main/java/org/terst/listen/ui/TranscriptScreen.kt new file mode 100644 index 0000000..c6d6e52 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/TranscriptScreen.kt @@ -0,0 +1,134 @@ +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.terst.listen.data.DownloadState +import org.terst.listen.data.EpisodeEntity +import org.terst.listen.data.TranscriptCueEntity + +/** + * An episode's words, following the playhead. + * + * A screen rather than a dialog, which is where it differs from chapters: a chapter list is + * eight rows you pick from and close, and this is a thousand you read while listening. + * + * Tapping a line seeks to it. That is the feature people actually want from a transcript -- + * finding the bit you half-heard -- and it is the reason the cue times have to be right for + * *this copy* of the episode rather than for the publisher's master. See transcript.Fits on the + * server for why that is not automatic. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TranscriptScreen( + vm: ListenViewModel, + player: PlayerViewModel, + episodeId: String, + onBack: () -> Unit, +) { + val reads = viewModel<TranscriptViewModel>().reads(episodeId) + val transcript by reads.note.collectAsState(initial = null) + val cues by reads.cues.collectAsState(initial = emptyList()) + val nowPlaying by player.nowPlaying.collectAsState() + var episode by remember(episodeId) { mutableStateOf<EpisodeEntity?>(null) } + LaunchedEffect(episodeId) { episode = vm.episode(episodeId) } + + // The position only means something for the episode that is playing. Open the transcript of + // a different episode and nothing is highlighted, which is the truth. + val positionMs = if (nowPlaying.episodeId == episodeId) nowPlaying.positionMs else null + val current = positionMs?.let { TranscriptSyncing.indexAt(cues, it) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Transcript", maxLines = 1) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Column(Modifier.padding(padding).fillMaxSize()) { + transcript?.takeIf { it.hasWords }?.let { + Text( + TranscriptSyncing.sourceLabel(it.source, it.cues), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp), + ) + } + if (cues.isEmpty()) { + Text( + TranscriptSyncing.emptyMessage( + transcript?.note, + downloaded = episode?.downloadState == DownloadState.DOWNLOADED, + ), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(24.dp), + ) + } else { + CueList(cues, current) { player.seekTo(it) } + } + } + } +} + +@Composable +internal fun CueList( + cues: List<TranscriptCueEntity>, + currentIndex: Int?, + onSeek: (Long) -> Unit, +) { + val listState = rememberLazyListState() + // Follows the playhead, and only when it moves to a different line -- scrolling on every + // position tick would fight a reader who has scrolled away to find something. + LaunchedEffect(currentIndex) { + currentIndex?.let { listState.animateScrollToItem(it) } + } + 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, + color = if (speaking) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier + .fillMaxWidth() + .clickable { onSeek(cue.startMs) } + .padding(horizontal = 16.dp, vertical = 6.dp), + ) + } + } +} diff --git a/app/src/main/java/org/terst/listen/ui/TranscriptSyncing.kt b/app/src/main/java/org/terst/listen/ui/TranscriptSyncing.kt new file mode 100644 index 0000000..db93a29 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/TranscriptSyncing.kt @@ -0,0 +1,65 @@ +package org.terst.listen.ui + +import org.terst.listen.data.TranscriptCueEntity + +/** + * Which line of a transcript is being spoken, and what the screen says when there are none. + * + * Pure, and separate from the screen, because this is the part that can be wrong in a way + * nobody notices: a transcript that highlights the line before or after the one being said + * looks right at a glance and is useless to read along with. + */ +object TranscriptSyncing { + + /** + * The index of the cue covering [positionMs], or the last one before it. + * + * Not simply "the cue whose range contains the position". Transcripts have gaps -- silence, + * music, a pause between speakers -- and during one of those the honest answer is the line + * last spoken, not nothing. A reader following along wants the highlight to stay where the + * words stopped rather than flick off and back. + * + * Before the first cue there is no answer, which is different from the first cue: an + * episode opening with thirty seconds of theme music has not said its first line yet. + */ + fun indexAt(cues: List<TranscriptCueEntity>, positionMs: Long): Int? { + if (cues.isEmpty() || positionMs < cues.first().startMs) return null + // Binary search: a three-hour episode runs to thousands of cues and this is asked on + // every position tick. + var low = 0 + var high = cues.lastIndex + var found = 0 + while (low <= high) { + val mid = (low + high) / 2 + if (cues[mid].startMs <= positionMs) { + found = mid + low = mid + 1 + } else { + high = mid - 1 + } + } + return found + } + + /** + * What to tell the reader when there is no transcript to show. + * + * Three states, and they are not the same sentence. Refused means the server looked at the + * publisher's own transcript and would not use it for this copy of the episode -- which is + * worth saying, because the words exist and are simply describing different audio. Nobody + * having looked is a fourth of nothing. Collapsing these into "no transcript" is the readout + * mistake this app has made twice. + */ + fun emptyMessage(note: String?, downloaded: Boolean): String = when { + !note.isNullOrBlank() -> "No transcript for this copy: $note." + downloaded -> "No transcript yet. The server supplies them with the audio." + else -> "Download this episode and its transcript comes with it." + } + + /** Names whose words these are, because an exact transcript and a guess read differently. */ + fun sourceLabel(source: String, cues: Int): String = when (source) { + "published" -> "$cues lines, from the publisher" + "asr" -> "$cues lines, recognised from the audio" + else -> "$cues lines" + } +} diff --git a/app/src/main/java/org/terst/listen/ui/TranscriptViewModel.kt b/app/src/main/java/org/terst/listen/ui/TranscriptViewModel.kt new file mode 100644 index 0000000..b1479ef --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/TranscriptViewModel.kt @@ -0,0 +1,30 @@ +package org.terst.listen.ui + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import org.terst.listen.data.Graph + +/** + * An episode's words. + * + * Its own view model rather than two more functions on [ListenViewModel], which is the library's + * and is already at the size the linter allows. Transcripts are also a different lifetime: the + * library list is on screen the whole time the app is, and a thousand cues are wanted only while + * somebody is reading them. + */ +class TranscriptViewModel(application: Application) : AndroidViewModel(application) { + + private val graph = Graph.get(application) + + /** + * The two reads an episode's words need. + * + * Flows rather than one-off reads because the words arrive with the download rather than + * when a screen opens -- an episode downloaded while its screen is up should grow a + * transcript without being navigated away from and back to. + */ + fun reads(episodeId: String) = TranscriptReads( + note = graph.transcripts.observe(episodeId), + cues = graph.transcripts.observeCues(episodeId), + ) +} diff --git a/app/src/test/java/org/terst/listen/data/FakeDaos.kt b/app/src/test/java/org/terst/listen/data/FakeDaos.kt index 54ef70c..ec76368 100644 --- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt +++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt @@ -444,3 +444,37 @@ class FakeAdReportDao : AdReportDao { rows.removeAll { it.episodeId == episodeId && it.startMs == startMs } } } + +/** In-memory transcripts: the cue rows and the one-row verdict beside them. */ +class FakeTranscriptDao : TranscriptDao { + val rows = mutableListOf<EpisodeTranscriptEntity>() + val cueRows = mutableListOf<TranscriptCueEntity>() + + override fun observe(episodeId: String): Flow<EpisodeTranscriptEntity?> = + flowOf(rows.firstOrNull { it.episodeId == episodeId }) + + override suspend fun get(episodeId: String) = rows.firstOrNull { it.episodeId == episodeId } + + /** Test-side read of what was stored. The DAO itself observes; nothing in the app polls. */ + fun cues(episodeId: String) = + cueRows.filter { it.episodeId == episodeId }.sortedBy { it.startMs } + + override fun observeCues(episodeId: String): Flow<List<TranscriptCueEntity>> = + flowOf(cueRows.filter { it.episodeId == episodeId }.sortedBy { it.startMs }) + + override suspend fun upsert(transcript: EpisodeTranscriptEntity) { + rows.removeAll { it.episodeId == transcript.episodeId } + rows += transcript + } + + override suspend fun insertCues(cues: List<TranscriptCueEntity>) { + for (cue in cues) { + cueRows.removeAll { it.episodeId == cue.episodeId && it.startMs == cue.startMs } + cueRows += cue + } + } + + override suspend fun clearCues(episodeId: String) { + cueRows.removeAll { it.episodeId == episodeId } + } +} diff --git a/app/src/test/java/org/terst/listen/data/TranscriptReplaceTest.kt b/app/src/test/java/org/terst/listen/data/TranscriptReplaceTest.kt new file mode 100644 index 0000000..c3e7f6a --- /dev/null +++ b/app/src/test/java/org/terst/listen/data/TranscriptReplaceTest.kt @@ -0,0 +1,79 @@ +package org.terst.listen.data + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Replacing an episode's words. + * + * One transaction rather than two calls from the caller. A cleared cue table with the new rows + * not yet written is a transcript that claims cues it cannot show, and the screen reading it + * mid-write would render an empty list under a header saying how many lines there are. + * + * It replaces rather than merges because the server is the authority and re-measures: an + * episode re-fetched with different advertising has different timings, and accumulated cues + * would leave the reader with two transcripts interleaved. + */ +class TranscriptReplaceTest { + + private fun cue(startMs: Long, text: String) = + TranscriptCueEntity(episodeId = "e1", startMs = startMs, endMs = startMs + 1000, text = text) + + private fun note(cues: Int, note: String = "") = EpisodeTranscriptEntity( + episodeId = "e1", source = "published", cues = cues, note = note, fetchedAtMs = 1, + ) + + @Test + fun `stores the verdict and the cues together`() = runTest { + val dao = FakeTranscriptDao() + + dao.replace(note(2), listOf(cue(0, "One."), cue(2000, "Two."))) + + assertEquals(2, dao.cues("e1").size) + assertEquals("One.", dao.cues("e1").first().text) + assertEquals(2, dao.get("e1")?.cues) + } + + @Test + fun `a second transcript replaces the first rather than joining it`() = runTest { + val dao = FakeTranscriptDao() + dao.replace(note(2), listOf(cue(0, "Old one."), cue(2000, "Old two."))) + + dao.replace(note(1), listOf(cue(500, "New."))) + + assertEquals(listOf("New."), dao.cues("e1").map { it.text }) + assertEquals(1, dao.get("e1")?.cues) + } + + /** A refusal has a reason and no words, and must clear any words stored earlier. */ + @Test + fun `a refusal clears the words it replaces`() = runTest { + val dao = FakeTranscriptDao() + dao.replace(note(1), listOf(cue(0, "Words."))) + + dao.replace(note(0, note = "this copy carries 4:16 of inserted advertising"), emptyList()) + + assertTrue(dao.cues("e1").isEmpty()) + val stored = dao.get("e1") + assertEquals(0, stored?.cues) + assertTrue(stored?.note?.contains("advertising") == true) + assertEquals(false, stored?.hasWords) + } + + /** One episode's words are not another's. */ + @Test + fun `replacing one episode leaves another alone`() = runTest { + val dao = FakeTranscriptDao() + dao.replace(note(1), listOf(cue(0, "Mine."))) + dao.replace( + EpisodeTranscriptEntity("e2", source = "asr", cues = 1, note = "", fetchedAtMs = 1), + listOf(TranscriptCueEntity("e2", startMs = 0, endMs = 1000, text = "Theirs.")), + ) + + dao.replace(note(1), listOf(cue(0, "Mine again."))) + + assertEquals(listOf("Theirs."), dao.cues("e2").map { it.text }) + } +} diff --git a/app/src/test/java/org/terst/listen/download/WaitForServerWiringTest.kt b/app/src/test/java/org/terst/listen/download/WaitForServerWiringTest.kt index 74c04da..67832e3 100644 --- a/app/src/test/java/org/terst/listen/download/WaitForServerWiringTest.kt +++ b/app/src/test/java/org/terst/listen/download/WaitForServerWiringTest.kt @@ -20,6 +20,7 @@ import org.terst.listen.data.FakeShowFingerprintDao import org.terst.listen.data.FakeSubscriptionDao import org.terst.listen.data.SubscriptionEntity import org.terst.listen.data.SubscriptionKind +import org.terst.listen.data.FakeTranscriptDao import org.terst.listen.server.ServerSkips import org.terst.listen.server.SkipServer import org.terst.listen.server.SkipServerConfig @@ -77,7 +78,9 @@ class WaitForServerWiringTest { transport, ) val fingerprints = FakeShowFingerprintDao().apply { episodeRows = dao.rows } - val serverSkips = ServerSkips(skipServer, subscriptions, fingerprints, FakePlaybackStateDao(dao.rows), FakeMidrollDao()) + val serverSkips = ServerSkips(skipServer, subscriptions, fingerprints, FakePlaybackStateDao(dao.rows), FakeMidrollDao(), + FakeTranscriptDao(), + ) return EpisodeDownloader(OkHttpClient(), dao, temp.newFolder(), null, serverSkips) { 1L } } @@ -143,7 +146,9 @@ class WaitForServerWiringTest { org.terst.listen.server.OkHttpSkipTransport(OkHttpClient()), ) val fingerprints = FakeShowFingerprintDao().apply { episodeRows = dao.rows } - val serverSkips = ServerSkips(skipServer, subscriptions, fingerprints, FakePlaybackStateDao(dao.rows), FakeMidrollDao()) + val serverSkips = ServerSkips(skipServer, subscriptions, fingerprints, FakePlaybackStateDao(dao.rows), FakeMidrollDao(), + FakeTranscriptDao(), + ) return skip to EpisodeDownloader(OkHttpClient(), dao, temp.newFolder(), null, serverSkips) { 1L } } @@ -186,7 +191,9 @@ class WaitForServerWiringTest { org.terst.listen.server.OkHttpSkipTransport(OkHttpClient()), ) val fingerprints = FakeShowFingerprintDao().apply { episodeRows = dao.rows } - val serverSkips = ServerSkips(skipServer, subscriptions, fingerprints, FakePlaybackStateDao(dao.rows), FakeMidrollDao()) + val serverSkips = ServerSkips(skipServer, subscriptions, fingerprints, FakePlaybackStateDao(dao.rows), FakeMidrollDao(), + FakeTranscriptDao(), + ) val result = EpisodeDownloader(OkHttpClient(), dao, temp.newFolder(), null, serverSkips) { 1L } .download(episode(), SubscriptionKind.PODCAST) diff --git a/app/src/test/java/org/terst/listen/playback/BreakVerdictsTest.kt b/app/src/test/java/org/terst/listen/playback/BreakVerdictsTest.kt index 8fd964b..bf7f7ec 100644 --- a/app/src/test/java/org/terst/listen/playback/BreakVerdictsTest.kt +++ b/app/src/test/java/org/terst/listen/playback/BreakVerdictsTest.kt @@ -185,6 +185,7 @@ class BreakVerdictsTest { org.terst.listen.data.FakeShowFingerprintDao(), org.terst.listen.data.FakePlaybackStateDao(mutableListOf()), midrolls, + org.terst.listen.data.FakeTranscriptDao(), ) val episode = org.terst.listen.data.EpisodeEntity( id = "e1", subscriptionId = "s1", guid = "g", title = "t", mediaUrl = "u", diff --git a/app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt b/app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt index aa651af..ae6a23b 100644 --- a/app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt +++ b/app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt @@ -11,6 +11,7 @@ import org.terst.listen.data.FakeMidrollDao import org.terst.listen.data.FakePlaybackStateDao import org.terst.listen.data.FakeShowFingerprintDao import org.terst.listen.data.FakeSubscriptionDao +import org.terst.listen.data.FakeTranscriptDao import org.terst.listen.data.SubscriptionEntity /** @@ -22,10 +23,17 @@ import org.terst.listen.data.SubscriptionEntity */ class ServerSkipsTest { - private class StubTransport(private val body: String?) : SkipTransport { + private class StubTransport( + private val body: String?, + /** Answers for URLs other than the episode lookup, keyed by a substring of the URL. */ + private val alsoServing: Map<String, String> = emptyMap(), + ) : SkipTransport { val gets = mutableListOf<String>() override suspend fun get(url: String, token: String): SkipReply { gets += url + alsoServing.entries.firstOrNull { url.contains(it.key) }?.let { + return SkipReply.Body(it.value) + } return body?.let { SkipReply.Body(it) } ?: SkipReply.NoAnswer } @@ -49,13 +57,29 @@ class ServerSkipsTest { discoveredAtMs = 0, ) + /** + * The fakes a subject is built from, bundled so the builder stays within the linter. + * + * The defaults are seeded here rather than patched afterwards: a test that deliberately + * passes an empty DAO -- an unsubscribed show -- means it. + */ + private inner class Fakes( + val subscriptions: FakeSubscriptionDao = FakeSubscriptionDao().apply { rows += show }, + val episodes: FakeEpisodeDao = FakeEpisodeDao().apply { rows += episode }, + val fingerprints: FakeShowFingerprintDao = FakeShowFingerprintDao(), + val midrolls: FakeMidrollDao = FakeMidrollDao(), + val transcripts: FakeTranscriptDao = FakeTranscriptDao(), + ) + private fun subject( transport: SkipTransport, - subscriptions: FakeSubscriptionDao = FakeSubscriptionDao().apply { rows += show }, - episodes: FakeEpisodeDao = FakeEpisodeDao().apply { rows += episode }, - fingerprints: FakeShowFingerprintDao = FakeShowFingerprintDao(), - midrolls: FakeMidrollDao = FakeMidrollDao(), + fakes: Fakes = Fakes(), ): ServerSkips { + val subscriptions = fakes.subscriptions + val episodes = fakes.episodes + val fingerprints = fakes.fingerprints + val midrolls = fakes.midrolls + val transcripts = fakes.transcripts // Both fakes write through to the same episode rows, the way the real foreign table // does, so a test sees the value where production would put it. fingerprints.episodeRows = episodes.rows @@ -65,6 +89,7 @@ class ServerSkipsTest { fingerprints, FakePlaybackStateDao(episodes.rows), midrolls, + transcripts, ) } @@ -85,7 +110,7 @@ class ServerSkipsTest { fun `does not ask when the show is gone`() = runTest { val transport = StubTransport("""{"status":"pending"}""") - val got = subject(transport, subscriptions = FakeSubscriptionDao()).resolve(episode) + val got = subject(transport, Fakes(subscriptions = FakeSubscriptionDao())).resolve(episode) assertEquals(ServerAnswer.Unavailable, got) assertTrue("asked about a show that is not subscribed", transport.gets.isEmpty()) @@ -95,7 +120,7 @@ class ServerSkipsTest { fun `records the detected offsets against the episode`() = runTest { val episodes = FakeEpisodeDao().apply { rows += episode } - subject(StubTransport(null), episodes = episodes).record( + subject(StubTransport(null), Fakes(episodes = episodes)).record( episode, ServerEpisode(ready = true, audioUrl = "x", durationMs = 1_698_717, introMs = 142_000, outroMs = 11_000, detected = true), @@ -114,7 +139,7 @@ class ServerSkipsTest { fun `records the measured duration`() = runTest { val episodes = FakeEpisodeDao().apply { rows += episode } - subject(StubTransport(null), episodes = episodes).record( + subject(StubTransport(null), Fakes(episodes = episodes)).record( episode, ServerEpisode(ready = true, audioUrl = "x", durationMs = 1_698_717, introMs = null, outroMs = null, detected = true), @@ -131,7 +156,7 @@ class ServerSkipsTest { fun `leaves an offset the server did not report alone`() = runTest { val episodes = FakeEpisodeDao().apply { rows += episode } - subject(StubTransport(null), episodes = episodes).record( + subject(StubTransport(null), Fakes(episodes = episodes)).record( episode, ServerEpisode(ready = true, audioUrl = "x", durationMs = null, introMs = 142_000, outroMs = null, detected = true), @@ -147,7 +172,7 @@ class ServerSkipsTest { fun `records a detected zero offset`() = runTest { val episodes = FakeEpisodeDao().apply { rows += episode } - subject(StubTransport(null), episodes = episodes).record( + subject(StubTransport(null), Fakes(episodes = episodes)).record( episode, ServerEpisode(ready = true, audioUrl = "x", durationMs = null, introMs = 0, outroMs = null, detected = true), @@ -167,7 +192,7 @@ class ServerSkipsTest { fun `records the ad breaks as skip ranges`() = runTest { val midrolls = FakeMidrollDao() - subject(StubTransport(null), midrolls = midrolls).record( + subject(StubTransport(null), Fakes(midrolls = midrolls)).record( episode, ServerEpisode( ready = true, audioUrl = "x", durationMs = null, introMs = null, outroMs = null, @@ -192,7 +217,7 @@ class ServerSkipsTest { fun `keeps how many other episodes carried each break`() = runTest { val midrolls = FakeMidrollDao() - subject(StubTransport(null), midrolls = midrolls).record( + subject(StubTransport(null), Fakes(midrolls = midrolls)).record( episode, ServerEpisode( ready = true, audioUrl = "x", durationMs = null, introMs = null, outroMs = null, @@ -214,7 +239,7 @@ class ServerSkipsTest { @Test fun `replaces the previous breaks rather than adding to them`() = runTest { val midrolls = FakeMidrollDao() - val skips = subject(StubTransport(null), midrolls = midrolls) + val skips = subject(StubTransport(null), Fakes(midrolls = midrolls)) skips.record( episode, @@ -236,7 +261,7 @@ class ServerSkipsTest { @Test fun `clears the breaks when the server published none`() = runTest { val midrolls = FakeMidrollDao() - val skips = subject(StubTransport(null), midrolls = midrolls) + val skips = subject(StubTransport(null), Fakes(midrolls = midrolls)) skips.record( episode, @@ -260,7 +285,7 @@ class ServerSkipsTest { fun `carries the kind through to the stored entity`() = runTest { val midrolls = FakeMidrollDao() - subject(StubTransport(null), midrolls = midrolls).record( + subject(StubTransport(null), Fakes(midrolls = midrolls)).record( episode, ServerEpisode( ready = true, audioUrl = "x", durationMs = null, introMs = null, outroMs = null, @@ -282,7 +307,7 @@ class ServerSkipsTest { @Test fun `cannot ask when the show's feed is unknown`() = runTest { - assertEquals(false, subject(StubTransport(null), subscriptions = FakeSubscriptionDao()).canAsk(episode)) + assertEquals(false, subject(StubTransport(null), Fakes(subscriptions = FakeSubscriptionDao())).canAsk(episode)) } @Test @@ -293,7 +318,139 @@ class ServerSkipsTest { FakeShowFingerprintDao(), FakePlaybackStateDao(FakeEpisodeDao().rows), FakeMidrollDao(), + FakeTranscriptDao(), ) assertEquals(false, unconfigured.canAsk(episode)) } + + /* + * An episode's words arrive with its audio, not when somebody opens them. + * + * The point of downloading is that the episode works with no network, and a transcript + * fetched on opening would be blank on a plane or in a car park -- which is where this app + * is actually used. Tens of kilobytes beside tens of megabytes of audio. + */ + + @Test + fun `stores the words when the audio lands`() = runTest { + val transcripts = FakeTranscriptDao() + val transport = StubTransport( + null, + alsoServing = mapOf("api/transcript" to """ + {"source":"published","cues":[ + {"startMs":0,"endMs":3120,"text":"Welcome back."}, + {"startMs":3500,"endMs":7000,"text":"Our sponsor this week."}]} + """.trimIndent()), + ) + + subject(transport, Fakes(transcripts = transcripts)).record( + episode, + ServerEpisode( + ready = true, audioUrl = "x", durationMs = null, introMs = null, outroMs = null, + detected = true, + transcript = ServerTranscriptNote( + source = "published", cues = 2, + url = "https://listen.terst.org/api/transcript?episode=abc", + ), + ), + ) + + assertEquals(2, transcripts.cueRows.size) + assertEquals("Welcome back.", transcripts.cueRows.first().text) + val row = transcripts.rows.single() + assertEquals("published", row.source) + assertEquals(2, row.cues) + assertTrue(row.hasWords) + } + + /** + * Refused is recorded, not skipped over. The episode screen says the server looked at the + * publisher's transcript and would not use it, which is a different thing from silence. + */ + @Test + fun `records why there are no words`() = runTest { + val transcripts = FakeTranscriptDao() + + subject(StubTransport(null), Fakes(transcripts = transcripts)).record( + episode, + ServerEpisode( + ready = true, audioUrl = "x", durationMs = null, introMs = null, outroMs = null, + detected = true, + transcript = ServerTranscriptNote( + note = "this copy carries 4:16 of inserted advertising", + ), + ), + ) + + val row = transcripts.rows.single() + assertTrue(row.note.contains("advertising")) + assertEquals(0, row.cues) + assertTrue("no cues to fetch, so none should be stored", transcripts.cueRows.isEmpty()) + } + + /** Nobody has looked yet, so there is nothing to write down. */ + @Test + fun `writes nothing when the server said nothing about a transcript`() = runTest { + val transcripts = FakeTranscriptDao() + + subject(StubTransport(null), Fakes(transcripts = transcripts)).record( + episode, + ServerEpisode( + ready = true, audioUrl = "x", durationMs = null, introMs = null, outroMs = null, + detected = true, + ), + ) + + assertTrue(transcripts.rows.isEmpty()) + } + + /** + * A transcript that will not download leaves the episode's words as not-yet-fetched rather + * than as refused. The audio is what matters, and the next refresh tries again. + */ + @Test + fun `a transcript that does not arrive does not become a refusal`() = runTest { + val transcripts = FakeTranscriptDao() + + subject(StubTransport(null), Fakes(transcripts = transcripts)).record( + episode, + ServerEpisode( + ready = true, audioUrl = "x", durationMs = null, introMs = null, outroMs = null, + detected = true, + transcript = ServerTranscriptNote( + source = "published", cues = 2, + url = "https://listen.terst.org/api/transcript?episode=abc", + ), + ), + ) + + assertTrue("stored a transcript it never received", transcripts.rows.isEmpty()) + assertTrue(transcripts.cueRows.isEmpty()) + } + + /** The server is the authority, and it re-measures on every refresh. */ + @Test + fun `replaces the words rather than accumulating them`() = runTest { + val transcripts = FakeTranscriptDao() + val transport = StubTransport( + null, + alsoServing = mapOf("api/transcript" to + """{"source":"asr","cues":[{"startMs":0,"endMs":1000,"text":"Only this."}]}"""), + ) + val info = ServerEpisode( + ready = true, audioUrl = "x", durationMs = null, introMs = null, outroMs = null, + detected = true, + transcript = ServerTranscriptNote( + source = "asr", cues = 1, + url = "https://listen.terst.org/api/transcript?episode=abc", + ), + ) + + val skips = subject(transport, Fakes(transcripts = transcripts)) + skips.record(episode, info) + skips.record(episode, info) + + assertEquals(1, transcripts.cueRows.size) + assertEquals(1, transcripts.rows.size) + } } diff --git a/app/src/test/java/org/terst/listen/server/ServerTranscriptTest.kt b/app/src/test/java/org/terst/listen/server/ServerTranscriptTest.kt new file mode 100644 index 0000000..a9f2c1f --- /dev/null +++ b/app/src/test/java/org/terst/listen/server/ServerTranscriptTest.kt @@ -0,0 +1,207 @@ +package org.terst.listen.server + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Asking the server for an episode's words. + * + * Two steps on purpose. The episode response says whether there is a transcript and where it + * came from, which is a line every episode needs; the cues themselves are a thousand rows and + * are fetched only for an episode somebody will actually read. + * + * Where they came from travels with them because the two kinds are not the same thing. A + * published transcript is the publisher's exact words, and the server only uses one where the + * publisher's master is the copy that was mirrored -- for a show inserting advertising it is + * minutes out of step with the audio the phone plays. Recognition is a machine's guess at + * exactly the bytes being played. + */ +class ServerTranscriptTest { + + private class FakeTransport(private val bodies: Map<String, String>) : SkipTransport { + val gets = mutableListOf<String>() + override suspend fun get(url: String, token: String): SkipReply { + gets += url + return bodies[url]?.let { SkipReply.Body(it) } ?: SkipReply.NoAnswer + } + + override suspend fun post(url: String, token: String, body: String) = SkipReply.Body("") + } + + private fun server(transport: SkipTransport) = + SkipServer({ SkipServerConfig("https://listen.terst.org", "tok") }, transport) + + private val episodeUrl = + "https://listen.terst.org/api/episode?feed=https%3A%2F%2Ff&guid=g1&enclosure=&title=" + + private fun episodeBody(transcript: String) = + """{"status":"ready","audioUrl":"/audio/abc?token=tok","transcript":$transcript}""" + + @Test + fun `reports that an episode has words and whose they are`() = runTest { + val transport = FakeTransport(mapOf( + episodeUrl to episodeBody( + """{"source":"published","cues":412,"url":"/api/transcript?episode=abc&token=tok"}""" + ), + )) + + val got = server(transport).episode("https://f", "g1", "", "") + + assertEquals("published", got?.transcript?.source) + assertEquals(412, got?.transcript?.cues) + assertTrue(got?.transcript?.hasWords == true) + } + + /** A root-relative transcript path, resolved the same way the audio url is. */ + @Test + fun `resolves the transcript url against the server`() = runTest { + val transport = FakeTransport(mapOf( + episodeUrl to episodeBody("""{"source":"published","cues":1,"url":"/api/transcript?episode=abc"}"""), + )) + + val got = server(transport).episode("https://f", "g1", "", "") + + assertEquals("https://listen.terst.org/api/transcript?episode=abc", got?.transcript?.url) + } + + /** + * Refused is not absent. The server looked at what the publisher offered and would not use + * it, and the episode screen says so rather than showing nothing. + */ + @Test + fun `carries the reason there are no words`() = runTest { + val transport = FakeTransport(mapOf( + episodeUrl to episodeBody( + """{"note":"this copy carries 4:16 of inserted advertising"}""" + ), + )) + + val got = server(transport).episode("https://f", "g1", "", "") + + assertTrue(got?.transcript?.hasWords == false) + assertTrue( + "expected the reason, got ${got?.transcript?.note}", + got?.transcript?.note?.contains("advertising") == true, + ) + assertNull("nothing to fetch, so nothing to point at", got?.transcript?.url) + } + + @Test + fun `an episode nobody has looked at has no transcript line at all`() = runTest { + val transport = FakeTransport(mapOf( + episodeUrl to """{"status":"ready","audioUrl":"/audio/abc?token=tok"}""", + )) + + val got = server(transport).episode("https://f", "g1", "", "") + + assertNull(got?.transcript) + } + + @Test + fun `fetches the cues themselves from the url the episode gave`() = runTest { + val cuesUrl = "https://listen.terst.org/api/transcript?episode=abc&token=tok" + val transport = FakeTransport(mapOf( + cuesUrl to """ + {"episodeId":"abc","source":"published","cues":[ + {"startMs":0,"endMs":3120,"text":"Welcome back."}, + {"startMs":3500,"endMs":7000,"text":"This week's sponsor."}]} + """.trimIndent(), + )) + + val got = server(transport).transcript(cuesUrl) + + assertEquals(2, got?.cues?.size) + assertEquals("Welcome back.", got?.cues?.first()?.text) + assertEquals(3120L, got?.cues?.first()?.endMs) + assertEquals("published", got?.source) + } + + @Test + fun `a transcript that does not arrive is null rather than empty`() = runTest { + val got = server(FakeTransport(emptyMap())) + .transcript("https://listen.terst.org/api/transcript?episode=abc") + + assertNull(got) + } + + /** + * A cue with no text or an inverted range is nothing to show and nothing to seek to. The + * same filtering the breaks get, and for the same reason: the player acts on these numbers. + */ + @Test + fun `drops cues that say nothing or run backwards`() = runTest { + val cuesUrl = "https://listen.terst.org/api/transcript?episode=abc" + val transport = FakeTransport(mapOf( + cuesUrl to """ + {"source":"asr","cues":[ + {"startMs":0,"endMs":1000,"text":"Real."}, + {"startMs":2000,"endMs":1500,"text":"Backwards."}, + {"startMs":3000,"endMs":4000,"text":" "}]} + """.trimIndent(), + )) + + val got = server(transport).transcript(cuesUrl) + + assertEquals(1, got?.cues?.size) + assertEquals("Real.", got?.cues?.first()?.text) + } + + /** No server means no words, the same as it means no skips: the app worked before one. */ + @Test + fun `asks for nothing when no server is configured`() = runTest { + val transport = FakeTransport(emptyMap()) + val unconfigured = SkipServer({ null }, transport) + + val got = unconfigured.transcript("https://listen.terst.org/api/transcript?episode=abc") + + assertNull(got) + assertTrue("asked anyway", transport.gets.isEmpty()) + } + + /** + * A body that is not a transcript is no transcript. There is nothing for a caller to do + * differently about the reason -- an enrichment that did not arrive is simply absent. + */ + @Test + fun `a body that does not parse is no transcript`() = runTest { + val cuesUrl = "https://listen.terst.org/api/transcript?episode=abc" + val transport = FakeTransport(mapOf(cuesUrl to "<html>not json</html>")) + + assertNull(server(transport).transcript(cuesUrl)) + } + + /** A transcript with no cues at all is nothing to show, and says so as an empty list. */ + @Test + fun `an empty cue list is empty rather than null`() = runTest { + val cuesUrl = "https://listen.terst.org/api/transcript?episode=abc" + val transport = FakeTransport(mapOf(cuesUrl to """{"source":"asr","cues":[]}""")) + + val got = server(transport).transcript(cuesUrl) + + assertEquals(0, got?.cues?.size) + assertEquals("asr", got?.source) + } + + /** + * An empty string is not a note and not a URL. + * + * The server omits both when it has nothing to say, but a JSON field that is present and + * blank would otherwise read as a refusal with no reason -- a screen saying "No transcript + * for this copy: ." -- and as a URL worth fetching. + */ + @Test + fun `a blank note or url reads as absent`() = runTest { + val transport = FakeTransport(mapOf( + episodeUrl to episodeBody("""{"source":"published","cues":3,"note":"","url":" "}"""), + )) + + val got = server(transport).episode("https://f", "g1", "", "") + + assertNull(got?.transcript?.note) + assertNull(got?.transcript?.url) + assertTrue(got?.transcript?.hasWords == true) + } +} diff --git a/app/src/test/java/org/terst/listen/server/SkipRefresherTest.kt b/app/src/test/java/org/terst/listen/server/SkipRefresherTest.kt index 28e0e03..6fcfda8 100644 --- a/app/src/test/java/org/terst/listen/server/SkipRefresherTest.kt +++ b/app/src/test/java/org/terst/listen/server/SkipRefresherTest.kt @@ -16,6 +16,7 @@ import org.terst.listen.data.FakeMidrollDao import org.terst.listen.data.FakePlaybackStateDao import org.terst.listen.data.FakeShowFingerprintDao import org.terst.listen.data.FakeSubscriptionDao +import org.terst.listen.data.FakeTranscriptDao import org.terst.listen.data.SubscriptionEntity import org.terst.listen.download.ServerAudio @@ -267,6 +268,7 @@ class SkipRefresherTest { fingerprints, playbackState, midrolls, + FakeTranscriptDao(), ) val refresher = SkipRefresher(dao, serverSkips) diff --git a/app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt b/app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt index c5c2c3a..f81d6d0 100644 --- a/app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt +++ b/app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt @@ -6,12 +6,14 @@ import org.junit.Assert.assertTrue import org.junit.Test import org.terst.listen.data.DownloadState import org.terst.listen.data.EpisodeEntity +import org.terst.listen.data.EpisodeTranscriptEntity import org.terst.listen.ui.EpisodeMenuAction.CHAPTERS import org.terst.listen.ui.EpisodeMenuAction.DOWNLOAD import org.terst.listen.ui.EpisodeMenuAction.MARK_OLDER_PLAYED import org.terst.listen.ui.EpisodeMenuAction.PUBLISHER_COPY import org.terst.listen.ui.EpisodeMenuAction.QUEUE import org.terst.listen.ui.EpisodeMenuAction.TOGGLE_PLAYED +import org.terst.listen.ui.EpisodeMenuAction.TRANSCRIPT import org.terst.listen.ui.EpisodeMenuAction.WATCH class EpisodeMenuTest { @@ -26,8 +28,16 @@ class EpisodeMenuTest { chaptersUrl = chaptersUrl, ) - private fun actions(e: EpisodeEntity, link: String? = null) = - EpisodeMenu.items(e, link).map { it.action } + private fun actions( + e: EpisodeEntity, + link: String? = null, + transcript: EpisodeTranscriptEntity? = null, + ) = EpisodeMenu.items(e, link, transcript = transcript).map { it.action } + + private fun transcript(cues: Int = 0, note: String = "") = EpisodeTranscriptEntity( + episodeId = "e1", source = if (cues > 0) "published" else "", cues = cues, + note = note, fetchedAtMs = 0, + ) @Test fun `an ordinary episode offers the everyday actions and nothing conditional`() { @@ -119,4 +129,29 @@ class EpisodeMenuTest { assertEquals("Sleep timer", item.label) } + + /* + * Transcripts. + * + * Offered whenever the server has looked, including when it looked and would not use what + * the publisher offered. Those are different answers and the screen gives a different one; + * hiding the refusal would leave "no transcript" and "a transcript we cannot trust for this + * copy" looking identical, which is the readout mistake this app has made twice before. + */ + + @Test + fun `a transcript is offered when there are words`() { + assertTrue(actions(episode(), transcript = transcript(cues = 412)).contains(TRANSCRIPT)) + } + + @Test + fun `a transcript is offered when the server looked and refused`() { + val refused = transcript(note = "this copy carries 4:16 of inserted advertising") + assertTrue(actions(episode(), transcript = refused).contains(TRANSCRIPT)) + } + + @Test + fun `nothing is offered when nobody has looked`() { + assertFalse(actions(episode()).contains(TRANSCRIPT)) + } } diff --git a/app/src/test/java/org/terst/listen/ui/TranscriptSyncingTest.kt b/app/src/test/java/org/terst/listen/ui/TranscriptSyncingTest.kt new file mode 100644 index 0000000..16161fe --- /dev/null +++ b/app/src/test/java/org/terst/listen/ui/TranscriptSyncingTest.kt @@ -0,0 +1,102 @@ +package org.terst.listen.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.terst.listen.data.TranscriptCueEntity + +/** + * Following a transcript while it plays. + * + * The failure worth testing is the quiet one: a highlight on the line before or after the one + * being spoken looks correct at a glance and is useless to read along with. + */ +class TranscriptSyncingTest { + + private fun cues(vararg spans: Pair<Long, Long>) = spans.mapIndexed { index, (start, end) -> + TranscriptCueEntity(episodeId = "e1", startMs = start, endMs = end, text = "line $index") + } + + private val sample = cues(0L to 3000L, 3500L to 7000L, 20_000L to 24_000L) + + @Test + fun `finds the line being spoken`() { + assertEquals(0, TranscriptSyncing.indexAt(sample, 1500)) + assertEquals(1, TranscriptSyncing.indexAt(sample, 4000)) + assertEquals(2, TranscriptSyncing.indexAt(sample, 22_000)) + } + + @Test + fun `a cue is current from the instant it starts`() { + assertEquals(1, TranscriptSyncing.indexAt(sample, 3500)) + } + + /** + * Transcripts have gaps -- silence, music, a pause. The honest answer during one is the line + * last spoken; flicking the highlight off and back is worse than leaving it. + */ + @Test + fun `holds the last line through a gap`() { + assertEquals(1, TranscriptSyncing.indexAt(sample, 12_000)) + } + + @Test + fun `holds the last line past the end`() { + assertEquals(2, TranscriptSyncing.indexAt(sample, 999_000)) + } + + /** An episode opening on theme music has not said its first line yet. */ + @Test + fun `nothing is current before the first line`() { + assertNull(TranscriptSyncing.indexAt(cues(5000L to 8000L), 1200)) + } + + @Test + fun `an empty transcript has no current line`() { + assertNull(TranscriptSyncing.indexAt(emptyList(), 1000)) + } + + /** Thousands of cues, asked on every position tick. */ + @Test + fun `stays correct over a long transcript`() { + val long = (0 until 5000).map { + TranscriptCueEntity("e1", startMs = it * 4000L, endMs = it * 4000L + 3500, text = "$it") + } + assertEquals(4999, TranscriptSyncing.indexAt(long, 4999 * 4000L + 10)) + assertEquals(2500, TranscriptSyncing.indexAt(long, 2500 * 4000L + 3900)) + } + + /* + * Why there is nothing to read is three different sentences, and the difference is the + * point: a refusal means the publisher's words exist and describe different audio. + */ + + @Test + fun `a refusal says what the server decided`() { + val message = TranscriptSyncing.emptyMessage( + "this copy carries 4:16 of inserted advertising", downloaded = true, + ) + assertTrue(message, message.contains("4:16")) + } + + @Test + fun `a downloaded episode with no words says the server supplies them`() { + val message = TranscriptSyncing.emptyMessage(null, downloaded = true) + assertTrue(message, message.contains("server")) + } + + @Test + fun `an undownloaded episode is told how to get one`() { + val message = TranscriptSyncing.emptyMessage(null, downloaded = false) + assertTrue(message, message.contains("Download")) + } + + /** An exact transcript and a machine's guess read differently, so the screen says which. */ + @Test + fun `names whose words these are`() { + assertTrue(TranscriptSyncing.sourceLabel("published", 412).contains("publisher")) + assertTrue(TranscriptSyncing.sourceLabel("asr", 412).contains("recognised")) + assertEquals("412 lines", TranscriptSyncing.sourceLabel("", 412)) + } +} |
