summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-10-20 06:48:48 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-10-20 06:48:48 +0000
commit9cc92e743dda2609a67595439c4694f6a4efae27 (patch)
tree7f200ad16613a33d31e0e01e4123ddaa987d3f09
parentf087b8d15f38bc5500aa2ee986865557460ec89c (diff)
Let the listener validate or invalidate a detected break from a popup
Keeps partners on episode_midrolls (schema 15), stores verdicts in their own break_verdicts table so a server refresh cannot wipe them, and drops invalidated breaks from skipping on the next tick. Verdicts are local only. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
-rw-r--r--.coverage-baseline2
-rw-r--r--app/schemas/org.terst.listen.data.AppDatabase/15.json842
-rw-r--r--app/src/androidTest/java/org/terst/listen/data/BreakVerdictsDbTest.kt57
-rw-r--r--app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt58
-rw-r--r--app/src/main/java/org/terst/listen/data/AppDatabase.kt28
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt13
-rw-r--r--app/src/main/java/org/terst/listen/data/Entities.kt35
-rw-r--r--app/src/main/java/org/terst/listen/data/Repositories.kt5
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/BreakPopup.kt87
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt14
-rw-r--r--app/src/main/java/org/terst/listen/playback/BreakVerdicts.kt33
-rw-r--r--app/src/main/java/org/terst/listen/playback/MidrollSkip.kt14
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt15
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlaybackService.kt13
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt5
-rw-r--r--app/src/main/java/org/terst/listen/playback/VerdictRecorder.kt33
-rw-r--r--app/src/main/java/org/terst/listen/server/ServerSkips.kt1
-rw-r--r--app/src/main/java/org/terst/listen/server/SkipServer.kt2
-rw-r--r--app/src/main/java/org/terst/listen/ui/BreakJudging.kt115
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt9
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeTimeline.kt22
-rw-r--r--app/src/main/java/org/terst/listen/ui/ListenViewModel.kt3
-rw-r--r--app/src/test/java/org/terst/listen/data/FakeDaos.kt18
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/BreakPopupTest.kt138
-rw-r--r--app/src/test/java/org/terst/listen/playback/BreakVerdictsTest.kt210
-rw-r--r--app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt32
-rw-r--r--app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt22
-rw-r--r--app/src/test/java/org/terst/listen/server/SkipServerTest.kt13
-rw-r--r--docs/superpowers/plans/2026-10-20-corrections-and-signature-library.md263
29 files changed, 2086 insertions, 16 deletions
diff --git a/.coverage-baseline b/.coverage-baseline
index 94f785c..afce29c 100644
--- a/.coverage-baseline
+++ b/.coverage-baseline
@@ -1 +1 @@
-94.15
+94.34
diff --git a/app/schemas/org.terst.listen.data.AppDatabase/15.json b/app/schemas/org.terst.listen.data.AppDatabase/15.json
new file mode 100644
index 0000000..098455b
--- /dev/null
+++ b/app/schemas/org.terst.listen.data.AppDatabase/15.json
@@ -0,0 +1,842 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 15,
+ "identityHash": "c5a87d01610eb177f97526990c26a4d2",
+ "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`)"
+ }
+ ]
+ }
+ ],
+ "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, 'c5a87d01610eb177f97526990c26a4d2')"
+ ]
+ }
+} \ No newline at end of file
diff --git a/app/src/androidTest/java/org/terst/listen/data/BreakVerdictsDbTest.kt b/app/src/androidTest/java/org/terst/listen/data/BreakVerdictsDbTest.kt
new file mode 100644
index 0000000..6a98c32
--- /dev/null
+++ b/app/src/androidTest/java/org/terst/listen/data/BreakVerdictsDbTest.kt
@@ -0,0 +1,57 @@
+package org.terst.listen.data
+
+import androidx.room.Room
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.terst.listen.playback.BreakVerdicts
+import org.terst.listen.playback.VerdictRecorder
+
+/**
+ * A verdict against the real tables: the server deleting and re-inserting an episode's ranges
+ * must not touch it, which is the whole reason it has a table of its own.
+ */
+@RunWith(AndroidJUnit4::class)
+class BreakVerdictsDbTest {
+
+ private lateinit var db: AppDatabase
+
+ @Before
+ fun setUp() {
+ db = Room.inMemoryDatabaseBuilder(
+ InstrumentationRegistry.getInstrumentation().targetContext,
+ AppDatabase::class.java,
+ ).build()
+ }
+
+ @After
+ fun tearDown() = db.close()
+
+ @Test
+ fun aVerdictSurvivesTheRangesBeingReplacedAndStillMatches() = runBlocking {
+ db.subscriptions().insert(SubscriptionEntity(id = "s1", feedUrl = "https://x/f", title = "S", addedAtMs = 0))
+ db.episodes().insertAll(listOf(
+ EpisodeEntity(
+ id = "e1", subscriptionId = "s1", guid = "g", title = "t", mediaUrl = "u",
+ mediaType = "audio/mpeg", discoveredAtMs = 0,
+ )
+ ))
+ val midrolls = db.midrolls()
+ midrolls.upsertRange(EpisodeMidrollEntity("e1", "server-0", 60_000, 90_000, "ad", partners = 3))
+ VerdictRecorder(db.breakVerdicts()) { 9L }.record(midrolls.getRanges("e1").single(), BreakVerdicts.INVALID)
+
+ // What ServerSkips does on every refresh, with the edges moved a little.
+ midrolls.deleteRanges("e1")
+ midrolls.upsertRange(EpisodeMidrollEntity("e1", "server-0", 60_900, 89_500, "ad", partners = 4))
+
+ val verdicts = db.breakVerdicts().getForEpisode("e1")
+ assertEquals(1, verdicts.size)
+ assertEquals(BreakVerdicts.INVALID, BreakVerdicts.forRange(verdicts, midrolls.getRanges("e1").single())?.verdict)
+ assertEquals("the evidence at the time of the verdict is kept", 3, verdicts.single().partners)
+ }
+}
diff --git a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
index 5352db8..0124562 100644
--- a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
+++ b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
@@ -595,4 +595,62 @@ class MigrationTest {
assertEquals("/audio/e1.mp3", it.getString(2))
}
}
+
+ /**
+ * Existing breaks keep their rows and gain a partner count of 0 -- unknown, which is the
+ * truth: the phone discarded the figure when it was written. The verdict table arrives empty
+ * and accepts a row, and the crown jewel beside it is untouched.
+ */
+ @Test
+ fun migrate14To15KeepsBreaksDefaultsPartnersToUnknownAndAddsVerdicts() {
+ helper.createDatabase(name, 14).apply {
+ execSQL(
+ """INSERT INTO subscriptions
+ (id, feedUrl, kind, title, addedAtMs, skipIntroMs, skipOutroMs, dripEnabled,
+ dripIntervalMs, dripCount, dripOldestFirst, skipAdChapters, bedtime)
+ VALUES ('s1', 'https://example.com/feed.xml', 'PODCAST', 'A Show', 1000, 0, 0,
+ 0, 86400000, 1, 1, 1, 0)"""
+ )
+ execSQL(
+ """INSERT INTO episodes
+ (id, subscriptionId, guid, title, mediaUrl, mediaType, discoveredAtMs,
+ positionMs, downloadState)
+ VALUES ('e1', 's1', 'g1', 'One', 'https://cdn/1.mp3', 'audio/mpeg', 1000,
+ 161803, 'NONE')"""
+ )
+ execSQL(
+ "INSERT INTO episode_midrolls (episodeId, candidateId, startMs, endMs, kind) " +
+ "VALUES ('e1', 'server-0', 1000, 2000, 'cue')"
+ )
+ close()
+ }
+
+ val db = helper.runMigrationsAndValidate(name, 15, true, AppDatabase.MIGRATION_14_15)
+
+ db.query("SELECT startMs, endMs, kind, partners FROM episode_midrolls WHERE episodeId = 'e1'").use {
+ assertEquals("the break was lost", 1, it.count)
+ it.moveToFirst()
+ assertEquals(1000L, it.getLong(0))
+ assertEquals(2000L, it.getLong(1))
+ assertEquals("cue", it.getString(2))
+ assertEquals("an old break's partner count is unknown, not invented", 0, it.getInt(3))
+ }
+ db.query("SELECT positionMs FROM episodes WHERE id = 'e1'").use {
+ it.moveToFirst()
+ assertEquals("the crown jewel must survive", 161803L, it.getLong(0))
+ }
+ db.query("SELECT COUNT(*) FROM break_verdicts").use {
+ it.moveToFirst()
+ assertEquals(0, it.getInt(0))
+ }
+ db.execSQL(
+ "INSERT INTO break_verdicts (episodeId, startMs, endMs, verdict, decidedAtMs) " +
+ "VALUES ('e1', 1000, 2000, 'invalid', 5)"
+ )
+ db.query("SELECT kind, partners FROM break_verdicts").use {
+ it.moveToFirst()
+ assertEquals("ad", it.getString(0))
+ assertEquals(0, it.getInt(1))
+ }
+ }
}
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 73fa38c..3d5d54e 100644
--- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt
+++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt
@@ -29,8 +29,9 @@ class Converters {
ShowFingerprintEntity::class,
ShowMidrollEntity::class,
EpisodeMidrollEntity::class,
+ BreakVerdictEntity::class,
],
- version = 14,
+ version = 15,
exportSchema = true,
)
@TypeConverters(Converters::class)
@@ -42,6 +43,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun rules(): RuleDao
abstract fun fingerprints(): ShowFingerprintDao
abstract fun midrolls(): MidrollDao
+ abstract fun breakVerdicts(): BreakVerdictDao
companion object {
@Volatile private var instance: AppDatabase? = null
@@ -378,6 +380,29 @@ abstract class AppDatabase : RoomDatabase() {
}
}
+ /**
+ * Keeps the evidence behind each break, and adds the table listener verdicts go in.
+ *
+ * `partners` defaults to 0, meaning unknown: an existing row was written before the
+ * phone kept it, and 0 is the answer that makes no claim. The verdict table is new and
+ * empty, and has no foreign key on purpose -- see [BreakVerdictEntity].
+ */
+ val MIGRATION_14_15 = object : Migration(14, 15) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE `episode_midrolls` ADD COLUMN `partners` INTEGER NOT NULL DEFAULT 0")
+ db.execSQL(
+ """CREATE TABLE IF NOT EXISTS `break_verdicts` (
+ `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`))"""
+ )
+ db.execSQL(
+ "CREATE INDEX IF NOT EXISTS `index_break_verdicts_episodeId` ON `break_verdicts` (`episodeId`)"
+ )
+ }
+ }
+
fun get(context: Context): AppDatabase = instance ?: synchronized(this) {
instance ?: build(context.applicationContext).also { instance = it }
}
@@ -390,6 +415,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,
)
.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 2ab6041..bdbc28e 100644
--- a/app/src/main/java/org/terst/listen/data/Daos.kt
+++ b/app/src/main/java/org/terst/listen/data/Daos.kt
@@ -521,3 +521,16 @@ interface QueueDao {
upsertAll(episodeIds.mapIndexed { index, id -> QueueEntity(id, index) })
}
}
+
+/** Verdicts the listener has given detected breaks; see [BreakVerdictEntity]. */
+@Dao
+interface BreakVerdictDao {
+ @Query("SELECT * FROM break_verdicts WHERE episodeId = :episodeId ORDER BY startMs")
+ suspend fun getForEpisode(episodeId: String): List<BreakVerdictEntity>
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsert(verdict: BreakVerdictEntity)
+
+ @Query("DELETE FROM break_verdicts WHERE episodeId = :episodeId AND startMs = :startMs AND endMs = :endMs")
+ suspend fun delete(episodeId: String, startMs: Long, endMs: Long)
+}
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 8db5a8b..4ae9faa 100644
--- a/app/src/main/java/org/terst/listen/data/Entities.kt
+++ b/app/src/main/java/org/terst/listen/data/Entities.kt
@@ -470,6 +470,41 @@ data class EpisodeMidrollEntity(
* default preserves exactly what those rows already did.
*/
@ColumnInfo(defaultValue = "ad") val kind: String = "ad",
+ /**
+ * How many other episodes carried this same audio -- the evidence behind the break, kept so
+ * the episode screen can say why it was detected. 0 means unknown, not "none": rows from
+ * before this column existed, and a server that did not say, both read as 0.
+ */
+ @ColumnInfo(defaultValue = "0") val partners: Int = 0,
+)
+
+/**
+ * A listener's verdict on one detected break: this is right, or this is wrong.
+ *
+ * Its own table rather than columns on [EpisodeMidrollEntity], because the server is the
+ * authority on an episode's ranges and `ServerSkips` deletes and re-inserts every one of them on
+ * each refresh. A verdict stored on a range would be wiped by the next refresh. This one is
+ * keyed by position instead, and [BreakVerdicts] re-matches it to whatever the ranges become.
+ *
+ * Deliberately no foreign key to the episode: a verdict is a label somebody gave, and the corpus
+ * should outlive the episode row that happened to be on the phone when it was given. [kind] and
+ * [partners] are what the detector claimed at that moment, so the label stands on its own after
+ * the range it was about has been replaced.
+ */
+@Entity(
+ tableName = "break_verdicts",
+ primaryKeys = ["episodeId", "startMs", "endMs"],
+ indices = [Index(value = ["episodeId"])],
+)
+data class BreakVerdictEntity(
+ val episodeId: String,
+ val startMs: Long,
+ val endMs: Long,
+ /** [BreakVerdicts.VALID] or [BreakVerdicts.INVALID]. */
+ val verdict: String,
+ val decidedAtMs: Long,
+ @ColumnInfo(defaultValue = "ad") val kind: String = "ad",
+ @ColumnInfo(defaultValue = "0") val partners: Int = 0,
)
/**
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 476a7e3..0cee350 100644
--- a/app/src/main/java/org/terst/listen/data/Repositories.kt
+++ b/app/src/main/java/org/terst/listen/data/Repositories.kt
@@ -21,6 +21,7 @@ import org.terst.listen.fingerprint.FileMidrollClipStore
import org.terst.listen.fingerprint.MidrollClipStore
import org.terst.listen.playback.PlaybackAdvancer
import org.terst.listen.playback.PlaybackSources
+import org.terst.listen.playback.VerdictRecorder
import org.terst.listen.queue.DripReleaser
import org.terst.listen.queue.QueueOrdering
import org.terst.listen.queue.RetentionPolicy
@@ -66,6 +67,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 breakVerdicts: BreakVerdictDao get() = db.breakVerdicts()
val libraryDir: File by lazy {
File(context.filesDir, "audio").also { it.mkdirs() }
@@ -97,6 +99,7 @@ class AppGraph(private val context: Context) {
PlaybackAdvancer(episodes, subscriptions, queue, sources = object : PlaybackSources {
override suspend fun chapters(episode: EpisodeEntity) = chapters.chapters(episode)
override suspend fun midrolls(episode: EpisodeEntity) = midrolls.getRanges(episode.id)
+ override suspend fun verdicts(episode: EpisodeEntity) = breakVerdicts.getForEpisode(episode.id)
override suspend fun fetchAudio(episode: EpisodeEntity, kind: SubscriptionKind) =
downloader.download(episode, kind).map { }
// DataStore's flow can throw (CorruptionException, IOException) if the preferences
@@ -144,6 +147,8 @@ class AppGraph(private val context: Context) {
})
}
+ val verdictRecorder: VerdictRecorder by lazy { VerdictRecorder(breakVerdicts) }
+
val repository: SubscriptionRepository by lazy {
SubscriptionRepository(
subscriptions,
diff --git a/app/src/main/java/org/terst/listen/fingerprint/BreakPopup.kt b/app/src/main/java/org/terst/listen/fingerprint/BreakPopup.kt
new file mode 100644
index 0000000..617e631
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/fingerprint/BreakPopup.kt
@@ -0,0 +1,87 @@
+package org.terst.listen.fingerprint
+
+import org.terst.listen.playback.BreakVerdicts
+
+/**
+ * The words in the popup a tap on a break opens.
+ *
+ * Here rather than in the composable for the reason [SkipSummary] is: the wording is the
+ * feature. The line he most needs -- whether this will actually be skipped -- was never said
+ * anywhere, and the "why" has to read as a reason, because a bare count says nothing about
+ * whether to trust it.
+ */
+object BreakPopup {
+
+ private const val MS_PER_SECOND = 1000L
+ private const val SECONDS_PER_MINUTE = 60L
+
+ /** Everything the popup prints, one string per line. */
+ data class Content(
+ val title: String,
+ val timing: String,
+ val why: String,
+ val willSkip: String,
+ /** What the listener has already said about this break, or null if nothing. */
+ val verdict: String?,
+ val validateLabel: String,
+ val invalidateLabel: String,
+ )
+
+ fun content(mark: SkipSummary.BreakMarker, skipFurniture: Boolean, verdict: String?): Content =
+ Content(
+ title = SkipSummary.describeKind(mark.kind),
+ timing = "Runs ${SkipSummary.clock(mark.startMs)} - ${SkipSummary.clock(mark.endMs)}, " +
+ "${length(mark.endMs - mark.startMs)} long.",
+ why = why(mark),
+ willSkip = willSkip(mark.kind, skipFurniture, verdict),
+ verdict = when (verdict) {
+ BreakVerdicts.VALID -> "You confirmed this one."
+ BreakVerdicts.INVALID -> "You marked this one wrong."
+ else -> null
+ },
+ validateLabel = if (SkipSummary.isAdvertising(mark.kind)) {
+ "Yes, this is advertising"
+ } else {
+ "Yes, this is the show's own audio"
+ },
+ invalidateLabel = "No, this is the programme",
+ )
+
+ /**
+ * Why the detector flagged it. Phrased as what that agreement looks like, because the same
+ * fact means different things: audio repeated across episodes is how inserted advertising
+ * looks, and equally how a recurring intro does.
+ *
+ * 0 is "the server did not say", never "no other episode" -- it is also what every break
+ * stored before the phone kept the count reads as, so claiming otherwise would be a lie.
+ */
+ private fun why(mark: SkipSummary.BreakMarker): String {
+ if (mark.partners <= 0) return "The server did not say how many other episodes carried this audio."
+ val episodes = if (mark.partners == 1) "1 other episode" else "${mark.partners} other episodes"
+ val looksLike = when (mark.kind) {
+ "intro" -> "a recurring intro looks"
+ "cue" -> "a recurring break cue looks"
+ "outro" -> "recurring credits look"
+ else -> "inserted advertising looks"
+ }
+ return "The same audio is in $episodes of this show, which is how $looksLike."
+ }
+
+ private fun willSkip(kind: String, skipFurniture: Boolean, verdict: String?): String = when {
+ verdict == BreakVerdicts.INVALID -> "Will not be skipped. You marked it as the programme."
+ SkipSummary.isAdvertising(kind) -> "Will be skipped. Advertising always is."
+ skipFurniture -> "Will be skipped, because skipping a show's own intros, cues and credits is on."
+ else -> "Will not be skipped. It is the show's own audio, and skipping that is off."
+ }
+
+ private fun length(ms: Long): String {
+ val seconds = ms / MS_PER_SECOND
+ val minutes = seconds / SECONDS_PER_MINUTE
+ val rest = seconds % SECONDS_PER_MINUTE
+ return when {
+ minutes == 0L -> "$rest s"
+ rest == 0L -> "$minutes min"
+ else -> "$minutes min $rest s"
+ }
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt b/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt
index d42d2a8..3f5d04b 100644
--- a/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt
+++ b/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt
@@ -60,7 +60,16 @@ object SkipSummary {
private const val MINUTES_PER_HOUR = 60
/** Where one detected break sits in an episode, as fractions of its length, and what it is. */
- data class BreakMarker(val start: Float, val end: Float, val kind: String)
+ data class BreakMarker(
+ val start: Float,
+ val end: Float,
+ val kind: String,
+ /** The range in milliseconds as stored, unclamped: what a verdict is matched on. */
+ val startMs: Long = 0,
+ val endMs: Long = 0,
+ /** How many other episodes carried this audio; 0 is unknown. */
+ val partners: Int = 0,
+ )
/**
* The detected breaks as positions on a 0..1 timeline, for the episode screen's scrubber.
@@ -78,6 +87,9 @@ object SkipSummary {
start = (it.startMs.toFloat() / durationMs).coerceIn(0f, 1f),
end = (it.endMs.toFloat() / durationMs).coerceIn(0f, 1f),
kind = it.kind,
+ startMs = it.startMs,
+ endMs = it.endMs,
+ partners = it.partners,
)
}
}
diff --git a/app/src/main/java/org/terst/listen/playback/BreakVerdicts.kt b/app/src/main/java/org/terst/listen/playback/BreakVerdicts.kt
new file mode 100644
index 0000000..bb33f1c
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/playback/BreakVerdicts.kt
@@ -0,0 +1,33 @@
+package org.terst.listen.playback
+
+import org.terst.listen.data.BreakVerdictEntity
+import org.terst.listen.data.EpisodeMidrollEntity
+import kotlin.math.abs
+
+/**
+ * Matching a listener's verdict back to the break it was about, and acting on it.
+ */
+object BreakVerdicts {
+ const val VALID = "valid"
+ const val INVALID = "invalid"
+
+ /** How far each edge of a break may have moved between passes and still be the same break. */
+ const val TOLERANCE_MS = 2_000L
+
+ /** The verdict recorded for the break at [startMs]..[endMs], or null. */
+ fun matching(verdicts: List<BreakVerdictEntity>, startMs: Long, endMs: Long): BreakVerdictEntity? =
+ verdicts
+ .filter { abs(it.startMs - startMs) <= TOLERANCE_MS && abs(it.endMs - endMs) <= TOLERANCE_MS }
+ .minByOrNull { abs(it.startMs - startMs) + abs(it.endMs - endMs) }
+
+ /** The verdict for [range], or null when the listener has not judged it. */
+ fun forRange(verdicts: List<BreakVerdictEntity>, range: EpisodeMidrollEntity): BreakVerdictEntity? =
+ matching(verdicts, range.startMs, range.endMs)
+
+ /** [ranges] without the ones the listener marked wrong. */
+ fun withoutInvalidated(
+ ranges: List<EpisodeMidrollEntity>,
+ verdicts: List<BreakVerdictEntity>,
+ ): List<EpisodeMidrollEntity> =
+ if (verdicts.isEmpty()) ranges else ranges.filterNot { forRange(verdicts, it)?.verdict == INVALID }
+}
diff --git a/app/src/main/java/org/terst/listen/playback/MidrollSkip.kt b/app/src/main/java/org/terst/listen/playback/MidrollSkip.kt
index 32b2144..d5bbca3 100644
--- a/app/src/main/java/org/terst/listen/playback/MidrollSkip.kt
+++ b/app/src/main/java/org/terst/listen/playback/MidrollSkip.kt
@@ -1,5 +1,6 @@
package org.terst.listen.playback
+import org.terst.listen.data.BreakVerdictEntity
import org.terst.listen.data.EpisodeMidrollEntity
/**
@@ -22,6 +23,10 @@ object MidrollSkip {
/**
* Where to jump to if the playhead has entered a break that should be skipped.
*
+ * A break the listener marked wrong in [verdicts] is not skipped, whatever its kind: that is
+ * the correction channel, and it has to take effect on the next tick. Marking one right
+ * changes nothing -- it records agreement.
+ *
* Advertising is skipped whatever [skipFurniture] says. The show's own intro, break cue and
* credits are skipped only when asked for.
*/
@@ -29,7 +34,8 @@ object MidrollSkip {
ranges: List<EpisodeMidrollEntity>,
positionMs: Long,
skipFurniture: Boolean,
- ): Long? = activeBreak(ranges, positionMs, skipFurniture)?.endMs?.takeIf { it > positionMs }
+ verdicts: List<BreakVerdictEntity> = emptyList(),
+ ): Long? = activeBreak(ranges, positionMs, skipFurniture, verdicts)?.endMs?.takeIf { it > positionMs }
/**
* Whether the playhead is inside a break that should be acted on right now, without working
@@ -43,14 +49,16 @@ object MidrollSkip {
ranges: List<EpisodeMidrollEntity>,
positionMs: Long,
skipFurniture: Boolean,
- ): Boolean = activeBreak(ranges, positionMs, skipFurniture) != null
+ verdicts: List<BreakVerdictEntity> = emptyList(),
+ ): Boolean = activeBreak(ranges, positionMs, skipFurniture, verdicts) != null
private fun activeBreak(
ranges: List<EpisodeMidrollEntity>,
positionMs: Long,
skipFurniture: Boolean,
+ verdicts: List<BreakVerdictEntity>,
): EpisodeMidrollEntity? =
- ranges.asSequence()
+ BreakVerdicts.withoutInvalidated(ranges, verdicts).asSequence()
.filter { skipFurniture || !isShowsOwnAudio(it.kind) }
.firstOrNull { positionMs >= it.startMs && positionMs < it.endMs }
diff --git a/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt b/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt
index 50f172c..9007c51 100644
--- a/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt
+++ b/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt
@@ -1,5 +1,6 @@
package org.terst.listen.playback
+import org.terst.listen.data.BreakVerdictEntity
import org.terst.listen.data.EpisodeDao
import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.EpisodeMidrollEntity
@@ -21,6 +22,8 @@ data class PlayingContext(
val skipFurniture: Boolean = true,
/** Whether a break that is due to be acted on is played fast instead of being skipped. */
val fastPlayBreaks: Boolean = false,
+ /** What the listener has said about this episode's breaks -- see [BreakVerdicts]. */
+ val verdicts: List<BreakVerdictEntity> = emptyList(),
) {
/** The episode's normal speed: a rule's choice, else the show's, else the global default. */
val speed: Float get() = PlaybackSpeed.forEpisode(episode?.speedOverride, show?.playbackSpeed)
@@ -38,6 +41,9 @@ interface PlaybackSources {
suspend fun midrolls(episode: EpisodeEntity): List<EpisodeMidrollEntity>
+ /** The listener's verdicts on this episode's breaks, re-read as they are given. */
+ suspend fun verdicts(episode: EpisodeEntity): List<BreakVerdictEntity> = emptyList()
+
suspend fun fetchAudio(episode: EpisodeEntity, kind: SubscriptionKind): Result<Unit>
/** Whether a show's own intro, cue and credits are skipped as well as the advertising. */
@@ -131,10 +137,19 @@ class PlaybackAdvancer(
sources.midrolls(episode),
sources.skipFurniture(),
sources.fastPlayBreaks(),
+ sources.verdicts(episode),
)
}
/**
+ * The same context with the listener's current verdicts, for the ticker to call before each
+ * decision. Verdicts are the one input here that changes while an episode plays -- marking a
+ * break wrong has to stop it being skipped from the next tick, not the next episode.
+ */
+ suspend fun withFreshVerdicts(context: PlayingContext): PlayingContext =
+ context.episode?.let { context.copy(verdicts = sources.verdicts(it)) } ?: context
+
+ /**
* Records an episode as finished and reports what plays next, or null if nothing does.
*
* Marking played happens before advancing: advance rewrites the queue, and the other order
diff --git a/app/src/main/java/org/terst/listen/playback/PlaybackService.kt b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
index ef4be31..603ec5b 100644
--- a/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
+++ b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
@@ -195,8 +195,19 @@ class PlaybackService : MediaLibraryService() {
*/
private suspend fun applyPlayheadPolicy() {
val exo = player ?: return
+ // Re-read every tick, unlike the rest of the context: a break marked wrong has to stop
+ // being skipped from the next tick, and this is a tiny indexed read of a handful of rows.
+ // Used for this decision only, never stored back over `context`, which a fast episode
+ // switch may have replaced while the read was in flight.
+ val ticking = try {
+ advancer?.withFreshVerdicts(context) ?: context
+ } catch (e: android.database.sqlite.SQLiteException) {
+ // A failed read must not stop the ticker; the last known verdicts still apply.
+ android.util.Log.w(TAG, "verdicts unreadable this tick", e)
+ context
+ }
val action = PlayheadPolicy.decide(
- context = context,
+ context = ticking,
positionMs = exo.currentPosition,
durationMs = exo.duration.takeIf { it > 0 },
progress = progress,
diff --git a/app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt b/app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt
index f814034..0fac437 100644
--- a/app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt
+++ b/app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt
@@ -73,9 +73,10 @@ object PlayheadPolicy {
private fun breakAction(context: PlayingContext, positionMs: Long): TickAction? =
if (context.fastPlayBreaks) {
TickAction.SetSpeed(FAST_BREAK_SPEED)
- .takeIf { MidrollSkip.isActedOnBreak(context.midrolls, positionMs, context.skipFurniture) }
+ .takeIf { MidrollSkip.isActedOnBreak(context.midrolls, positionMs, context.skipFurniture, context.verdicts) }
} else {
- MidrollSkip.skipTarget(context.midrolls, positionMs, context.skipFurniture)?.let(TickAction::SeekTo)
+ MidrollSkip.skipTarget(context.midrolls, positionMs, context.skipFurniture, context.verdicts)
+ ?.let(TickAction::SeekTo)
}
private fun chapterAction(
diff --git a/app/src/main/java/org/terst/listen/playback/VerdictRecorder.kt b/app/src/main/java/org/terst/listen/playback/VerdictRecorder.kt
new file mode 100644
index 0000000..d89762f
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/playback/VerdictRecorder.kt
@@ -0,0 +1,33 @@
+package org.terst.listen.playback
+
+import org.terst.listen.data.BreakVerdictDao
+import org.terst.listen.data.BreakVerdictEntity
+import org.terst.listen.data.EpisodeMidrollEntity
+
+/**
+ * Writes down a verdict.
+ *
+ * Replaces whatever verdict already matches the break rather than adding a second: boundaries
+ * drift between passes, so changing your mind about a break whose edges have since moved would
+ * otherwise leave two rows disagreeing about one stretch of audio.
+ */
+class VerdictRecorder(
+ private val dao: BreakVerdictDao,
+ private val now: () -> Long = System::currentTimeMillis,
+) {
+ suspend fun record(range: EpisodeMidrollEntity, verdict: String) {
+ BreakVerdicts.forRange(dao.getForEpisode(range.episodeId), range)
+ ?.let { dao.delete(it.episodeId, it.startMs, it.endMs) }
+ dao.upsert(
+ BreakVerdictEntity(
+ episodeId = range.episodeId,
+ startMs = range.startMs,
+ endMs = range.endMs,
+ verdict = verdict,
+ decidedAtMs = now(),
+ kind = range.kind,
+ partners = range.partners,
+ )
+ )
+ }
+}
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 608521a..bb8d2e1 100644
--- a/app/src/main/java/org/terst/listen/server/ServerSkips.kt
+++ b/app/src/main/java/org/terst/listen/server/ServerSkips.kt
@@ -87,6 +87,7 @@ class ServerSkips(
startMs = range.startMs,
endMs = range.endMs,
kind = range.kind,
+ partners = range.partners,
)
)
}
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 990b758..4872ab9 100644
--- a/app/src/main/java/org/terst/listen/server/SkipServer.kt
+++ b/app/src/main/java/org/terst/listen/server/SkipServer.kt
@@ -59,6 +59,8 @@ data class ServerBreak(
@SerialName("startMs") val startMs: Long = 0,
@SerialName("endMs") val endMs: Long = 0,
val kind: String = "ad",
+ /** How many other episodes carried this audio; 0 when the server does not say. */
+ val partners: Int = 0,
)
/** What the server knows about one episode. */
diff --git a/app/src/main/java/org/terst/listen/ui/BreakJudging.kt b/app/src/main/java/org/terst/listen/ui/BreakJudging.kt
new file mode 100644
index 0000000..bc9c802
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/BreakJudging.kt
@@ -0,0 +1,115 @@
+package org.terst.listen.ui
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.MaterialTheme
+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.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+import org.terst.listen.data.BreakVerdictEntity
+import org.terst.listen.data.EpisodeMidrollEntity
+import org.terst.listen.data.AppGraph
+import org.terst.listen.fingerprint.BreakPopup
+import org.terst.listen.fingerprint.SkipSummary
+import org.terst.listen.playback.BreakVerdicts
+
+/**
+ * What the break popup needs beyond the break itself: what has already been said about this
+ * episode's breaks, whether the show's own audio is being skipped, and how to say something.
+ */
+class BreakJudging(
+ val verdicts: List<BreakVerdictEntity>,
+ val skipFurniture: Boolean,
+ val judge: (SkipSummary.BreakMarker, String) -> Unit,
+) {
+ companion object {
+ val NONE = BreakJudging(emptyList(), true) { _, _ -> }
+ }
+}
+
+/**
+ * What the break popup reads and writes, apart from [ListenViewModel] so that class does not grow
+ * a function per feature.
+ */
+class BreakJudgingSource(private val graph: AppGraph) {
+ /** What the listener has said about this episode's detected breaks. */
+ suspend fun verdicts(episodeId: String) = graph.breakVerdicts.getForEpisode(episodeId)
+
+ /** Records "that break is right" or "that break is wrong"; see [BreakVerdicts]. */
+ suspend fun record(range: EpisodeMidrollEntity, verdict: String) = graph.verdictRecorder.record(range, verdict)
+
+ /** Whether the show's own intros, cues and credits are skipped, which the popup reports. */
+ val skipShowFurniture = graph.preferences.skipShowFurniture
+}
+
+/**
+ * Loads an episode's verdicts and records new ones. The recording is a plain database write the
+ * playback service re-reads every tick, so nothing here has to tell the player anything.
+ */
+@Composable
+internal fun rememberBreakJudging(
+ vm: ListenViewModel,
+ episodeId: String,
+ ranges: List<EpisodeMidrollEntity>,
+): BreakJudging {
+ var verdicts by remember { mutableStateOf(emptyList<BreakVerdictEntity>()) }
+ val skipFurniture by vm.breakJudging.skipShowFurniture.collectAsState(initial = true)
+ val scope = rememberCoroutineScope()
+ LaunchedEffect(episodeId) { verdicts = vm.breakJudging.verdicts(episodeId) }
+ return BreakJudging(verdicts, skipFurniture) { mark, verdict ->
+ // The stored range this mark was drawn from, not a rebuilt one: the verdict carries the
+ // detector's own kind and partner count as evidence.
+ val range = ranges.firstOrNull { it.startMs == mark.startMs && it.endMs == mark.endMs }
+ if (range != null) {
+ scope.launch {
+ vm.breakJudging.record(range, verdict)
+ verdicts = vm.breakJudging.verdicts(episodeId)
+ }
+ }
+ }
+}
+
+/** The popup a tap on a break opens. Every word in it comes from [BreakPopup]. */
+@Composable
+internal fun BreakDialog(
+ mark: SkipSummary.BreakMarker,
+ judging: BreakJudging,
+ onSeek: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val verdict = BreakVerdicts.matching(judging.verdicts, mark.startMs, mark.endMs)?.verdict
+ val content = BreakPopup.content(mark, judging.skipFurniture, verdict)
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(content.title) },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(content.timing, style = MaterialTheme.typography.bodyMedium)
+ Text(content.why, style = MaterialTheme.typography.bodyMedium)
+ Text(content.willSkip, style = MaterialTheme.typography.bodyMedium)
+ content.verdict?.let { Text(it, style = MaterialTheme.typography.titleSmall) }
+ DialogAction("Seek here") { onSeek(); onDismiss() }
+ DialogAction(content.validateLabel) { judging.judge(mark, BreakVerdicts.VALID) }
+ DialogAction(content.invalidateLabel) { judging.judge(mark, BreakVerdicts.INVALID) }
+ }
+ },
+ confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } },
+ )
+}
+
+@Composable
+private fun DialogAction(label: String, onClick: () -> Unit) {
+ TextButton(onClick = onClick, modifier = Modifier.fillMaxWidth()) { Text(label) }
+}
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 ac0b99f..e9c0db8 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt
@@ -73,6 +73,7 @@ fun EpisodeScreen(
}
val scope = rememberCoroutineScope()
val rules by vm.rules.collectAsState()
+ val judging = rememberBreakJudging(vm, episodeId, ranges)
val nowPlaying by player.nowPlaying.collectAsState()
val showTitle = vm.subscriptions.collectAsState().value
@@ -113,7 +114,7 @@ fun EpisodeScreen(
ranges = ranges,
nowPlaying = nowPlaying,
speed = speed,
- ),
+ ).withJudging(judging),
modifier = Modifier.padding(padding),
actions = episodeActions(vm, player, current, nowPlaying, hooks),
)
@@ -281,6 +282,10 @@ private fun episodeDetailState(
)
}
+/** The same state with the break popup's verdicts and actions filled in. */
+private fun EpisodeDetailState.withJudging(judging: BreakJudging) =
+ copy(playback = playback.copy(judging = judging))
+
/** What the episode screen's transport controls act on. */
data class EpisodePlayback(
val positionMs: Long,
@@ -288,6 +293,8 @@ data class EpisodePlayback(
val isPlaying: Boolean,
/** Detected ad breaks as fractions of the episode -- see [SkipSummary.breakMarkers]. */
val breaks: List<SkipSummary.BreakMarker>,
+ /** What the break popup shows and does -- see [BreakJudging]. */
+ val judging: BreakJudging = BreakJudging.NONE,
)
/**
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeTimeline.kt b/app/src/main/java/org/terst/listen/ui/EpisodeTimeline.kt
index 87ccfac..cd8c4b6 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeTimeline.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeTimeline.kt
@@ -59,7 +59,7 @@ fun EpisodeScrubber(playback: EpisodePlayback, actions: EpisodeDetailActions) {
enabled = duration > 0,
)
}
- BreakMarks(playback.breaks, duration) { actions.seekTo(it) }
+ BreakMarks(playback.breaks, duration, playback.judging) { actions.seekTo(it) }
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(formatClock(shown.toLong()), style = MaterialTheme.typography.bodySmall)
Text(formatClock(duration), style = MaterialTheme.typography.bodySmall)
@@ -75,14 +75,21 @@ fun EpisodeScrubber(playback: EpisodePlayback, actions: EpisodeDetailActions) {
* not silently invert which colour means advertising. A kind this version does not recognise
* draws as advertising too, matching what playback does with it.
*
- * Tapping a band names it and seeks to its start. The strip is its own, taller target rather than
- * a hairline behind the slider, because a break is a few pixels wide and a finger is not; what a
- * tap hits is decided by [BreakTrack.markAt], so the composable only converts pixels.
+ * Tapping a band names it and opens [BreakDialog], whose "seek here" is the old tap behaviour.
+ * The strip is its own, taller target rather than a hairline behind the slider, because a break
+ * is a few pixels wide and a finger is not; what a tap hits is decided by [BreakTrack.markAt],
+ * so the composable only converts pixels.
*/
@Composable
-private fun BreakMarks(breaks: List<SkipSummary.BreakMarker>, durationMs: Long, onSeek: (Long) -> Unit) {
+private fun BreakMarks(
+ breaks: List<SkipSummary.BreakMarker>,
+ durationMs: Long,
+ judging: BreakJudging,
+ onSeek: (Long) -> Unit,
+) {
if (breaks.isEmpty()) return
var picked by remember(breaks) { mutableStateOf<SkipSummary.BreakMarker?>(null) }
+ var open by remember { mutableStateOf(false) }
val adColour = MaterialTheme.colorScheme.tertiary
val furnitureColour = MaterialTheme.colorScheme.secondary
val trackColour = MaterialTheme.colorScheme.surfaceVariant
@@ -101,7 +108,7 @@ private fun BreakMarks(breaks: List<SkipSummary.BreakMarker>, durationMs: Long,
slop = TAP_SLOP_PX / usable,
)
picked = hit
- hit?.let { onSeek(BreakTrack.markStartMs(it, durationMs)) }
+ open = hit != null
}
},
) {
@@ -122,6 +129,9 @@ private fun BreakMarks(breaks: List<SkipSummary.BreakMarker>, durationMs: Long,
}
picked?.let {
Text(BreakTrack.describeMark(it, durationMs), style = MaterialTheme.typography.bodySmall)
+ if (open) {
+ BreakDialog(it, judging, { onSeek(BreakTrack.markStartMs(it, durationMs)) }) { open = false }
+ }
}
}
diff --git a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
index 4365729..3115c32 100644
--- a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
@@ -86,6 +86,9 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) {
/** The ad breaks detection has confirmed in one episode, for the episode screen's readout. */
suspend fun midrollRanges(episodeId: String) = graph.midrolls.getRanges(episodeId)
+ /** The break popup's reads and writes, kept off this class -- see [BreakJudgingSource]. */
+ val breakJudging = BreakJudgingSource(graph)
+
/**
* Moves where an episode will start from, for the episode screen's scrubber.
*
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 9515c3d..8f28795 100644
--- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt
+++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
@@ -405,3 +405,21 @@ class FakeQueueDao : QueueDao {
override suspend fun clear() = entries.clear()
override suspend fun size() = entries.size
}
+
+class FakeBreakVerdictDao : BreakVerdictDao {
+ val rows = mutableListOf<BreakVerdictEntity>()
+
+ override suspend fun getForEpisode(episodeId: String) =
+ rows.filter { it.episodeId == episodeId }.sortedBy { it.startMs }
+
+ override suspend fun upsert(verdict: BreakVerdictEntity) {
+ rows.removeAll {
+ it.episodeId == verdict.episodeId && it.startMs == verdict.startMs && it.endMs == verdict.endMs
+ }
+ rows += verdict
+ }
+
+ override suspend fun delete(episodeId: String, startMs: Long, endMs: Long) {
+ rows.removeAll { it.episodeId == episodeId && it.startMs == startMs && it.endMs == endMs }
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/fingerprint/BreakPopupTest.kt b/app/src/test/java/org/terst/listen/fingerprint/BreakPopupTest.kt
new file mode 100644
index 0000000..a845d27
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/fingerprint/BreakPopupTest.kt
@@ -0,0 +1,138 @@
+package org.terst.listen.fingerprint
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.terst.listen.playback.BreakVerdicts
+
+/**
+ * The wording of the popup a tap on a break opens. Pinned like [SkipSummary]'s, because the one
+ * line he most needs -- will this actually be skipped -- is the one the app never used to say.
+ */
+class BreakPopupTest {
+
+ private fun mark(kind: String, partners: Int = 0, startMs: Long = 720_000, endMs: Long = 860_000) =
+ SkipSummary.BreakMarker(0.4f, 0.5f, kind, startMs, endMs, partners)
+
+ private fun content(
+ kind: String,
+ partners: Int = 0,
+ skipFurniture: Boolean = true,
+ verdict: String? = null,
+ ) = BreakPopup.content(mark(kind, partners), skipFurniture, verdict)
+
+ @Test
+ fun `the title is the pinned wording for the kind`() {
+ assertEquals("Ad break", content("ad").title)
+ assertEquals("Show intro", content("intro").title)
+ assertEquals("Break cue", content("cue").title)
+ assertEquals("Credits", content("outro").title)
+ assertEquals("Ad break", content("something-new").title)
+ }
+
+ @Test
+ fun `it says when the break runs and for how long`() {
+ assertEquals("Runs 12:00 - 14:20, 2 min 20 s long.", content("ad").timing)
+ assertEquals(
+ "Runs 1:00 - 1:45, 45 s long.",
+ BreakPopup.content(mark("ad", startMs = 60_000, endMs = 105_000), true, null).timing,
+ )
+ assertEquals(
+ "Runs 10:00 - 12:00, 2 min long.",
+ BreakPopup.content(mark("ad", startMs = 600_000, endMs = 720_000), true, null).timing,
+ )
+ }
+
+ @Test
+ fun `an advert is explained by the other episodes that carried it`() {
+ assertEquals(
+ "The same audio is in 3 other episodes of this show, which is how inserted advertising looks.",
+ content("ad", partners = 3).why,
+ )
+ assertEquals(
+ "The same audio is in 1 other episode of this show, which is how inserted advertising looks.",
+ content("ad", partners = 1).why,
+ )
+ }
+
+ @Test
+ fun `the shows own audio is explained as recurring rather than as advertising`() {
+ assertEquals(
+ "The same audio is in 4 other episodes of this show, which is how a recurring intro looks.",
+ content("intro", partners = 4).why,
+ )
+ assertEquals(
+ "The same audio is in 4 other episodes of this show, which is how a recurring break cue looks.",
+ content("cue", partners = 4).why,
+ )
+ assertEquals(
+ "The same audio is in 4 other episodes of this show, which is how recurring credits look.",
+ content("outro", partners = 4).why,
+ )
+ }
+
+ /** 0 means the phone was never told, not that nothing else carried it -- it must not claim so. */
+ @Test
+ fun `an unknown count admits it rather than claiming there is no evidence`() {
+ val why = content("ad", partners = 0).why
+
+ assertEquals("The server did not say how many other episodes carried this audio.", why)
+ }
+
+ @Test
+ fun `advertising is always skipped`() {
+ val expected = "Will be skipped. Advertising always is."
+
+ assertEquals(expected, content("ad", skipFurniture = false).willSkip)
+ assertEquals(expected, content("ad", skipFurniture = true).willSkip)
+ assertEquals(expected, content("never-seen-kind", skipFurniture = false).willSkip)
+ }
+
+ @Test
+ fun `the shows own audio is skipped only when that is turned on`() {
+ assertEquals(
+ "Will be skipped, because skipping a show's own intros, cues and credits is on.",
+ content("intro", skipFurniture = true).willSkip,
+ )
+ assertEquals(
+ "Will not be skipped. It is the show's own audio, and skipping that is off.",
+ content("cue", skipFurniture = false).willSkip,
+ )
+ }
+
+ @Test
+ fun `a break marked wrong is not skipped whatever it is`() {
+ val expected = "Will not be skipped. You marked it as the programme."
+
+ assertEquals(expected, content("ad", verdict = BreakVerdicts.INVALID).willSkip)
+ assertEquals(expected, content("intro", skipFurniture = true, verdict = BreakVerdicts.INVALID).willSkip)
+ assertEquals(expected, content("intro", skipFurniture = false, verdict = BreakVerdicts.INVALID).willSkip)
+ }
+
+ @Test
+ fun `a break marked right is skipped exactly as before`() {
+ assertEquals(
+ content("ad").willSkip,
+ content("ad", verdict = BreakVerdicts.VALID).willSkip,
+ )
+ assertEquals(
+ content("intro", skipFurniture = false).willSkip,
+ content("intro", skipFurniture = false, verdict = BreakVerdicts.VALID).willSkip,
+ )
+ }
+
+ @Test
+ fun `the verdict line reports what was recorded`() {
+ assertNull(content("ad").verdict)
+ assertEquals("You confirmed this one.", content("ad", verdict = BreakVerdicts.VALID).verdict)
+ assertEquals("You marked this one wrong.", content("ad", verdict = BreakVerdicts.INVALID).verdict)
+ }
+
+ @Test
+ fun `the buttons say what each answer means for this kind of break`() {
+ assertEquals("Yes, this is advertising", content("ad").validateLabel)
+ assertEquals("Yes, this is the show's own audio", content("intro").validateLabel)
+ assertEquals("No, this is the programme", content("ad").invalidateLabel)
+ assertEquals("No, this is the programme", content("outro").invalidateLabel)
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/playback/BreakVerdictsTest.kt b/app/src/test/java/org/terst/listen/playback/BreakVerdictsTest.kt
new file mode 100644
index 0000000..8fd964b
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/playback/BreakVerdictsTest.kt
@@ -0,0 +1,210 @@
+package org.terst.listen.playback
+
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.terst.listen.data.BreakVerdictEntity
+import org.terst.listen.data.EpisodeMidrollEntity
+import org.terst.listen.data.FakeBreakVerdictDao
+import org.terst.listen.data.FakeMidrollDao
+import org.terst.listen.server.ServerBreak
+import org.terst.listen.server.ServerEpisode
+import org.terst.listen.server.ServerSkips
+import org.terst.listen.server.SkipServer
+
+/**
+ * A verdict is the most trustworthy label in the system -- a person confirmed it -- so the two
+ * ways it can be lost are pinned here: the server replacing the ranges, and the boundaries
+ * moving a little between passes.
+ */
+class BreakVerdictsTest {
+
+ private fun verdict(startMs: Long, endMs: Long, v: String = BreakVerdicts.INVALID, episode: String = "e1") =
+ BreakVerdictEntity(episode, startMs, endMs, v, decidedAtMs = 1L)
+
+ private fun range(startMs: Long, endMs: Long, kind: String = "ad") =
+ EpisodeMidrollEntity("e1", "server-0", startMs, endMs, kind)
+
+ @Test
+ fun `a verdict matches the same break exactly`() {
+ val v = verdict(60_000, 90_000)
+
+ assertEquals(v, BreakVerdicts.matching(listOf(v), 60_000, 90_000))
+ }
+
+ @Test
+ fun `a verdict still matches when both edges drift a little between passes`() {
+ val v = verdict(60_000, 90_000)
+
+ assertEquals(v, BreakVerdicts.matching(listOf(v), 61_500, 88_700))
+ assertEquals(v, BreakVerdicts.matching(listOf(v), 58_000, 92_000))
+ }
+
+ @Test
+ fun `a verdict does not match a genuinely different break`() {
+ val v = verdict(60_000, 90_000)
+
+ assertNull(BreakVerdicts.matching(listOf(v), 600_000, 630_000))
+ // Overlapping, but a different stretch: starts 10s later.
+ assertNull(BreakVerdicts.matching(listOf(v), 70_000, 90_000))
+ // Same start, runs far longer.
+ assertNull(BreakVerdicts.matching(listOf(v), 60_000, 150_000))
+ }
+
+ @Test
+ fun `just outside the tolerance is a different break`() {
+ val v = verdict(60_000, 90_000)
+
+ assertNull(BreakVerdicts.matching(listOf(v), 60_000 + BreakVerdicts.TOLERANCE_MS + 1, 90_000))
+ }
+
+ @Test
+ fun `where two verdicts could match the nearer one wins`() {
+ val near = verdict(60_500, 90_000)
+ val far = verdict(61_900, 90_000)
+
+ assertEquals(near, BreakVerdicts.matching(listOf(far, near), 60_000, 90_000))
+ }
+
+ @Test
+ fun `an invalidated range is dropped and a validated or unjudged one is kept`() {
+ val wrong = range(60_000, 90_000)
+ val right = range(300_000, 330_000)
+ val unjudged = range(600_000, 630_000)
+ val verdicts = listOf(
+ verdict(60_000, 90_000, BreakVerdicts.INVALID),
+ verdict(300_000, 330_000, BreakVerdicts.VALID),
+ )
+
+ assertEquals(listOf(right, unjudged), BreakVerdicts.withoutInvalidated(listOf(wrong, right, unjudged), verdicts))
+ }
+
+ @Test
+ fun `an invalidated range whose edges have shifted is still dropped`() {
+ val shifted = range(61_000, 89_000)
+
+ assertEquals(
+ emptyList<EpisodeMidrollEntity>(),
+ BreakVerdicts.withoutInvalidated(listOf(shifted), listOf(verdict(60_000, 90_000))),
+ )
+ }
+
+ // --- acting on it at playback -------------------------------------------------------------
+
+ @Test
+ fun `an invalidated advert is no longer skipped`() {
+ val ranges = listOf(range(60_000, 90_000))
+ val wrong = listOf(verdict(60_000, 90_000, BreakVerdicts.INVALID))
+
+ assertNull(MidrollSkip.skipTarget(ranges, 70_000, skipFurniture = true, verdicts = wrong))
+ assertEquals(false, MidrollSkip.isActedOnBreak(ranges, 70_000, skipFurniture = true, verdicts = wrong))
+ }
+
+ @Test
+ fun `a validated advert is still skipped, exactly as an unjudged one`() {
+ val ranges = listOf(range(60_000, 90_000))
+ val right = listOf(verdict(60_000, 90_000, BreakVerdicts.VALID))
+
+ assertEquals(90_000L, MidrollSkip.skipTarget(ranges, 70_000, skipFurniture = false, verdicts = right))
+ assertEquals(90_000L, MidrollSkip.skipTarget(ranges, 70_000, skipFurniture = false))
+ }
+
+ @Test
+ fun `a verdict on one break leaves the others skipped`() {
+ val ranges = listOf(range(60_000, 90_000), range(300_000, 330_000))
+ val wrong = listOf(verdict(60_000, 90_000, BreakVerdicts.INVALID))
+
+ assertEquals(330_000L, MidrollSkip.skipTarget(ranges, 310_000, skipFurniture = false, verdicts = wrong))
+ }
+
+ @Test
+ fun `invalidating the shows own audio with furniture skipping off changes nothing`() {
+ val intro = listOf(range(0, 98_000, "intro"))
+ val wrong = listOf(verdict(0, 98_000, BreakVerdicts.INVALID))
+
+ assertNull(MidrollSkip.skipTarget(intro, 10_000, skipFurniture = false))
+ assertNull(MidrollSkip.skipTarget(intro, 10_000, skipFurniture = false, verdicts = wrong))
+ }
+
+ @Test
+ fun `invalidating the shows own audio with furniture skipping on stops the skip`() {
+ val intro = listOf(range(0, 98_000, "intro"))
+ val wrong = listOf(verdict(0, 98_000, BreakVerdicts.INVALID))
+
+ assertEquals(98_000L, MidrollSkip.skipTarget(intro, 10_000, skipFurniture = true))
+ assertNull(MidrollSkip.skipTarget(intro, 10_000, skipFurniture = true, verdicts = wrong))
+ }
+
+ // --- recording ----------------------------------------------------------------------------
+
+ @Test
+ fun `recording stores the verdict with the position, the time and what the detector claimed`() = runTest {
+ val dao = FakeBreakVerdictDao()
+
+ VerdictRecorder(dao) { 777L }.record(
+ EpisodeMidrollEntity("e1", "server-0", 60_000, 90_000, "cue", partners = 4),
+ BreakVerdicts.INVALID,
+ )
+
+ assertEquals(
+ listOf(BreakVerdictEntity("e1", 60_000, 90_000, BreakVerdicts.INVALID, 777L, "cue", 4)),
+ dao.rows,
+ )
+ }
+
+ @Test
+ fun `changing your mind replaces the verdict rather than keeping both`() = runTest {
+ val dao = FakeBreakVerdictDao()
+ val recorder = VerdictRecorder(dao) { 5L }
+
+ recorder.record(range(60_000, 90_000), BreakVerdicts.INVALID)
+ // The server has since moved the edges a little; it is still the same break.
+ recorder.record(range(61_000, 91_000), BreakVerdicts.VALID)
+
+ assertEquals(1, dao.rows.size)
+ assertEquals(BreakVerdicts.VALID, dao.rows.single().verdict)
+ }
+
+ // --- surviving a refresh ------------------------------------------------------------------
+
+ @Test
+ fun `a verdict survives the server replacing the episodes ranges`() = runTest {
+ val verdicts = FakeBreakVerdictDao()
+ val midrolls = FakeMidrollDao()
+ val skips = ServerSkips(
+ SkipServer(
+ { null },
+ object : org.terst.listen.server.SkipTransport {
+ override suspend fun get(url: String, token: String) = org.terst.listen.server.SkipReply.NoAnswer
+ override suspend fun post(url: String, token: String, body: String) =
+ org.terst.listen.server.SkipReply.NoAnswer
+ },
+ ),
+ org.terst.listen.data.FakeSubscriptionDao(),
+ org.terst.listen.data.FakeShowFingerprintDao(),
+ org.terst.listen.data.FakePlaybackStateDao(mutableListOf()),
+ midrolls,
+ )
+ val episode = org.terst.listen.data.EpisodeEntity(
+ id = "e1", subscriptionId = "s1", guid = "g", title = "t", mediaUrl = "u",
+ mediaType = "audio/mpeg", discoveredAtMs = 0,
+ )
+ fun answer(start: Long, end: Long) = ServerEpisode(
+ ready = true, audioUrl = "x", durationMs = null, introMs = null, outroMs = null,
+ detected = true, breaks = listOf(ServerBreak(start, end, "ad", partners = 3)),
+ )
+
+ skips.record(episode, answer(60_000, 90_000))
+ VerdictRecorder(verdicts) { 1L }.record(midrolls.rangeRows.single(), BreakVerdicts.INVALID)
+ // The next refresh: every range deleted and re-inserted, edges slightly moved.
+ skips.record(episode, answer(60_800, 89_400))
+
+ val kept = BreakVerdicts.matching(verdicts.getForEpisode("e1"), 60_800, 89_400)
+ assertEquals(BreakVerdicts.INVALID, kept?.verdict)
+ assertEquals(
+ emptyList<EpisodeMidrollEntity>(),
+ BreakVerdicts.withoutInvalidated(midrolls.getRanges("e1"), verdicts.getForEpisode("e1")),
+ )
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt b/app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt
index 4e3215e..eba2629 100644
--- a/app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt
+++ b/app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt
@@ -6,6 +6,7 @@ import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
+import org.terst.listen.data.BreakVerdictEntity
import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.EpisodeMidrollEntity
import org.terst.listen.data.FakeEpisodeDao
@@ -98,7 +99,10 @@ class PlaybackAdvancerTest {
val onFetch: suspend (EpisodeEntity) -> Result<Unit> = { Result.success(Unit) },
val chapters: List<Chapter> = emptyList(),
val onChapters: () -> Unit = {},
+ val verdictRows: () -> List<BreakVerdictEntity> = { emptyList() },
) : PlaybackSources {
+ override suspend fun verdicts(episode: EpisodeEntity) = verdictRows()
+
override suspend fun chapters(episode: EpisodeEntity): List<Chapter> {
onChapters()
return chapters
@@ -117,6 +121,34 @@ class PlaybackAdvancerTest {
/** Unwraps the common case so the assertions stay about the thing under test. */
private fun StartOutcome.ready() = (this as? StartOutcome.Ready)?.next
+ // --- verdicts ---------------------------------------------------------------------------
+
+ /**
+ * Marking a break wrong has to stop it being skipped from the next tick, and the context is
+ * read once per episode -- so the ticker asks for the verdicts afresh each time.
+ */
+ @Test
+ fun `fresh verdicts are read each time rather than once per episode`() = runTest {
+ show()
+ val ep = episode("a")
+ var rows = emptyList<BreakVerdictEntity>()
+ val live = PlaybackAdvancer(episodes, subscriptions, queue, FakeSources(verdictRows = { rows })) { now }
+ val context = live.contextFor("a")
+ assertEquals(emptyList<BreakVerdictEntity>(), context.verdicts)
+
+ rows = listOf(BreakVerdictEntity(ep.id, 60_000, 90_000, BreakVerdicts.INVALID, 1L))
+
+ assertEquals(rows, live.withFreshVerdicts(context).verdicts)
+ assertEquals("everything else is left alone", context.episode, live.withFreshVerdicts(context).episode)
+ }
+
+ @Test
+ fun `with nothing playing there are no verdicts to fetch`() = runTest {
+ val live = PlaybackAdvancer(episodes, subscriptions, queue, FakeSources(verdictRows = { error("asked") })) { now }
+
+ assertEquals(PlayingContext(), live.withFreshVerdicts(PlayingContext()))
+ }
+
// --- finishing --------------------------------------------------------------------------
@Test
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 56c0bf0..aa651af 100644
--- a/app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt
+++ b/app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt
@@ -185,6 +185,28 @@ class ServerSkipsTest {
}
/**
+ * The evidence behind a break is what makes it worth opening: "the same audio turned up in
+ * three other episodes" is a reason, and a bare range is not.
+ */
+ @Test
+ fun `keeps how many other episodes carried each break`() = runTest {
+ val midrolls = FakeMidrollDao()
+
+ subject(StubTransport(null), midrolls = midrolls).record(
+ episode,
+ ServerEpisode(
+ ready = true, audioUrl = "x", durationMs = null, introMs = null, outroMs = null,
+ detected = true,
+ breaks = listOf(ServerBreak(0, 60_000, partners = 5), ServerBreak(900_000, 960_000)),
+ ),
+ )
+
+ val ranges = midrolls.rangeRows.sortedBy { it.startMs }
+ assertEquals(5, ranges[0].partners)
+ assertEquals("a server that does not say reads as unknown", 0, ranges[1].partners)
+ }
+
+ /**
* The server is the authority on an episode's breaks, so a fresh answer replaces the old
* one. Left to accumulate, a campaign rotating between fetches would leave the phone
* skipping stretches that are no longer advertising.
diff --git a/app/src/test/java/org/terst/listen/server/SkipServerTest.kt b/app/src/test/java/org/terst/listen/server/SkipServerTest.kt
index c54ed35..aa0fd06 100644
--- a/app/src/test/java/org/terst/listen/server/SkipServerTest.kt
+++ b/app/src/test/java/org/terst/listen/server/SkipServerTest.kt
@@ -298,6 +298,19 @@ class SkipServerTest {
}
@Test
+ fun `reads how many other episodes carried a break`() = runTest {
+ val transport = FakeTransport().apply {
+ response = """{"status":"ready","audioUrl":"/audio/abc","breaks":[
+ {"startMs":0,"endMs":60000,"kind":"ad","partners":7},{"startMs":90000,"endMs":99000}]}"""
+ }
+
+ val got = server(transport).episode("https://f", "g1", "", "")
+
+ assertEquals(7, got?.breaks?.get(0)?.partners)
+ assertEquals(0, got?.breaks?.get(1)?.partners)
+ }
+
+ @Test
fun `an episode with no breaks reports an empty list rather than null`() = runTest {
val transport = FakeTransport().apply { response = """{"status":"ready","audioUrl":"/audio/abc"}""" }
diff --git a/docs/superpowers/plans/2026-10-20-corrections-and-signature-library.md b/docs/superpowers/plans/2026-10-20-corrections-and-signature-library.md
new file mode 100644
index 0000000..6dea531
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-20-corrections-and-signature-library.md
@@ -0,0 +1,263 @@
+# Human corrections, then a signature library — plan and handover
+
+Written 2026-10-20, immediately before a context compaction. Assume the next session knows
+nothing. `CLAUDE.md` holds the standing rules; this holds where the work is and why.
+
+**Spec this argues from:** `docs/superpowers/specs/2026-10-08-furniture-and-cues-design.md`
+(furniture/ad distinction, partly built) and the design published at
+https://claude.ai/artifact/BJmBFVrohrJo1hB6vYSz2Y (signature library, nothing built).
+
+---
+
+## Where things stand
+
+**Branch `main`, clean at `f087b8d`.** 771 unit tests. Room schema **14**. APK published at
+https://static.terst.org/files/listen-debug.apk built from `f087b8d`.
+
+**Server healthy**, `listend.service` on tungsten, 43 shows, 335 mirrored episodes, 501 breaks.
+Up since 2026-10-17, zero restarts.
+
+### Shipped since the break-kinds plan
+
+| commit | what |
+|---|---|
+| `6c4d40e` | Refresh skips on feed refresh; `audioFromServer` provenance so server timestamps never reach publisher audio |
+| `203e8a6` | That sweep bounded to 6 concurrent instead of several hundred sequential |
+| `bd2a8ba` | Downloads **wait** for the server's copy instead of silently taking the publisher's; `WAITING_FOR_SERVER`; explicit publisher action |
+| `999759f` | A rejected token (401/403) is permanent, not a reason to wait; notice in Settings |
+| `d96c876` | Skip readout becomes three numbers plus tappable marks; the 37-row timestamp list deleted |
+| `2329c92` | **Episode screen becomes the player**; `PlayerScreen.kt` deleted; six buttons to one plus overflow |
+| `f087b8d` | Show name in the top bar, episode in the body; chapters fetched lazily |
+
+### In flight when this was written
+
+One subagent building the **tap-a-mark popup** (see next section). It will land a commit and a
+schema 14 → 15 migration adding `partners`, plus a verdict table. If the tree is dirty or an
+unexpected commit is present, that is it.
+
+---
+
+## The sequence, and why it is in this order
+
+Peter's instruction, verbatim: *"let me tap the highlighted skips - give me a popup that has meta
+data and a way to explicitly validate or invalidate it"* — offered as the answer to the risk that
+a wrong signature-library entry would cut the same audio from every episode forever, silently.
+
+**That reordered the plan.** Corrections come before the library, not after it, because the labels
+are the most trustworthy signal in the system — a human confirmed them — and they are the only
+source of **negative** evidence. Nothing else can say "this is not an advertisement".
+
+### 1. The popup — IN FLIGHT
+
+Tapping a break mark opens metadata and a verdict. Detail in the dispatch; the parts that matter:
+
+- **Persist `partners`.** The server sends it on every break and the phone discards it. It is the
+ "why" the popup exists to show. Schema 14 → 15.
+- **Verdicts live in their own table**, keyed by `(episodeId, startMs, endMs)` with a tolerance.
+ They must survive `ServerSkips.recordBreaks`, which **deletes and re-inserts every range for an
+ episode** on each refresh — a verdict stored on `episode_midrolls` would be wiped.
+- **Invalidate takes effect on the next tick**, no refresh, no round trip.
+- **Verdicts stay local for now.** No endpoint, no consumer. Storing them from today means a
+ corpus exists by the time the library does.
+
+### 1b. "This is an ad" in the notification — NOT STARTED
+
+Peter, 2026-10-20: *"The notification currently has duplicate skip buttons (our custom ones plus
+forward/back) - there's UI space for explicit 'this is an ad' there."*
+
+**He is right about the duplication.** `ListenSessionCallback.onConnect` leaves
+`COMMAND_SEEK_BACK` and `COMMAND_SEEK_FORWARD` in the player commands (they are in
+`DEFAULT_PLAYER_COMMANDS`), which Media3's notification provider renders itself, and then
+`NotificationControls.customLayout()` publishes a second seek-back-15 / forward-25 pair on top.
+Reclaiming a slot means dropping one pair.
+
+**Do this on a device, carefully.** `ListenSessionCallback` carries a comment recording that
+removing commands to free notification slots once silently killed the headphone skip button.
+Whichever pair goes, verify: the notification, the lock screen, Bluetooth/headphone transport,
+and Android Auto. Note also that that comment currently contradicts its own code — it says "Only
+previous-media-item goes" while the code removes both previous and next. Worth resolving while
+in there.
+
+**Why this button matters more than the popup.** They catch opposite errors:
+
+| surface | catches | evidence |
+|---|---|---|
+| tap a mark → popup | **false positives** — a mark that should not be there | negative label |
+| notification button | **false negatives** — an advertisement nothing detected | positive label |
+
+False negatives are by far the bigger problem: the scan finds about **38%** of the advertising the
+feeds imply, so roughly three fifths of what he hears is undetected. The popup can only ever
+correct what was already found. And the notification is available at the one moment he actually
+notices — phone in pocket, advertisement playing, without unlocking anything.
+
+**What one press captures, and what to do with it.** A press is a *timestamp*, not a range, and a
+signature needs a range. Do not ask him to press twice. Record the position and let the server
+work out the extent: take a window back from the press (he presses partway in, and spots run
+roughly 15–60s, so something like `[t-45s, t+15s]`) and feed it to the existing
+`detect.MatchedRuns` machinery as a *seeded* search rather than a blind one. That is the same
+correlation already in use, pointed at a known-positive location instead of groping.
+
+**The press also skips, and what happens next is more signal.** Peter, 2026-10-20: *"Also skip -
+if I hit back immediately that might invalidate the signal or indicate I jumped past the end."*
+
+That turns one button into a complete labelling gesture. The rule, which needs no extra UI:
+
+| gesture | means | what it fixes |
+|---|---|---|
+| press | advertising starts at or before here | **start** of the range |
+| press again, still in the jump | still advertising | **extends** the range |
+| back, landing inside the jump | overshot | **end** of the range, at the landing point |
+| back, landing before the first press | that was not advertising | **invalidates** the whole thing |
+
+The two back cases are told apart by *where he lands*, not by guessing: inside the jumped span is
+a correction to the end, before the original press is a rejection. Both are unambiguous from the
+playhead alone.
+
+**How far to jump is an ergonomics question, and probably not a constant.**
+
+An earlier draft of this plan argued the jump must be a fixed size and deliberately unequal to the
+25s forward-seek, so that a back-press would be "unambiguous". Peter challenged it and it does not
+hold, in either half:
+
+- The system knows **which button was pressed**. A following back-press is read as a correction
+ because the last action was the ad button, not because the distance was distinctive. Equal
+ distances would create no ambiguity anywhere in the logic.
+- The listener is not computing anything either. He presses back until the programme resumes, and
+ the correction is read from **where he lands** against a span the system recorded exactly. That
+ works for any jump size; he never needs to know the number.
+
+What actually sets the size is the trade between **too small** (several presses to clear one pod)
+and **too large** (routinely landing in content, so he corrects constantly). It should sit near the
+typical *remaining* advertisement duration from a mid-advertisement press.
+
+And it need not be fixed: if a detected break is nearby, jump to its end — that position is already
+known. If the episode's declared-versus-served gap says minutes remain, that is informative too.
+The press means "get me past this advertisement", so the best jump is the best estimate available
+at that moment. Correction semantics are unaffected, because "landed inside the jumped span" holds
+for any span.
+
+The one real reason to keep the two buttons distinct is that *he* must not confuse them in his
+pocket, which is a matter of icon and label, not distance.
+
+**"Immediately" needs a window.** A back-press within roughly 15–20 seconds of wall clock counts
+as correction; later than that it is ordinary navigation and must not be read as a label. Store
+the window with the label so a future change to it does not retroactively reinterpret old data.
+
+This composes with his earlier manual-skip-logging idea: *"a series of skips with small gaps
+between them, sometimes followed by one or more skips back, is a pretty strong signal of an ad
+block."* A press and a skip-burst are two observations of the same event, and a skip *back* after
+over-shooting brackets the end precisely. Treat them as one input stream.
+
+### 2. Push verdicts to the server — NOT STARTED
+
+Phone to server. Needs an endpoint and server-side storage. Design note: a verdict is about a
+*range of a specific episode's audio*, so it is only meaningful for episodes whose bytes the
+server itself served — gate on `audioFromServer`, the same invariant the refresh sweep uses.
+
+### 3. The index spike — NOT STARTED, and it is the gate
+
+**Do this before anything else in the library, because it decides whether the library is
+possible.** A linear scan of N signatures against each episode does not survive: at ~0.33s per
+correlation, 500 entries × 8 episodes is over twenty minutes per show against a pass that
+currently takes seven. That is the same cliff the original intro search went over (199s per end,
+killed by WorkManager before persisting anything).
+
+Needs landmark hashing — spectral peak pairs, hashed with their time offset, inverted index. A
+true match produces many hashes agreeing on one offset; noise scatters.
+
+**The spike has a ready-made pass/fail test**, from the probe run 2026-10-20:
+
+- It must find the Up First repeat that scored **r = 0.877** (template: episode
+ `32b5b954c1e705c185f51829.mp3` at 847s for 60s; found in `99e85f679bcf64db8739cfda.mp3` at
+ 836s, and at r = 0.678 in `73ae08f072189cc954ea6b71.mp3`).
+- It must reject the non-matches that scored **0.06–0.13** (Tangle `7ad21fd746fe0d22440c9f6a.mp3`,
+ Serial `94f4ce3a94f5af2bf1b488aa.mp3`, Heavyweight `c7801ebf5c52825fbab680e8.mp3`).
+
+If it cannot do both, fast, the library stops there and nothing else is wasted.
+
+### 4. Store and age signatures — NOT STARTED
+
+Two kinds, which behave nothing alike:
+
+- **Cue** — a show's own furniture (break stinger, opening, credits). One show, permanent while
+ subscribed. Search Engine's is 8.0s and recurs 3–4 times an episode; Jon Stewart's ~5s at four
+ positions.
+- **Copy** — one advertisement. Any show, aged out after months unseen.
+
+Confidence is a count of sightings across **separate fetch occasions**, which is also the
+independence test the withheld intro/outro offsets have been waiting for. Never promote on one
+sighting.
+
+### 5. Shadow run — NOT STARTED
+
+Match against each batch, log what it *would* have cut, publish nothing. A week or two answers
+whether it finds advertising or accumulates junk, before one extra second is skipped.
+
+### 6. Publish matches, and unlock intro/outro — NOT STARTED
+
+Only once the shadow run has earned it.
+
+---
+
+## Measurements that must not be re-derived
+
+All in `docs/detection-findings.md` with full numbers. Re-running them costs hours.
+
+| finding | number |
+|---|---|
+| Ads found vs the declared length gap, current 12s floor | **~38%** median on shows with verified insertion |
+| Lowering the floor to 8s | LeVar 10:30 → 19:43, **Sleepy History 14:11 → 174:44** on a show with 0:48 of ads. **Do not lower it.** |
+| Filtering breaks by partner count or run length | Agreement drops 67% → 43%. **Disproven.** |
+| `MinRunSamples` and `MergeGapMs` | **Interact.** Low floor + 60s bridging welds noise into enormous spans. Never tune one alone. |
+| Ad repeats within a show | r = **0.877** |
+| Ad repeats across unrelated shows | r = 0.06–0.13 (two probes, both negative; one was a host-read spot that cannot repeat) |
+| Host-read ads (Darknet Diaries) | Never appear elsewhere. The library will not help such shows across shows, only across their own episodes over time. |
+| Episodes with no inserted ads at all | 138 of 322, and the app used to report "Ads inserted" on 111 of them. **Now 0.** |
+
+Peter's own domain knowledge, which has twice beaten my analysis: **ads come in ~15-second units,
+clustered in a few pods, with long stretches of content between.** That is what produced the 12s
+floor. And he reports ads repeat across podcasts within a network (NPR, Pushkin) — consistent with
+everything measured, though not directly caught in a probe.
+
+---
+
+## Traps that have already cost time
+
+- **Never run anything as root against `/site/listen.terst.org/data`.** An experiment did, left
+ `state.json` root-owned, and the service crash-looped **4,645 times over 13 hours** while
+ `systemctl is-active` reported "active" (it was the restart loop). Probe against a **copy** with
+ the audio dir symlinked, and check `/api/health`, not `is-active`.
+- **`gradle-test` reports a false FAIL for `connectedDebugAndroidTest`.** Line 70 scans
+ `*/test-results/*`; connected tests write to `app/build/outputs/androidTest-results/connected/`.
+ Parse that XML directly and check its mtime. **Unfixed in `/workspace/devtools`.**
+- **The emulator AVD is `doot_test_api36`.** There is no `feedreader_test` AVD. `adb` is at
+ `/opt/android-sdk/platform-tools/adb`, not on PATH.
+- **The pre-commit hook takes several minutes** (full build, tests, lint, detekt, coverage). Two
+ subagents have died mid-commit; one finished its work and never committed it. Always check
+ `git status` for orphaned work before assuming a failed agent did nothing.
+- **Post-hoc filtering cannot model a detector change.** Predicting the floor raise by filtering
+ already-detected breaks gave "31% → 34%"; the real answer was 30% → 23%. Measure by running the
+ detector, not by filtering its old output.
+
+---
+
+## Open, and genuinely Peter's
+
+- **Chapters** — settled by evidence, not asked: exactly one of his 61 shows publishes any, so
+ they live in the overflow.
+- **The queue screen** — asked, answered: *"Leave it."* It keeps its own screen.
+- **Still open: nothing from the UI proposal.** Both remaining questions were resolved — queue
+ stays its own screen ("Leave it"), chapters went to the overflow on evidence.
+- **Answered 2026-10-20:** the press skips as well as records, and the gesture that follows it
+ carries the rest of the label. See 1b.
+
+## Known gaps, deliberate
+
+- **Compose has no automated coverage** — no Robolectric. Everything visual is verified by eye on
+ the emulator only.
+- **The rejected-token notice is in-memory**, so after a process restart it reappears on the next
+ refresh rather than at launch.
+- **Library and Settings redesign (step 4 of the UI plan) not started.** Smallest wins; nothing
+ depends on it. Mockups in https://claude.ai/artifact/AnywWE7j7e96QUpXvF9yaW
+- **Intro/outro offsets still withheld.** They need sightings spanning more than one fetch
+ occasion — which step 4 of the library delivers.