summaryrefslogtreecommitdiff
path: root/app
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 /app
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
Diffstat (limited to 'app')
-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
27 files changed, 1822 insertions, 15 deletions
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"}""" }