summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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))
+ }
+}