summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-10-28 08:41:34 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-10-28 08:41:34 +0000
commit562742d040d75bdb2875e7411622dc3ec952ed24 (patch)
tree70806fd657804c8376c757f1a0949d1966860003
parent73cad06e99357e8b3cf48f7eaf890db8ff30db6b (diff)
The app reads an episode's words, and follows them while they play
The ⋮ menu gains Transcript, which opens the episode's lines with the one being spoken in bold and the list following the playhead. Tapping a line seeks to it, which is the thing anybody actually wants a transcript for -- finding the bit you half-heard. It is also why the server refuses a publisher's transcript for an ad-inserted copy. Tap a line in a transcript of the ad-free master and the audio starts minutes from where the words are. The words arrive with the audio rather than when the screen opens. The point of downloading is that the episode works with no network, and a transcript fetched on opening is blank on a plane or in a car park, which is where this app is used. Tens of kilobytes beside tens of megabytes. One row per cue, not a JSON column. 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 the oversized pendingClipPcm crashed the app on every show's second fingerprinted episode. A three-hour episode is thousands of cues, so one column holding them all is that bug with a different name. Both tables cascade from the episode. Schema 17, migrated and checked on a device, including that deleting an episode takes its words. Three states again, kept apart: words to read, a recorded reason there are none -- the server looked at the publisher's transcript and would not use it for this copy -- and nobody having looked. The menu offers the entry for the first two and the screen says something different for each. TranscriptSyncing holds the highlighting, apart from the screen and tested, because the way this goes wrong is quiet: a highlight on the line before or after the one being spoken looks right at a glance and is useless to read along with. It holds the last line through a gap rather than flicking off during silence or music, and highlights nothing before the first line, since an episode opening on theme music has not said anything yet. Verified end to end against a real feed before committing, on a throwaway data directory: Wards Way Podcast mirrored, both episodes' transcripts taken, and the API served 748 cues whose last one ends 466ms before the measured file does. Transcripts live on their own view model rather than two more functions on ListenViewModel, which was already at the size the linter allows -- same call as splitting Screens.kt, and they have a different lifetime anyway. The coverage gate caught the replace transaction having no direct test, which was fair: its clear-then-insert ordering is the thing that would leave a reader looking at an empty list under a header counting lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
-rw-r--r--.coverage-baseline2
-rw-r--r--app/schemas/org.terst.listen.data.AppDatabase/17.json1019
-rw-r--r--app/src/androidTest/java/org/terst/listen/ui/TranscriptCueListTest.kt72
-rw-r--r--app/src/main/java/org/terst/listen/data/AppDatabase.kt46
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt42
-rw-r--r--app/src/main/java/org/terst/listen/data/Entities.kt67
-rw-r--r--app/src/main/java/org/terst/listen/data/Repositories.kt3
-rw-r--r--app/src/main/java/org/terst/listen/server/ServerSkips.kt56
-rw-r--r--app/src/main/java/org/terst/listen/server/SkipServer.kt88
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt2
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt12
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt8
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt13
-rw-r--r--app/src/main/java/org/terst/listen/ui/MainActivity.kt10
-rw-r--r--app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt2
-rw-r--r--app/src/main/java/org/terst/listen/ui/TranscriptReads.kt18
-rw-r--r--app/src/main/java/org/terst/listen/ui/TranscriptScreen.kt134
-rw-r--r--app/src/main/java/org/terst/listen/ui/TranscriptSyncing.kt65
-rw-r--r--app/src/main/java/org/terst/listen/ui/TranscriptViewModel.kt30
-rw-r--r--app/src/test/java/org/terst/listen/data/FakeDaos.kt34
-rw-r--r--app/src/test/java/org/terst/listen/data/TranscriptReplaceTest.kt79
-rw-r--r--app/src/test/java/org/terst/listen/download/WaitForServerWiringTest.kt13
-rw-r--r--app/src/test/java/org/terst/listen/playback/BreakVerdictsTest.kt1
-rw-r--r--app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt189
-rw-r--r--app/src/test/java/org/terst/listen/server/ServerTranscriptTest.kt207
-rw-r--r--app/src/test/java/org/terst/listen/server/SkipRefresherTest.kt2
-rw-r--r--app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt39
-rw-r--r--app/src/test/java/org/terst/listen/ui/TranscriptSyncingTest.kt102
28 files changed, 2327 insertions, 28 deletions
diff --git a/.coverage-baseline b/.coverage-baseline
index 64eced0..1fbd300 100644
--- a/.coverage-baseline
+++ b/.coverage-baseline
@@ -1 +1 @@
-94.56
+94.45
diff --git a/app/schemas/org.terst.listen.data.AppDatabase/17.json b/app/schemas/org.terst.listen.data.AppDatabase/17.json
new file mode 100644
index 0000000..10b1574
--- /dev/null
+++ b/app/schemas/org.terst.listen.data.AppDatabase/17.json
@@ -0,0 +1,1019 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 17,
+ "identityHash": "adf1055a4381e72e612054b59947bf6b",
+ "entities": [
+ {
+ "tableName": "subscriptions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `feedUrl` TEXT NOT NULL, `kind` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `author` TEXT, `imageUrl` TEXT, `link` TEXT, `addedAtMs` INTEGER NOT NULL, `etag` TEXT, `lastModified` TEXT, `lastFetchedAtMs` INTEGER, `lastError` TEXT, `playbackSpeed` REAL, `skipIntroMs` INTEGER NOT NULL, `skipOutroMs` INTEGER NOT NULL, `dripEnabled` INTEGER NOT NULL DEFAULT 0, `dripIntervalMs` INTEGER NOT NULL DEFAULT 86400000, `dripCount` INTEGER NOT NULL DEFAULT 1, `dripOldestFirst` INTEGER NOT NULL DEFAULT 1, `dripLastReleaseAtMs` INTEGER, `skipAdChapters` INTEGER NOT NULL DEFAULT 0, `midrollPendingEpisodeId` TEXT, `bedtime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "feedUrl",
+ "columnName": "feedUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "kind",
+ "columnName": "kind",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "author",
+ "columnName": "author",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "imageUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "link",
+ "columnName": "link",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "addedAtMs",
+ "columnName": "addedAtMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "etag",
+ "columnName": "etag",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastModified",
+ "columnName": "lastModified",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastFetchedAtMs",
+ "columnName": "lastFetchedAtMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastError",
+ "columnName": "lastError",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "playbackSpeed",
+ "columnName": "playbackSpeed",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "skipIntroMs",
+ "columnName": "skipIntroMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "skipOutroMs",
+ "columnName": "skipOutroMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dripEnabled",
+ "columnName": "dripEnabled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "dripIntervalMs",
+ "columnName": "dripIntervalMs",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "86400000"
+ },
+ {
+ "fieldPath": "dripCount",
+ "columnName": "dripCount",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "1"
+ },
+ {
+ "fieldPath": "dripOldestFirst",
+ "columnName": "dripOldestFirst",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "1"
+ },
+ {
+ "fieldPath": "dripLastReleaseAtMs",
+ "columnName": "dripLastReleaseAtMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "skipAdChapters",
+ "columnName": "skipAdChapters",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "midrollPendingEpisodeId",
+ "columnName": "midrollPendingEpisodeId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "bedtime",
+ "columnName": "bedtime",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_subscriptions_feedUrl",
+ "unique": true,
+ "columnNames": [
+ "feedUrl"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_feedUrl` ON `${TABLE_NAME}` (`feedUrl`)"
+ }
+ ]
+ },
+ {
+ "tableName": "episodes",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `mediaUrl` TEXT NOT NULL, `mediaType` TEXT NOT NULL, `mediaLengthBytes` INTEGER, `durationMs` INTEGER, `publishedAtMs` INTEGER, `seasonNumber` INTEGER, `episodeNumber` INTEGER, `imageUrl` TEXT, `chaptersUrl` TEXT, `discoveredAtMs` INTEGER NOT NULL, `positionMs` INTEGER NOT NULL, `playedAtMs` INTEGER, `actualDurationMs` INTEGER, `downloadState` TEXT NOT NULL, `localPath` TEXT, `downloadedAtMs` INTEGER, `audioFromServer` INTEGER NOT NULL DEFAULT 0, `dripReleasedAtMs` INTEGER, `appliedRuleId` TEXT, `appliedRuleReason` TEXT, `speedOverride` REAL, `midrollCheckedAtMs` INTEGER, `chaptersJson` TEXT, `detectedIntroMs` INTEGER, `detectedOutroMs` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subscriptionId",
+ "columnName": "subscriptionId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "guid",
+ "columnName": "guid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mediaUrl",
+ "columnName": "mediaUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mediaType",
+ "columnName": "mediaType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mediaLengthBytes",
+ "columnName": "mediaLengthBytes",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "durationMs",
+ "columnName": "durationMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "publishedAtMs",
+ "columnName": "publishedAtMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "seasonNumber",
+ "columnName": "seasonNumber",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "episodeNumber",
+ "columnName": "episodeNumber",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "imageUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "chaptersUrl",
+ "columnName": "chaptersUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "discoveredAtMs",
+ "columnName": "discoveredAtMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "positionMs",
+ "columnName": "positionMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playedAtMs",
+ "columnName": "playedAtMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "actualDurationMs",
+ "columnName": "actualDurationMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "downloadState",
+ "columnName": "downloadState",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "localPath",
+ "columnName": "localPath",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "downloadedAtMs",
+ "columnName": "downloadedAtMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "audioFromServer",
+ "columnName": "audioFromServer",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "dripReleasedAtMs",
+ "columnName": "dripReleasedAtMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "appliedRuleId",
+ "columnName": "appliedRuleId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "appliedRuleReason",
+ "columnName": "appliedRuleReason",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "speedOverride",
+ "columnName": "speedOverride",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "midrollCheckedAtMs",
+ "columnName": "midrollCheckedAtMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "chaptersJson",
+ "columnName": "chaptersJson",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "detectedIntroMs",
+ "columnName": "detectedIntroMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "detectedOutroMs",
+ "columnName": "detectedOutroMs",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_episodes_subscriptionId",
+ "unique": false,
+ "columnNames": [
+ "subscriptionId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_subscriptionId` ON `${TABLE_NAME}` (`subscriptionId`)"
+ },
+ {
+ "name": "index_episodes_subscriptionId_guid",
+ "unique": true,
+ "columnNames": [
+ "subscriptionId",
+ "guid"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_episodes_subscriptionId_guid` ON `${TABLE_NAME}` (`subscriptionId`, `guid`)"
+ },
+ {
+ "name": "index_episodes_publishedAtMs",
+ "unique": false,
+ "columnNames": [
+ "publishedAtMs"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_publishedAtMs` ON `${TABLE_NAME}` (`publishedAtMs`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "subscriptions",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "subscriptionId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "queue",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`episodeId`), FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "episodeId",
+ "columnName": "episodeId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "episodeId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_queue_position",
+ "unique": false,
+ "columnNames": [
+ "position"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_queue_position` ON `${TABLE_NAME}` (`position`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "episodes",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "episodeId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "rules",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `position` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `subscriptionId` TEXT, `titleContains` TEXT, `titleExcludes` TEXT, `minDurationMs` INTEGER, `maxDurationMs` INTEGER, `maxAgeMs` INTEGER, `seasonNumber` INTEGER, `action` TEXT NOT NULL, `autoDownload` INTEGER NOT NULL, `speed` REAL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "enabled",
+ "columnName": "enabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subscriptionId",
+ "columnName": "subscriptionId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "titleContains",
+ "columnName": "titleContains",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "titleExcludes",
+ "columnName": "titleExcludes",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "minDurationMs",
+ "columnName": "minDurationMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "maxDurationMs",
+ "columnName": "maxDurationMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "maxAgeMs",
+ "columnName": "maxAgeMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "seasonNumber",
+ "columnName": "seasonNumber",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "action",
+ "columnName": "action",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "autoDownload",
+ "columnName": "autoDownload",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "speed",
+ "columnName": "speed",
+ "affinity": "REAL"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_rules_position",
+ "unique": false,
+ "columnNames": [
+ "position"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_rules_position` ON `${TABLE_NAME}` (`position`)"
+ }
+ ]
+ },
+ {
+ "tableName": "show_fingerprints",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscriptionId` TEXT NOT NULL, `kind` TEXT NOT NULL, `referenceClipPcm` BLOB, `pendingEpisodeId` TEXT, `sampleRate` INTEGER NOT NULL, `confirmedCount` INTEGER NOT NULL, `lastConfirmedEpisodeId` TEXT, `updatedAtMs` INTEGER NOT NULL, PRIMARY KEY(`subscriptionId`, `kind`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "subscriptionId",
+ "columnName": "subscriptionId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "kind",
+ "columnName": "kind",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "referenceClipPcm",
+ "columnName": "referenceClipPcm",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "pendingEpisodeId",
+ "columnName": "pendingEpisodeId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "sampleRate",
+ "columnName": "sampleRate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "confirmedCount",
+ "columnName": "confirmedCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastConfirmedEpisodeId",
+ "columnName": "lastConfirmedEpisodeId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "updatedAtMs",
+ "columnName": "updatedAtMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "subscriptionId",
+ "kind"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_show_fingerprints_subscriptionId",
+ "unique": false,
+ "columnNames": [
+ "subscriptionId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_show_fingerprints_subscriptionId` ON `${TABLE_NAME}` (`subscriptionId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "subscriptions",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "subscriptionId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "show_midrolls",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscriptionId` TEXT NOT NULL, `id` TEXT NOT NULL, `referenceClipPath` TEXT NOT NULL, `sampleRate` INTEGER NOT NULL, `confirmedCount` INTEGER NOT NULL, `lastConfirmedEpisodeId` TEXT, `updatedAtMs` INTEGER NOT NULL, PRIMARY KEY(`subscriptionId`, `id`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "subscriptionId",
+ "columnName": "subscriptionId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "referenceClipPath",
+ "columnName": "referenceClipPath",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sampleRate",
+ "columnName": "sampleRate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "confirmedCount",
+ "columnName": "confirmedCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastConfirmedEpisodeId",
+ "columnName": "lastConfirmedEpisodeId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "updatedAtMs",
+ "columnName": "updatedAtMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "subscriptionId",
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_show_midrolls_subscriptionId",
+ "unique": false,
+ "columnNames": [
+ "subscriptionId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_show_midrolls_subscriptionId` ON `${TABLE_NAME}` (`subscriptionId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "subscriptions",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "subscriptionId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "episode_midrolls",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `candidateId` TEXT NOT NULL, `startMs` INTEGER NOT NULL, `endMs` INTEGER NOT NULL, `kind` TEXT NOT NULL DEFAULT 'ad', `partners` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`episodeId`, `candidateId`), FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "episodeId",
+ "columnName": "episodeId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "candidateId",
+ "columnName": "candidateId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "startMs",
+ "columnName": "startMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "endMs",
+ "columnName": "endMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "kind",
+ "columnName": "kind",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'ad'"
+ },
+ {
+ "fieldPath": "partners",
+ "columnName": "partners",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "episodeId",
+ "candidateId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_episode_midrolls_episodeId",
+ "unique": false,
+ "columnNames": [
+ "episodeId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_episode_midrolls_episodeId` ON `${TABLE_NAME}` (`episodeId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "episodes",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "episodeId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "break_verdicts",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `startMs` INTEGER NOT NULL, `endMs` INTEGER NOT NULL, `verdict` TEXT NOT NULL, `decidedAtMs` INTEGER NOT NULL, `kind` TEXT NOT NULL DEFAULT 'ad', `partners` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`episodeId`, `startMs`, `endMs`))",
+ "fields": [
+ {
+ "fieldPath": "episodeId",
+ "columnName": "episodeId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "startMs",
+ "columnName": "startMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "endMs",
+ "columnName": "endMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "verdict",
+ "columnName": "verdict",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "decidedAtMs",
+ "columnName": "decidedAtMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "kind",
+ "columnName": "kind",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'ad'"
+ },
+ {
+ "fieldPath": "partners",
+ "columnName": "partners",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "episodeId",
+ "startMs",
+ "endMs"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_break_verdicts_episodeId",
+ "unique": false,
+ "columnNames": [
+ "episodeId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_break_verdicts_episodeId` ON `${TABLE_NAME}` (`episodeId`)"
+ }
+ ]
+ },
+ {
+ "tableName": "ad_reports",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `startMs` INTEGER NOT NULL, `endMs` INTEGER NOT NULL, `reportedAtMs` INTEGER NOT NULL, `correctionWindowMs` INTEGER NOT NULL, `endCorrected` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`episodeId`, `startMs`))",
+ "fields": [
+ {
+ "fieldPath": "episodeId",
+ "columnName": "episodeId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "startMs",
+ "columnName": "startMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "endMs",
+ "columnName": "endMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "reportedAtMs",
+ "columnName": "reportedAtMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "correctionWindowMs",
+ "columnName": "correctionWindowMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "endCorrected",
+ "columnName": "endCorrected",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "episodeId",
+ "startMs"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_ad_reports_episodeId",
+ "unique": false,
+ "columnNames": [
+ "episodeId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_ad_reports_episodeId` ON `${TABLE_NAME}` (`episodeId`)"
+ }
+ ]
+ },
+ {
+ "tableName": "episode_transcripts",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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 )",
+ "fields": [
+ {
+ "fieldPath": "episodeId",
+ "columnName": "episodeId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "cues",
+ "columnName": "cues",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "note",
+ "columnName": "note",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fetchedAtMs",
+ "columnName": "fetchedAtMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "episodeId"
+ ]
+ },
+ "foreignKeys": [
+ {
+ "table": "episodes",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "episodeId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "transcript_cues",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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 )",
+ "fields": [
+ {
+ "fieldPath": "episodeId",
+ "columnName": "episodeId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "startMs",
+ "columnName": "startMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "endMs",
+ "columnName": "endMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "text",
+ "columnName": "text",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "episodeId",
+ "startMs"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_transcript_cues_episodeId",
+ "unique": false,
+ "columnNames": [
+ "episodeId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_transcript_cues_episodeId` ON `${TABLE_NAME}` (`episodeId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "episodes",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "episodeId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'adf1055a4381e72e612054b59947bf6b')"
+ ]
+ }
+} \ No newline at end of file
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))
+ }
+}