From b3620e5f60ed8b590286dcf8a2b27919973d8743 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Mon, 28 Sep 2026 07:17:39 +0000 Subject: Bedtime shows, dimmed caught-up shows, a show menu, and skip visibility Four things Peter asked for, plus a crash found while verifying them. - Bedtime shows. A per-show flag (schema 8->9) that takes a show out of the library entirely; the moon icon in the top bar switches to listing only those. Not a category system -- the one grouping worth building is the one with behaviour attached, which is that entering bedtime mode also arms the sleep timer. BedtimePolicy owns the two cases that make that safe: it will not overwrite a timer set by hand, and leaving only cancels a timer bedtime itself armed. The mode is deliberately not persisted; a restart is morning. - Shows with nothing waiting are dimmed. Same condition the library sort already used -- nothing unplayed and unqueued -- now returned alongside it as SubscriptionListing.hasWaiting. That comes from a COUNT rather than the sort key on purpose: an episode with no publish date ranks at 0, which is right for ordering and would have greyed out a show with one sitting unheard. LibraryOrderTest pins that case. The lastError line keeps its error colour through the dimming, since a failing feed looks exactly like a feed with nothing new. - Per-show controls moved into an overflow menu and a settings sheet, and the tutorial prose is gone. Reaching the episodes used to mean scrolling past drip cadence and two paragraphs; Peter wrote the requirements and does not need them explained back. Unsubscribe went into the same menu, off the top bar where it sat one tap from Refresh. - Skip detection is visible at last, per show and per episode. SkipSummary is pure and its wording is tested, because the distinction that matters is confirmed-and-acting versus still-gathering, and "not checked yet" is a third state again -- midroll scanning runs nightly, so a fresh download legitimately has nothing to say yet. And the crash. Opening the new readout on a device threw SQLiteBlobTooBigException, which turned out not to be the readout's fault: show_fingerprints.pendingClipPcm held a whole decoded head/tail window -- 240s at 8kHz is 3.84MB as 16-bit PCM -- and Android reads every Room row through a CursorWindow that refuses anything over 2MB *on the way out*. The insert succeeded and the next read threw. Intro/outro detection therefore died the moment a show's second episode was fingerprinted and could never have worked as shipped; nothing off-device could have caught it. Migration 9->10 drops the column and keeps only pendingEpisodeId, re-decoding on demand -- exactly what midrollPendingEpisodeId already did for the same reason. StoredClip now owns the limit, MidrollUpdater caps a candidate segment at 60s before persisting it (a long sponsor block would hit the same wall) and refuses anything that still would not fit, and StoredClipSizeTest pins both against the limit so the class cannot come back quietly. Trimming under-skips a very long break rather than over-skipping into content, which is the safe direction. Verified on the emulator: 10 migration tests including the never-before-run 7->8, LibraryOrderTest, and the whole reported flow by hand -- bedtime split, timer arming and cancelling, the dimmed row, both readouts. Re-ran the exact sequence that crashed: three downloads, no crash, FingerprintWorker SUCCESS, and the MP3 decoder visibly running, which is the first time MediaCodecClipSource has executed on hardware at all. Still open, and recorded in CLAUDE.md rather than glossed: detection has not actually confirmed an intro or outro for a real show. Three Up First downloads left both ends "Nothing found yet". Possibly correct -- the threshold is strict by design -- or possibly the 8-second reference window against a roughly 6-second sting. That wants the desktop prototype, not a guess at a tuned constant. Coverage 91.76% -> 91.84%. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B --- .../org.terst.listen.data.AppDatabase/10.json | 747 ++++++++++++++++++++ .../org.terst.listen.data.AppDatabase/9.json | 752 +++++++++++++++++++++ .../java/org/terst/listen/data/LibraryOrderTest.kt | 62 +- .../java/org/terst/listen/data/MigrationTest.kt | 83 +++ .../main/java/org/terst/listen/data/AppDatabase.kt | 60 +- app/src/main/java/org/terst/listen/data/Daos.kt | 42 +- .../main/java/org/terst/listen/data/Entities.kt | 41 +- .../terst/listen/fingerprint/FingerprintUpdater.kt | 37 +- .../terst/listen/fingerprint/IntroOutroDetector.kt | 3 +- .../listen/fingerprint/MediaCodecClipSource.kt | 7 +- .../terst/listen/fingerprint/MidrollDetector.kt | 3 +- .../org/terst/listen/fingerprint/MidrollUpdater.kt | 50 +- .../org/terst/listen/fingerprint/SkipSummary.kt | 112 +++ .../org/terst/listen/fingerprint/StoredClip.kt | 32 + .../org/terst/listen/playback/BedtimePolicy.kt | 31 + .../main/java/org/terst/listen/ui/DripSettings.kt | 59 +- .../main/java/org/terst/listen/ui/EpisodeScreen.kt | 44 +- .../main/java/org/terst/listen/ui/LibraryScreen.kt | 125 +++- .../java/org/terst/listen/ui/ListenViewModel.kt | 34 +- .../java/org/terst/listen/ui/PlayerViewModel.kt | 28 + .../java/org/terst/listen/ui/RulesViewModel.kt | 2 +- .../main/java/org/terst/listen/ui/SettingRows.kt | 59 ++ .../java/org/terst/listen/ui/ShowSettingsSheet.kt | 109 +++ .../org/terst/listen/ui/ShowSettingsViewModel.kt | 50 ++ .../main/java/org/terst/listen/ui/SkipSettings.kt | 35 +- .../java/org/terst/listen/ui/SubscriptionScreen.kt | 101 ++- app/src/main/java/org/terst/listen/work/Workers.kt | 2 +- .../test/java/org/terst/listen/data/FakeDaos.kt | 12 +- .../terst/listen/data/ShowFingerprintEntityTest.kt | 15 +- .../org/terst/listen/data/StoredClipSizeTest.kt | 60 ++ .../listen/fingerprint/FingerprintUpdaterTest.kt | 2 +- .../terst/listen/fingerprint/SkipSummaryTest.kt | 209 ++++++ .../org/terst/listen/playback/BedtimePolicyTest.kt | 48 ++ 33 files changed, 2857 insertions(+), 199 deletions(-) create mode 100644 app/schemas/org.terst.listen.data.AppDatabase/10.json create mode 100644 app/schemas/org.terst.listen.data.AppDatabase/9.json create mode 100644 app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt create mode 100644 app/src/main/java/org/terst/listen/fingerprint/StoredClip.kt create mode 100644 app/src/main/java/org/terst/listen/playback/BedtimePolicy.kt create mode 100644 app/src/main/java/org/terst/listen/ui/SettingRows.kt create mode 100644 app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt create mode 100644 app/src/test/java/org/terst/listen/data/StoredClipSizeTest.kt create mode 100644 app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt create mode 100644 app/src/test/java/org/terst/listen/playback/BedtimePolicyTest.kt (limited to 'app') diff --git a/app/schemas/org.terst.listen.data.AppDatabase/10.json b/app/schemas/org.terst.listen.data.AppDatabase/10.json new file mode 100644 index 0000000..aacc2e7 --- /dev/null +++ b/app/schemas/org.terst.listen.data.AppDatabase/10.json @@ -0,0 +1,747 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "6b086b6c5300aace4b1950bd882c2de6", + "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, `downloadState` TEXT NOT NULL, `localPath` TEXT, `downloadedAtMs` INTEGER, `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": "downloadState", + "columnName": "downloadState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadedAtMs", + "columnName": "downloadedAtMs", + "affinity": "INTEGER" + }, + { + "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, `referenceClipPcm` BLOB 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": "referenceClipPcm", + "columnName": "referenceClipPcm", + "affinity": "BLOB", + "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, 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 + } + ], + "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" + ] + } + ] + } + ], + "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, '6b086b6c5300aace4b1950bd882c2de6')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/org.terst.listen.data.AppDatabase/9.json b/app/schemas/org.terst.listen.data.AppDatabase/9.json new file mode 100644 index 0000000..849e972 --- /dev/null +++ b/app/schemas/org.terst.listen.data.AppDatabase/9.json @@ -0,0 +1,752 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "2d882e6af98676065a505e077b27f730", + "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, `downloadState` TEXT NOT NULL, `localPath` TEXT, `downloadedAtMs` INTEGER, `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": "downloadState", + "columnName": "downloadState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadedAtMs", + "columnName": "downloadedAtMs", + "affinity": "INTEGER" + }, + { + "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, `pendingClipPcm` 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": "pendingClipPcm", + "columnName": "pendingClipPcm", + "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, `referenceClipPcm` BLOB 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": "referenceClipPcm", + "columnName": "referenceClipPcm", + "affinity": "BLOB", + "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, 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 + } + ], + "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" + ] + } + ] + } + ], + "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, '2d882e6af98676065a505e077b27f730')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt b/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt index 4117b79..8f2f7b6 100644 --- a/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt +++ b/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt @@ -44,11 +44,17 @@ class LibraryOrderTest { @After fun tearDown() = db.close() - private suspend fun show(id: String) = subscriptions.insert( - SubscriptionEntity(id = id, feedUrl = "https://example.com/$id", title = id, addedAtMs = 0) + private suspend fun show(id: String, bedtime: Boolean = false) = subscriptions.insert( + SubscriptionEntity( + id = id, + feedUrl = "https://example.com/$id", + title = id, + addedAtMs = 0, + bedtime = bedtime, + ) ) - private suspend fun episode(id: String, showId: String, publishedAtMs: Long) = + private suspend fun episode(id: String, showId: String, publishedAtMs: Long?) = episodes.insertAll( listOf( EpisodeEntity( @@ -64,7 +70,14 @@ class LibraryOrderTest { ) ) - private suspend fun order() = subscriptions.observeAll().first().map { it.id } + private suspend fun listing(bedtime: Boolean = false) = + subscriptions.observeAll(bedtime).first() + + private suspend fun order(bedtime: Boolean = false) = + listing(bedtime).map { it.subscription.id } + + private suspend fun waiting(id: String) = + listing().first { it.subscription.id == id }.hasWaiting @Test fun ranksByTheNewestEpisodeStillWaiting() = runBlocking { @@ -151,4 +164,45 @@ class LibraryOrderTest { assertEquals(listOf("active", "empty"), order()) } + + /** Bedtime shows are a separate list, not a re-ordering of the same one. */ + @Test + fun bedtimeShowsAreOutOfTheOrdinaryLibrary() = runBlocking { + show("daytime") + show("sleepy", bedtime = true) + episode("e1", "daytime", 1 * day) + episode("e2", "sleepy", 5 * day) + + assertEquals("the newer show is a bedtime one and must not appear", listOf("daytime"), order()) + assertEquals(listOf("sleepy"), order(bedtime = true)) + } + + /** Which is what greys a show out: nothing unplayed and unqueued left. */ + @Test + fun hasWaitingGoesFalseOncePlayedAndQueuedAccountForEverything() = runBlocking { + show("a") + episode("played", "a", 1 * day) + episode("queued", "a", 2 * day) + assertEquals(true, waiting("a")) + + episodes.markPlayed("played", 1) + queue.replaceAll(listOf("queued")) + + assertEquals(false, waiting("a")) + } + + /** + * The trap in computing this from the sort key instead of a count. + * + * Ranking uses MAX(COALESCE(publishedAtMs, 0)), so a show whose only waiting episode has no + * publish date ranks at 0 -- correct for ordering, but reading "has something waiting" off + * that same 0 would grey out a show that does. + */ + @Test + fun aWaitingEpisodeWithNoPublishDateStillCounts() = runBlocking { + show("undated") + episode("e1", "undated", null) + + assertEquals(true, waiting("undated")) + } } 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 09b669f..091d8e6 100644 --- a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt +++ b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt @@ -333,4 +333,87 @@ class MigrationTest { assertEquals(90000L, cursor.getLong(1)) } } + + /** + * Bedtime shows. Every existing subscription must come through as *not* bedtime: the flag + * hides a show from the library, so a default that went the other way would empty it. + */ + @Test + fun migrates8To9DefaultingEveryExistingShowToNotBedtime() { + helper.createDatabase(name, 8).use { db -> + db.execSQL( + """INSERT INTO subscriptions + (id, feedUrl, kind, title, addedAtMs, skipIntroMs, skipOutroMs, dripEnabled, + dripIntervalMs, dripCount, dripOldestFirst, skipAdChapters) + VALUES ('s1', 'https://example.com/feed.xml', 'PODCAST', 'A Show', 1000, 0, 0, + 0, 86400000, 1, 1, 1)""" + ) + db.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, + 515151, 'DOWNLOADED')""" + ) + } + + val db = helper.runMigrationsAndValidate(name, 9, true, AppDatabase.MIGRATION_8_9) + + db.query("SELECT bedtime, title FROM subscriptions WHERE id = 's1'").use { cursor -> + assertEquals(1, cursor.count) + cursor.moveToFirst() + assertEquals("an existing show must stay in the library", 0, cursor.getInt(0)) + assertEquals("A Show", cursor.getString(1)) + } + + db.query("SELECT positionMs FROM episodes WHERE id = 'e1'").use { cursor -> + cursor.moveToFirst() + assertEquals("the crown jewel must survive", 515151L, cursor.getLong(0)) + } + } + + /** + * Drops `pendingClipPcm`, which could hold a whole 240-second decoded clip -- 3.84MB, well + * past the 2MB a CursorWindow will carry. Rows written with one could be inserted and then + * never read back: reproduced on an emulator as a hard crash the moment a show's second + * episode was fingerprinted. + * + * The surviving columns must come through, because the confirmed reference clip is the part + * worth keeping -- losing it would restart detection for every show. + */ + @Test + fun migrates9To10DroppingTheOversizedPendingClipAndKeepingTheRest() { + helper.createDatabase(name, 9).use { db -> + db.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)""" + ) + db.execSQL( + """INSERT INTO show_fingerprints + (subscriptionId, kind, referenceClipPcm, pendingClipPcm, pendingEpisodeId, + sampleRate, confirmedCount, lastConfirmedEpisodeId, updatedAtMs) + VALUES ('s1', 'INTRO', X'0A0B', X'0C0D', 'e9', 8000, 2, 'e8', 4242)""" + ) + } + + val db = helper.runMigrationsAndValidate(name, 10, true, AppDatabase.MIGRATION_9_10) + + db.query( + """SELECT referenceClipPcm, pendingEpisodeId, confirmedCount, lastConfirmedEpisodeId, + sampleRate, updatedAtMs + FROM show_fingerprints WHERE subscriptionId = 's1' AND kind = 'INTRO'""" + ).use { cursor -> + assertEquals(1, cursor.count) + cursor.moveToFirst() + assertEquals("a confirmed reference clip is worth keeping", 2, cursor.getBlob(0).size) + assertEquals("e9", cursor.getString(1)) + assertEquals(2, cursor.getInt(2)) + assertEquals("e8", cursor.getString(3)) + assertEquals(8000, cursor.getInt(4)) + assertEquals(4242L, cursor.getLong(5)) + } + } } 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 e8769d3..d1b7ed2 100644 --- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt +++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt @@ -30,7 +30,7 @@ class Converters { ShowMidrollEntity::class, EpisodeMidrollEntity::class, ], - version = 8, + version = 10, exportSchema = true, ) @TypeConverters(Converters::class) @@ -230,6 +230,62 @@ abstract class AppDatabase : RoomDatabase() { } } + /** + * Bedtime shows: the ones Peter only listens to falling asleep. + * + * Defaulted off, and it has to be. The flag takes a show out of the library entirely, + * so a default of 1 here would hand an upgrader an empty list and look like data loss. + */ + val MIGRATION_8_9 = object : Migration(8, 9) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `bedtime` INTEGER NOT NULL DEFAULT 0") + } + } + + /** + * Drops `show_fingerprints.pendingClipPcm`, which could not be read back. + * + * It held a whole decoded head or tail window -- 240 seconds at 8kHz, 3.84MB as 16-bit + * PCM -- and Android reads every Room row through a CursorWindow, which refuses a row + * over 2MB. The insert succeeded and the next read threw SQLiteBlobTooBigException, so + * the intro/outro half of Tier 3 crashed the moment a show's second episode was + * fingerprinted. Found on an emulator; it could not have shown up off-device. + * + * The pending episode's *id* stays, and its audio is re-decoded on demand -- exactly + * what [SubscriptionEntity.midrollPendingEpisodeId] already does, for the same reason. + * Recreated rather than altered because SQLite cannot drop a column in place on the + * versions this app supports; every other column is copied across, since a confirmed + * reference clip is worth keeping. + */ + val MIGRATION_9_10 = object : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """CREATE TABLE IF NOT EXISTS `show_fingerprints_new` ( + `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 DELETE CASCADE)""" + ) + db.execSQL( + """INSERT INTO `show_fingerprints_new` + (subscriptionId, kind, referenceClipPcm, pendingEpisodeId, sampleRate, + confirmedCount, lastConfirmedEpisodeId, updatedAtMs) + SELECT subscriptionId, kind, referenceClipPcm, pendingEpisodeId, + sampleRate, confirmedCount, lastConfirmedEpisodeId, updatedAtMs + FROM `show_fingerprints`""" + ) + db.execSQL("DROP TABLE `show_fingerprints`") + db.execSQL("ALTER TABLE `show_fingerprints_new` RENAME TO `show_fingerprints`") + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_show_fingerprints_subscriptionId` " + + "ON `show_fingerprints` (`subscriptionId`)" + ) + } + } + fun get(context: Context): AppDatabase = instance ?: synchronized(this) { instance ?: build(context.applicationContext).also { instance = it } } @@ -240,7 +296,7 @@ abstract class AppDatabase : RoomDatabase() { // deliberate addition, and the list is the record of them. .addMigrations( MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, - MIGRATION_6_7, MIGRATION_7_8, + MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, ) .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 541d6ef..4fb6342 100644 --- a/app/src/main/java/org/terst/listen/data/Daos.kt +++ b/app/src/main/java/org/terst/listen/data/Daos.kt @@ -1,6 +1,7 @@ package org.terst.listen.data import androidx.room.Dao +import androidx.room.Embedded import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query @@ -8,6 +9,18 @@ import androidx.room.Transaction import androidx.room.Update import kotlinx.coroutines.flow.Flow +/** + * A show as the library lists it: the row, plus whether anything of it is still waiting. + * + * [hasWaiting] is what greys a show out. It is computed in the same query as the ordering + * because both answer the same question about the same subquery, and asking twice would let + * the list and the dimming disagree. + */ +data class SubscriptionListing( + @Embedded val subscription: SubscriptionEntity, + val hasWaiting: Boolean, +) + @Dao interface SubscriptionDao { /** @@ -20,17 +33,36 @@ interface SubscriptionDao { * * Queued episodes are excluded for the same reason played ones are: they have been dealt * with. Peter's words -- once they're on the queue they're noise in the list. + * + * [bedtime] picks which of the two lists this is. They are disjoint, not a filter over one + * list: a bedtime show is absent from the ordinary library entirely, which is the point. + * + * [SubscriptionListing.hasWaiting] comes from a COUNT rather than from `latest`, even + * though the sort key is right there. An episode with no publish date ranks at 0, which is + * fine for ordering and wrong for "has something waiting" -- reading the flag off the sort + * key would grey out a show that has an undated episode sitting unheard. */ @Query( - """SELECT s.* FROM subscriptions s - LEFT JOIN (SELECT subscriptionId, MAX(COALESCE(publishedAtMs, 0)) AS latest + """SELECT s.*, COALESCE(e.waiting, 0) > 0 AS hasWaiting FROM subscriptions s + LEFT JOIN (SELECT subscriptionId, COUNT(*) AS waiting, + MAX(COALESCE(publishedAtMs, 0)) AS latest FROM episodes WHERE playedAtMs IS NULL AND id NOT IN (SELECT episodeId FROM queue) GROUP BY subscriptionId) e ON e.subscriptionId = s.id + WHERE s.bedtime = :bedtime ORDER BY COALESCE(e.latest, 0) DESC, s.title COLLATE NOCASE""" ) - fun observeAll(): Flow> + fun observeAll(bedtime: Boolean): Flow> + + /** + * Every show, bedtime ones included. + * + * Deliberately not the library query: a rule may well target a bedtime show, and a screen + * that has navigated into one must keep rendering it after bedtime mode is switched off. + */ + @Query("SELECT * FROM subscriptions ORDER BY title COLLATE NOCASE") + fun observeEvery(): Flow> @Query("SELECT * FROM subscriptions ORDER BY title COLLATE NOCASE") suspend fun getAll(): List @@ -67,6 +99,10 @@ interface SubscriptionDao { @Query("UPDATE subscriptions SET skipAdChapters = :enabled WHERE id = :id") suspend fun setSkipAdChapters(id: String, enabled: Boolean) + /** Moves a show between the ordinary library and the bedtime one. */ + @Query("UPDATE subscriptions SET bedtime = :enabled WHERE id = :id") + suspend fun setBedtime(id: String, enabled: Boolean) + /** Tier 2 of the skip feature: how much of the top and tail of every episode to pass over. */ @Query("UPDATE subscriptions SET skipIntroMs = :introMs, skipOutroMs = :outroMs WHERE id = :id") suspend fun updateSkips(id: String, introMs: Long, outroMs: 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 4f5242d..83ee56a 100644 --- a/app/src/main/java/org/terst/listen/data/Entities.kt +++ b/app/src/main/java/org/terst/listen/data/Entities.kt @@ -102,15 +102,25 @@ data class SubscriptionEntity( * The one episode set aside to compare the *next* newly-downloaded episode against, when * looking for a midroll segment neither has been matched to yet. * - * Unlike [ShowFingerprintEntity.pendingClipPcm] this stores only an id, never audio: a - * pending clip for intro/outro is a few seconds, cheap to persist between runs, but a - * pending clip here is a *whole episode* -- tens of megabytes of PCM doubles for an hour of - * audio, uselessly large to keep in a database row. [MidrollUpdater][org.terst.listen.fingerprint.MidrollUpdater] - * re-decodes this episode's file on demand instead, and if retention has already reclaimed - * it, simply moves the pending slot on to whatever is newest -- the same graceful "nothing to - * compare against yet" path as never having had a pending episode at all. + * An id, never audio -- the same rule [ShowFingerprintEntity.pendingEpisodeId] follows, and + * for the same reason: a pending clip here is a *whole episode*, tens of megabytes of PCM + * for an hour of audio, far past what a database row can carry back out. + * [MidrollUpdater][org.terst.listen.fingerprint.MidrollUpdater] re-decodes this episode's + * file on demand instead, and if retention has already reclaimed it, simply moves the + * pending slot on to whatever is newest -- the same graceful "nothing to compare against + * yet" path as never having had a pending episode at all. */ val midrollPendingEpisodeId: String? = null, + + /** + * A show only listened to falling asleep, kept out of the ordinary library. + * + * Not a category system: the one grouping that earns its keep here is the one with a + * behaviour attached, which is that entering bedtime mode also starts the sleep timer. + * A show marked this way is invisible until that mode is on, so the default must be false + * -- see [AppDatabase.MIGRATION_8_9]. + */ + @ColumnInfo(defaultValue = "0") val bedtime: Boolean = false, ) /** One a day, the cadence a drip subscription is for. */ @@ -279,13 +289,16 @@ data class ShowFingerprintEntity( */ val referenceClipPcm: ByteArray?, /** - * The most recently processed episode's *whole* head/tail clip, kept only while there is no - * [referenceClipPcm] yet -- the next episode needs something to compare against to find a - * first candidate. Null again as soon as one is found; unlike the reference clip this is - * genuinely temporary and, unlike it, sized like a whole decoded clip rather than a short - * matched segment. + * The episode set aside to compare the *next* one against, while there is no + * [referenceClipPcm] yet -- finding a first candidate needs two episodes. + * + * An id, never the audio. The clip itself is a whole decoded head or tail window: 240 + * seconds at 8kHz is 3.84MB as 16-bit PCM, and Android reads every Room row through a + * CursorWindow that refuses anything over 2MB. Storing it wrote a row that could never be + * read back. It is re-decoded from this episode's file on demand instead, exactly as + * [SubscriptionEntity.midrollPendingEpisodeId] already does; if retention has reclaimed + * the file, detection simply starts again from whatever is newest. */ - val pendingClipPcm: ByteArray?, val pendingEpisodeId: String?, val sampleRate: Int, /** @@ -308,7 +321,6 @@ data class ShowFingerprintEntity( return subscriptionId == other.subscriptionId && kind == other.kind && referenceClipPcm.contentEqualsOrBothNull(other.referenceClipPcm) && - pendingClipPcm.contentEqualsOrBothNull(other.pendingClipPcm) && pendingEpisodeId == other.pendingEpisodeId && sampleRate == other.sampleRate && confirmedCount == other.confirmedCount && @@ -320,7 +332,6 @@ data class ShowFingerprintEntity( var result = subscriptionId.hashCode() result = 31 * result + kind.hashCode() result = 31 * result + (referenceClipPcm?.contentHashCode() ?: 0) - result = 31 * result + (pendingClipPcm?.contentHashCode() ?: 0) result = 31 * result + (pendingEpisodeId?.hashCode() ?: 0) result = 31 * result + sampleRate result = 31 * result + confirmedCount diff --git a/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt b/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt index dc627fa..7018103 100644 --- a/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt +++ b/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt @@ -1,5 +1,6 @@ package org.terst.listen.fingerprint +import org.terst.listen.data.EpisodeDao import org.terst.listen.data.EpisodeEntity import org.terst.listen.data.FingerprintKind import org.terst.listen.data.ShowFingerprintDao @@ -40,6 +41,7 @@ interface AudioClipSource { */ class FingerprintUpdater( private val fingerprints: ShowFingerprintDao, + private val episodes: EpisodeDao, private val clips: AudioClipSource, private val referenceLengthSamples: Int = DEFAULT_REFERENCE_SECONDS * DEFAULT_SAMPLE_RATE, private val now: () -> Long = System::currentTimeMillis, @@ -53,7 +55,7 @@ class FingerprintUpdater( private suspend fun processEnd(episode: EpisodeEntity, kind: FingerprintKind, clip: DecodedClip) { val stored = fingerprints.get(episode.subscriptionId, kind) - val state = stateOf(stored) + val state = stateOf(stored, kind) val update = IntroOutroDetector.check(state, episode.id, clip.samples, referenceLengthSamples) fingerprints.upsert(entityOf(update.state, episode.subscriptionId, kind, clip.sampleRate)) @@ -84,17 +86,40 @@ class FingerprintUpdater( } } - private fun stateOf(stored: ShowFingerprintEntity?): FingerprintState { + private suspend fun stateOf(stored: ShowFingerprintEntity?, kind: FingerprintKind): FingerprintState { if (stored == null) return FingerprintState() return FingerprintState( referenceClip = stored.referenceClipPcm?.let(PcmCodec::decode), - pendingClip = stored.pendingClipPcm?.let(PcmCodec::decode), + pendingClip = pendingClipOf(stored, kind), pendingEpisodeId = stored.pendingEpisodeId, confirmedCount = stored.confirmedCount, lastConfirmedEpisodeId = stored.lastConfirmedEpisodeId, ) } + /** + * Re-decodes the pending episode rather than reading a clip back out of the database. + * + * The clip is a whole head or tail window and does not fit in a row -- see + * [org.terst.listen.data.ShowFingerprintEntity.pendingEpisodeId]. Decoding again costs one + * pass over a file already on disk, and only until a first candidate exists. + * + * Null whenever that episode or its file has gone: the detector then treats the episode in + * hand as the new pending one, which is the same place it starts from anyway. + */ + private suspend fun pendingClipOf(stored: ShowFingerprintEntity, kind: FingerprintKind): DoubleArray? { + if (stored.referenceClipPcm != null) return null + val pendingId = stored.pendingEpisodeId ?: return null + val pending = episodes.getById(pendingId) ?: return null + if (pending.localPath == null) return null + + val clip = when (kind) { + FingerprintKind.INTRO -> clips.decodeHead(pending) + FingerprintKind.OUTRO -> clips.decodeTail(pending) + } + return clip?.samples + } + private fun entityOf( state: FingerprintState, subscriptionId: String, @@ -104,7 +129,6 @@ class FingerprintUpdater( subscriptionId = subscriptionId, kind = kind, referenceClipPcm = state.referenceClip?.let(PcmCodec::encode), - pendingClipPcm = state.pendingClip?.let(PcmCodec::encode), pendingEpisodeId = state.pendingEpisodeId, sampleRate = sampleRate, confirmedCount = state.confirmedCount, @@ -112,9 +136,10 @@ class FingerprintUpdater( updatedAtMs = now(), ) - private companion object { + companion object { + /** Public so [org.terst.listen.fingerprint.StoredClip]'s guard can measure them. */ const val DEFAULT_REFERENCE_SECONDS = 8 const val DEFAULT_SAMPLE_RATE = 8_000 - const val MS_PER_SECOND = 1_000L + private const val MS_PER_SECOND = 1_000L } } diff --git a/app/src/main/java/org/terst/listen/fingerprint/IntroOutroDetector.kt b/app/src/main/java/org/terst/listen/fingerprint/IntroOutroDetector.kt index 3a446d7..9050466 100644 --- a/app/src/main/java/org/terst/listen/fingerprint/IntroOutroDetector.kt +++ b/app/src/main/java/org/terst/listen/fingerprint/IntroOutroDetector.kt @@ -55,7 +55,8 @@ sealed interface FingerprintUpdate { */ object IntroOutroDetector { - private const val CONFIRM_THRESHOLD = 3 + /** Public so the UI can say "2 of 3" rather than hardcoding the same number again. */ + const val CONFIRM_THRESHOLD = 3 private const val MIN_SCORE = 0.6 private const val CANDIDATE_STEP_DIVISOR = 4 diff --git a/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt b/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt index 133d391..20f4417 100644 --- a/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt +++ b/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt @@ -188,7 +188,12 @@ class MediaCodecClipSource : AudioClipSource { } } - private companion object { + companion object { + /** + * The head/tail window decoded for matching. Public because [StoredClip]'s size guard + * measures it: this window is far too big to persist, and a test says so rather than a + * comment nobody reads. + */ const val CLIP_SECONDS = 240 // durationMs is the feed's own claim, occasionally a little short of the real file. const val FULL_DECODE_MARGIN_SECONDS = 30 diff --git a/app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt b/app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt index b33f577..8cbb441 100644 --- a/app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt +++ b/app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt @@ -15,7 +15,8 @@ package org.terst.listen.fingerprint */ object MidrollDetector { - private const val CONFIRM_THRESHOLD = 3 + /** Public for the same reason as [IntroOutroDetector.CONFIRM_THRESHOLD]. */ + const val CONFIRM_THRESHOLD = 3 private const val MIN_SCORE = 0.6 data class Candidate( diff --git a/app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt b/app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt index 47f9184..5849d1d 100644 --- a/app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt +++ b/app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt @@ -93,28 +93,30 @@ class MidrollUpdater( val pendingEpisode = episodes.getById(pendingEpisodeId) ?: return val pendingClip = clips.decodeFull(pendingEpisode) ?: return + // The same real segment surfaces from a fresh discovery pass every time the pending + // pointer rolls forward -- e2 vs e3 finds the same ad e1 vs e2 already tracked. + // Recognised by *where* it landed in the new episode, not by re-correlating clip + // content: two coarse passes over the same segment do not reliably extract the same + // sub-range of it, so a content comparison between them can score low even though both + // come from the one real ad break -- position overlap is what is actually stable + // between runs. val tolerance = discoverySamples.toLong() * coarseFactor - for (segment in discover(pendingClip.samples, newClip.samples)) { - // The same real segment surfaces from a fresh discovery pass every time the pending - // pointer rolls forward -- e2 vs e3 finds the same ad e1 vs e2 already tracked. - // Recognised here by *where* it landed in the new episode, not by re-correlating - // clip content: two coarse passes over the same segment do not reliably extract the - // same sub-range of it, so a content comparison between them can score low even - // though both come from the one real ad break -- position overlap is what is - // actually stable between runs. - val overlapsTracked = alreadyMatchedInNewEpisode.any { existing -> + val undiscovered = discover(pendingClip.samples, newClip.samples).filterNot { segment -> + alreadyMatchedInNewEpisode.any { existing -> rangesOverlap( segment.positionInB - tolerance, segment.positionInB + segment.lengthInB + tolerance, existing.positionInClip.toLong(), existing.positionInClip.toLong() + existing.lengthSamples, ) } - if (overlapsTracked) continue + } + for (segment in undiscovered) { + val pcm = storablePcm(segment.referenceClip, newClip.sampleRate) ?: continue midrolls.upsertCandidate( ShowMidrollEntity( subscriptionId = subscriptionId, id = newId(), - referenceClipPcm = PcmCodec.encode(segment.referenceClip), + referenceClipPcm = pcm, sampleRate = newClip.sampleRate, confirmedCount = SEEDED_CONFIRM_COUNT, lastConfirmedEpisodeId = newEpisode.id, @@ -124,6 +126,29 @@ class MidrollUpdater( } } + /** + * A discovered segment as bytes a database row can carry back out, or null if even the + * trimmed clip would not fit. + * + * A shared segment has no length limit of its own -- a long sponsor block could run for + * minutes, and at 8kHz 16-bit PCM two minutes is already past the 2MB a CursorWindow will + * return. See [StoredClip]; the fingerprint table shipped with exactly that bug, and it + * crashed the app on read rather than failing on write. + * + * Trimming shortens the range that gets skipped, so a very long break plays its tail rather + * than being cut entirely. That is the safe direction to be wrong in: over-skipping eats + * real content, and not trimming at all makes a row that cannot be read. + * + * The [StoredClip.fits] check afterwards is a backstop, unreachable while the cap and the + * limit agree. It returns null rather than throwing because an untracked ad break is a + * missing feature and an oversized row is a crash. + */ + private fun storablePcm(clip: DoubleArray, sampleRate: Int): ByteArray? { + val maxSamples = StoredClip.MAX_CANDIDATE_SECONDS * sampleRate + val trimmed = if (clip.size <= maxSamples) clip else clip.copyOf(maxSamples) + return PcmCodec.encode(trimmed).takeIf { StoredClip.fits(it.size) } + } + private fun rangesOverlap(aStart: Long, aEnd: Long, bStart: Long, bEnd: Long) = aStart < bEnd && bStart < aEnd private data class DiscoveredSegment(val referenceClip: DoubleArray, val positionInB: Long, val lengthInB: Long) @@ -181,11 +206,12 @@ class MidrollUpdater( if (row.id !in surviving) midrolls.deleteCandidate(subscriptionId, row.id) } for (candidate in result.candidates) { + val pcm = storablePcm(candidate.referenceClip, sampleRate) ?: continue midrolls.upsertCandidate( ShowMidrollEntity( subscriptionId = subscriptionId, id = candidate.id, - referenceClipPcm = PcmCodec.encode(candidate.referenceClip), + referenceClipPcm = pcm, sampleRate = sampleRate, confirmedCount = candidate.confirmedCount, lastConfirmedEpisodeId = candidate.lastConfirmedEpisodeId, diff --git a/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt b/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt new file mode 100644 index 0000000..3718f58 --- /dev/null +++ b/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt @@ -0,0 +1,112 @@ +package org.terst.listen.fingerprint + +import org.terst.listen.data.EpisodeEntity +import org.terst.listen.data.EpisodeMidrollEntity +import org.terst.listen.data.ShowFingerprintEntity +import org.terst.listen.data.ShowMidrollEntity +import org.terst.listen.data.SubscriptionEntity + +/** The three lines the episode screen shows about skipping. [breaks] is never empty. */ +data class EpisodeSkips(val intro: String, val outro: String, val breaks: List) + +/** + * Says in words what the skip detection has worked out, so it stops being invisible. + * + * Tier 3 decides on its own, in a background worker, whether to cut audio out of an episode. + * Until this existed there was no way to tell a show it had confirmed an intro for from one it + * had never looked at -- both simply played. The distinction the readout has to carry is + * confirmed-and-acting versus still-gathering, because only the first one skips anything. + * + * Pure, and the wording is pinned by tests: this is the only explanation of the feature the app + * ever gives. + */ +object SkipSummary { + + private const val MS_PER_SECOND = 1000L + private const val SECONDS_PER_MINUTE = 60 + private const val MINUTES_PER_HOUR = 60 + + /** One end of a show's episodes -- its intro or its outro. */ + fun candidateState(state: ShowFingerprintEntity?, threshold: Int): String { + // No reference clip means no candidate: either nothing has been processed, or a single + // episode's clip is set aside with nothing yet to compare it against. Both are the same + // thing to a reader -- detection has not found anything. + if (state?.referenceClipPcm == null) return "Nothing found yet" + + return if (state.confirmedCount >= threshold) { + "Confirmed by ${state.confirmedCount} episodes" + } else { + "Found, ${state.confirmedCount} of $threshold episodes agree" + } + } + + /** A show's midroll candidates, which unlike intro/outro can be several at once. */ + fun midrollState(candidates: List, threshold: Int): String { + if (candidates.isEmpty()) return "None found yet" + + val confirmed = candidates.count { it.confirmedCount >= threshold } + val gathering = candidates.size - confirmed + + return listOfNotNull( + "$confirmed confirmed".takeIf { confirmed > 0 }, + "$gathering still gathering".takeIf { gathering > 0 }, + ).joinToString(", ") + } + + /** + * The offset actually in force for one episode, and where it came from. + * + * Mirrors [org.terst.listen.playback.effectiveIntroMs]: a detected value wins over the + * manual one. A detected *zero* is a real finding -- this show turned out to have no intro + * -- and must not read as "nothing detected", or the readout would disagree with what + * playback is doing. + */ + private fun offset(detectedMs: Long?, manualMs: Long): String = when { + detectedMs != null -> "${describe(detectedMs)}, detected" + manualMs > 0 -> "${describe(manualMs)}, set by hand" + else -> "None" + } + + /** Detected ad breaks in one episode, earliest first, as clock times you can scrub to. */ + private fun ranges(ranges: List): List = + ranges.sortedBy { it.startMs }.map { "${clock(it.startMs)} - ${clock(it.endMs)}" } + + /** + * Everything the episode screen says about skipping, decided here rather than in the + * composable so the "not checked yet" case is actually covered by a test. + */ + fun episode( + episode: EpisodeEntity, + show: SubscriptionEntity?, + ranges: List, + ): EpisodeSkips { + val breaks = ranges(ranges) + return EpisodeSkips( + intro = offset(episode.detectedIntroMs, show?.skipIntroMs ?: 0), + outro = offset(episode.detectedOutroMs, show?.skipOutroMs ?: 0), + breaks = when { + breaks.isNotEmpty() -> breaks + // Distinct from "none found": scanning runs nightly while charging, so a + // freshly downloaded episode has genuinely not been looked at yet. + episode.midrollCheckedAtMs == null -> listOf("Not checked yet") + else -> listOf("None found") + }, + ) + } + + private fun describe(ms: Long): String = + if (ms <= 0) "None" else "${ms / MS_PER_SECOND}s" + + private fun clock(ms: Long): String { + val totalSeconds = ms / MS_PER_SECOND + val seconds = totalSeconds % SECONDS_PER_MINUTE + val minutes = (totalSeconds / SECONDS_PER_MINUTE) % MINUTES_PER_HOUR + val hours = totalSeconds / SECONDS_PER_MINUTE / MINUTES_PER_HOUR + + return if (hours > 0) { + "%d:%02d:%02d".format(hours, minutes, seconds) + } else { + "%d:%02d".format(minutes, seconds) + } + } +} diff --git a/app/src/main/java/org/terst/listen/fingerprint/StoredClip.kt b/app/src/main/java/org/terst/listen/fingerprint/StoredClip.kt new file mode 100644 index 0000000..4d425df --- /dev/null +++ b/app/src/main/java/org/terst/listen/fingerprint/StoredClip.kt @@ -0,0 +1,32 @@ +package org.terst.listen.fingerprint + +/** + * How much decoded audio may be put in a database row. + * + * Android hands every Room read through a CursorWindow, which refuses a row larger than 2MB -- + * on the way *out*. An oversized blob inserts cleanly and then throws + * `SQLiteBlobTooBigException` the next time anything reads it, which is how the intro/outro + * half of Tier 3 shipped unable to get past a show's second episode. + * + * Every persisted clip goes through [fits], so the limit is one shared fact rather than + * something each caller has to remember. + */ +object StoredClip { + + /** Android's own cap on a row read through a CursorWindow. */ + const val CURSOR_WINDOW_LIMIT_BYTES = 2 * 1024 * 1024 + + /** Half of it, so the columns alongside the blob can never tip an accepted clip over. */ + const val SAFE_BYTES = CURSOR_WINDOW_LIMIT_BYTES / 2 + + /** + * The longest midroll segment worth keeping, at [MediaCodecClipSource.TARGET_SAMPLE_RATE]. + * + * Sixty seconds is about 960KB, comfortably inside [SAFE_BYTES], and long enough to + * identify an ad break by correlation several times over -- matching is about whether the + * segment recurs, not about hearing all of it. + */ + const val MAX_CANDIDATE_SECONDS = 60 + + fun fits(bytes: Int): Boolean = bytes <= SAFE_BYTES +} diff --git a/app/src/main/java/org/terst/listen/playback/BedtimePolicy.kt b/app/src/main/java/org/terst/listen/playback/BedtimePolicy.kt new file mode 100644 index 0000000..de52742 --- /dev/null +++ b/app/src/main/java/org/terst/listen/playback/BedtimePolicy.kt @@ -0,0 +1,31 @@ +package org.terst.listen.playback + +/** What switching bedtime mode should do to the sleep timer. */ +sealed interface BedtimeAction { + data class ArmSleepTimer(val minutes: Long) : BedtimeAction + data object CancelSleepTimer : BedtimeAction + data object Nothing : BedtimeAction +} + +/** + * Bedtime mode's one side effect: it arms the sleep timer, and takes it away again. + * + * Pure, because both directions have a failure that is invisible until it bites. Arming over a + * timer the listener set by hand silently changes a duration they chose; cancelling on the way + * out when bedtime never armed anything silently kills that same timer. Neither shows up as a + * crash -- they show up as audio still playing at 3am, or stopping mid-sentence. + */ +object BedtimePolicy { + + /** + * Long rather than short. [SleepTimer]'s own reasoning applies: stopping late costs + * nothing, stopping early wakes you up to restart it. + */ + const val DEFAULT_MINUTES = 45L + + fun entering(timerAlreadyArmed: Boolean): BedtimeAction = + if (timerAlreadyArmed) BedtimeAction.Nothing else BedtimeAction.ArmSleepTimer(DEFAULT_MINUTES) + + fun leaving(bedtimeArmedIt: Boolean): BedtimeAction = + if (bedtimeArmedIt) BedtimeAction.CancelSleepTimer else BedtimeAction.Nothing +} diff --git a/app/src/main/java/org/terst/listen/ui/DripSettings.kt b/app/src/main/java/org/terst/listen/ui/DripSettings.kt index 9b8188a..97ba063 100644 --- a/app/src/main/java/org/terst/listen/ui/DripSettings.kt +++ b/app/src/main/java/org/terst/listen/ui/DripSettings.kt @@ -3,19 +3,13 @@ package org.terst.listen.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import org.terst.listen.data.SubscriptionEntity import java.util.concurrent.TimeUnit @@ -41,43 +35,32 @@ fun DripSettings( onFetchArchive: () -> Unit, ) { Column( - Modifier.fillMaxWidth().padding(horizontal = 16.dp), + Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Switch( - // Named, because a bare switch has nothing for a screen reader to announce -- - // and nothing for the UI checker to tap by. - modifier = Modifier.semantics { contentDescription = "Turn slow drip on or off" }, - checked = subscription.dripEnabled, - onCheckedChange = { wanted -> - if (wanted) { - onEnable( - subscription.dripIntervalMs, - subscription.dripCount, - subscription.dripOldestFirst, - ) - } else { - onDisable() - } - }, - ) - Column(Modifier.padding(start = 12.dp)) { - Text("Slow drip", style = MaterialTheme.typography.titleSmall) - Text(dripSummary(subscription), style = MaterialTheme.typography.bodySmall) - } - } + SwitchRow( + label = "Slow drip", + description = "Turn slow drip on or off", + checked = subscription.dripEnabled, + detail = dripSummary(subscription), + onCheckedChange = { wanted -> + if (wanted) { + onEnable( + subscription.dripIntervalMs, + subscription.dripCount, + subscription.dripOldestFirst, + ) + } else { + onDisable() + } + }, + ) if (subscription.dripEnabled) { DripCadence(subscription, onEnable) } OutlinedButton(onClick = onFetchArchive, enabled = !busy) { Text("Fetch archive") } - Text( - "Pulls older episodes by following the feed's archive pages. Many publishers do not " + - "offer them, in which case a drip can only work through what the feed lists today.", - style = MaterialTheme.typography.bodySmall, - ) } } @@ -127,11 +110,9 @@ private fun DripChip(label: String, selected: Boolean, onClick: () -> Unit) { } private fun dripSummary(subscription: SubscriptionEntity): String { - if (!subscription.dripEnabled) { - return "Off. The whole catalogue is listed." - } + if (!subscription.dripEnabled) return "Whole catalogue listed" val days = TimeUnit.MILLISECONDS.toDays(subscription.dripIntervalMs) val every = if (days == 1L) "day" else "$days days" val order = if (subscription.dripOldestFirst) "oldest" else "newest" - return "${subscription.dripCount} $order first, every $every. Only released episodes are listed." + return "${subscription.dripCount} $order first, every $every" } 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 0e161e3..09356f1 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt @@ -36,7 +36,13 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import org.terst.listen.data.EpisodeEntity +import org.terst.listen.data.EpisodeMidrollEntity +import org.terst.listen.data.SubscriptionEntity import org.terst.listen.feed.WatchLink +import org.terst.listen.fingerprint.EpisodeSkips +import org.terst.listen.fingerprint.SkipSummary +import org.terst.listen.playback.effectiveIntroMs +import org.terst.listen.playback.effectiveOutroMs /** * One episode in full. @@ -54,8 +60,13 @@ fun EpisodeScreen( nav: SubscriptionNav, ) { var episode by remember { mutableStateOf(null) } - LaunchedEffect(episodeId) { episode = vm.episode(episodeId) } + var ranges by remember { mutableStateOf>(emptyList()) } + LaunchedEffect(episodeId) { + episode = vm.episode(episodeId) + ranges = vm.midrollRanges(episodeId) + } val rules by vm.rules.collectAsState() + val shows by vm.subscriptions.collectAsState() val current = episode Scaffold( @@ -78,6 +89,11 @@ fun EpisodeScreen( EpisodeDetail( episode = current, ruleName = rules.firstOrNull { rule -> rule.id == current.appliedRuleId }?.name, + skips = SkipSummary.episode( + current, + shows.firstOrNull { it.id == current.subscriptionId }, + ranges, + ), modifier = Modifier.padding(padding), actions = EpisodeDetailActions( play = { player.play(current); nav.openPlayer() }, @@ -97,6 +113,7 @@ fun EpisodeScreen( private fun EpisodeDetail( episode: EpisodeEntity, ruleName: String?, + skips: EpisodeSkips, modifier: Modifier, actions: EpisodeDetailActions, ) { @@ -140,12 +157,37 @@ private fun EpisodeDetail( RuleAttribution(episode, ruleName) + SkipReadout(skips) + episode.description?.let { Text(stripHtml(it), style = MaterialTheme.typography.bodyMedium) } } } +/** + * What is actually being skipped in this episode, and where the numbers came from. + * + * The one place the detection is visible per episode. It deliberately reports what playback + * will really do -- [effectiveIntroMs] and [effectiveOutroMs] prefer a detected value over the + * manual one, so reading the manual field here would quietly contradict the player. + * + * "Not checked yet" is a distinct state from "nothing found". Midroll scanning runs nightly + * while charging, so a freshly downloaded episode legitimately has nothing to say yet, and + * reporting that as "no ad breaks" would look like the feature failing. + */ +@Composable +private fun SkipReadout(skips: EpisodeSkips) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Skips", style = MaterialTheme.typography.titleSmall) + ReadoutRow("Intro", skips.intro) + ReadoutRow("Outro", skips.outro) + skips.breaks.forEachIndexed { index, line -> + ReadoutRow(if (index == 0) "Ad breaks" else "", line) + } + } +} + /** * Show notes are HTML in practice, whatever the spec says. * diff --git a/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt b/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt index 16301aa..a3a1149 100644 --- a/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt @@ -14,6 +14,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Bedtime +import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Button @@ -47,15 +49,17 @@ import coil3.compose.AsyncImage import org.terst.listen.data.DownloadState import org.terst.listen.data.EpisodeEntity import org.terst.listen.data.SubscriptionEntity +import org.terst.listen.data.SubscriptionListing import java.util.concurrent.TimeUnit @OptIn(ExperimentalMaterial3Api::class) @Composable fun LibraryScreen(vm: ListenViewModel, player: PlayerViewModel, nav: LibraryNav) { - val subscriptions by vm.subscriptions.collectAsState() + val subscriptions by vm.library.collectAsState() val queue by vm.queue.collectAsState() val refreshing by vm.refreshing.collectAsState() val banner by vm.banner.collectAsState() + val bedtime by vm.bedtime.collectAsState() val nowPlaying by player.nowPlaying.collectAsState() val snackbar = remember { SnackbarHostState() } var adding by remember { mutableStateOf(false) } @@ -69,19 +73,22 @@ fun LibraryScreen(vm: ListenViewModel, player: PlayerViewModel, nav: LibraryNav) Scaffold( topBar = { - TopAppBar( - title = { Text("Listen") }, - actions = { - IconButton(onClick = { adding = !adding }) { - Icon(Icons.Default.Add, contentDescription = "Add a podcast") - } - IconButton(onClick = vm::refreshAll, enabled = !refreshing) { - Icon(Icons.Default.Refresh, contentDescription = "Refresh all feeds") - } - IconButton(onClick = nav.openSettings) { - Icon(Icons.Default.Settings, contentDescription = "Settings") - } - }, + LibraryTopBar( + bedtime = bedtime, + refreshing = refreshing, + actions = LibraryTopBarActions( + toggleAdd = { adding = !adding }, + refreshAll = vm::refreshAll, + // Both halves of the switch happen here rather than inside a view model: + // the show list is library state and the sleep timer is playback state, + // and they live in different view models by design. + toggleBedtime = { + val entering = !bedtime + vm.setBedtime(entering) + player.onBedtimeChanged(entering) + }, + openSettings = nav.openSettings, + ), ) }, snackbarHost = { SnackbarHost(snackbar) { Snackbar(it) } }, @@ -106,15 +113,53 @@ fun LibraryScreen(vm: ListenViewModel, player: PlayerViewModel, nav: LibraryNav) AddSubscription(onSubmit = { vm.subscribe(it); adding = false }) } - LibraryList(subscriptions, queue, nav.openSubscription, nav.openQueue) + LibraryList(subscriptions, queue, bedtime, nav.openSubscription, nav.openQueue) } } } +/** The top bar's four callbacks, bundled so the signature stays readable. */ +private data class LibraryTopBarActions( + val toggleAdd: () -> Unit, + val refreshAll: () -> Unit, + val toggleBedtime: () -> Unit, + val openSettings: () -> Unit, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LibraryTopBar( + bedtime: Boolean, + refreshing: Boolean, + actions: LibraryTopBarActions, +) { + TopAppBar( + title = { Text(if (bedtime) "Bedtime" else "Listen") }, + actions = { + IconButton(onClick = actions.toggleAdd) { + Icon(Icons.Default.Add, contentDescription = "Add a podcast") + } + IconButton(onClick = actions.refreshAll, enabled = !refreshing) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh all feeds") + } + IconButton(onClick = actions.toggleBedtime) { + Icon( + if (bedtime) Icons.Default.LightMode else Icons.Default.Bedtime, + contentDescription = if (bedtime) "Leave bedtime mode" else "Enter bedtime mode", + ) + } + IconButton(onClick = actions.openSettings) { + Icon(Icons.Default.Settings, contentDescription = "Settings") + } + }, + ) +} + @Composable private fun LibraryList( - subscriptions: List, + subscriptions: List, queue: List, + bedtime: Boolean, onOpenSubscription: (String) -> Unit, onOpenQueue: () -> Unit, ) { @@ -123,10 +168,10 @@ private fun LibraryList( item { UpNextCard(queue, onOpenQueue) } } if (subscriptions.isEmpty()) { - item { EmptyLibrary() } + item { if (bedtime) EmptyBedtime() else EmptyLibrary() } } - items(subscriptions, key = { it.id }) { subscription -> - SubscriptionRow(subscription) { onOpenSubscription(subscription.id) } + items(subscriptions, key = { it.subscription.id }) { listing -> + SubscriptionRow(listing) { onOpenSubscription(listing.subscription.id) } HorizontalDivider() } } @@ -147,6 +192,20 @@ private fun UpNextCard(queue: List, onOpenQueue: () -> Unit) { } } +/** Dimmed rather than hidden: a show you are caught up on is still one you subscribe to. */ +private const val CAUGHT_UP_ALPHA = 0.38f + +@Composable +private fun EmptyBedtime() { + Column(Modifier.fillMaxWidth().padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text("No bedtime podcasts", style = MaterialTheme.typography.titleMedium) + Text( + "Mark a show as Bedtime in its menu and it moves here, out of the main list.", + style = MaterialTheme.typography.bodyMedium, + ) + } +} + @Composable private fun EmptyLibrary() { Column(Modifier.fillMaxWidth().padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally) { @@ -177,21 +236,43 @@ private fun AddSubscription(onSubmit: (String) -> Unit) { } } +/** + * One show. Dimmed when it has nothing waiting, so a scan down the list separates the shows + * with something to hear from the ones you are caught up on. + * + * The dimming stops short of [SubscriptionEntity.lastError], which keeps its error colour: a + * feed that is failing to refresh looks exactly like a feed with nothing new, and that is the + * one case where "nothing waiting" is a lie worth shouting about. + */ @Composable -private fun SubscriptionRow(subscription: SubscriptionEntity, onClick: () -> Unit) { +private fun SubscriptionRow(listing: SubscriptionListing, onClick: () -> Unit) { + val subscription = listing.subscription + val alpha = if (listing.hasWaiting) 1f else CAUGHT_UP_ALPHA + Row(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(16.dp)) { AsyncImage( model = subscription.imageUrl, contentDescription = null, + alpha = alpha, modifier = Modifier .size(48.dp) .clip(RoundedCornerShape(8.dp)) .background(MaterialTheme.colorScheme.surfaceVariant), ) Column(Modifier.weight(1f).padding(start = 12.dp)) { - Text(subscription.title, style = MaterialTheme.typography.titleMedium, maxLines = 2) + Text( + subscription.title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha), + maxLines = 2, + ) subscription.author?.let { - Text(it, style = MaterialTheme.typography.bodySmall, maxLines = 1) + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha), + maxLines = 1, + ) } // A feed that has quietly stopped working is otherwise indistinguishable from one // with nothing new, which is the whole reason the error is stored rather than logged. 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 2b520ec..831ec8f 100644 --- a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt +++ b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt @@ -3,10 +3,12 @@ package org.terst.listen.ui import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.terst.listen.data.EpisodeEntity @@ -16,6 +18,7 @@ import org.terst.listen.data.RefreshOutcome import org.terst.listen.data.RuleEntity import org.terst.listen.data.SubscriptionEntity import org.terst.listen.data.SubscriptionKind +import org.terst.listen.data.SubscriptionListing import org.terst.listen.feed.FeedResolver import org.terst.listen.feed.SubscriptionResolver import org.terst.listen.feed.YouTubeUrls @@ -27,10 +30,36 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) { private val graph = Graph.get(app) + /** + * Whether the library is showing bedtime shows instead of the ordinary ones. + * + * Deliberately not persisted. It describes the next half hour, not a setting: waking up to + * an app still in bedtime mode, with the whole library apparently gone, is the failure this + * avoids. A restart is morning. + */ + private val _bedtime = MutableStateFlow(false) + val bedtime: StateFlow = _bedtime.asStateFlow() + + /** What the library screen lists, which of the two lists depending on [bedtime]. */ + @OptIn(ExperimentalCoroutinesApi::class) + val library: StateFlow> = + _bedtime.flatMapLatest { graph.subscriptions.observeAll(it) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyList()) + + /** + * Every show, for the screens that look one up by id. + * + * Separate from [library] on purpose: a show screen opened from bedtime mode must survive + * bedtime mode being switched off, and it would blank if it read the filtered list. + */ val subscriptions: StateFlow> = - graph.subscriptions.observeAll() + graph.subscriptions.observeEvery() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyList()) + fun setBedtime(enabled: Boolean) { + _bedtime.value = enabled + } + val queue: StateFlow> = graph.queue.observe() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyList()) @@ -52,6 +81,9 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) { suspend fun episode(id: String): EpisodeEntity? = graph.episodes.getById(id) + /** The ad breaks detection has confirmed in one episode, for the episode screen's readout. */ + suspend fun midrollRanges(episodeId: String) = graph.midrolls.getRanges(episodeId) + /** * A show's episodes as it displays them. * diff --git a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt index cc28fe9..fed4960 100644 --- a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt +++ b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt @@ -18,6 +18,8 @@ import org.terst.listen.feed.WatchLink import org.terst.listen.playback.NowPlaying import org.terst.listen.playback.PlayerConnection import org.terst.listen.playback.StartOutcome +import org.terst.listen.playback.BedtimeAction +import org.terst.listen.playback.BedtimePolicy import org.terst.listen.playback.SleepTimer import org.terst.listen.playback.toMediaItem @@ -203,6 +205,31 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) { fun cancelSleepTimer() { sleepTimer.cancel() _sleepRemainingMs.value = null + bedtimeArmedTimer = false + } + + /** + * Tracked so leaving bedtime mode only cancels a timer bedtime mode started. + * + * Without it, flicking the moon icon off would silently kill a timer set by hand. + */ + private var bedtimeArmedTimer = false + + /** Bedtime mode's effect on playback: see [BedtimePolicy] for why each case is what it is. */ + fun onBedtimeChanged(entering: Boolean) { + val action = if (entering) { + BedtimePolicy.entering(timerAlreadyArmed = sleepTimer.isArmed) + } else { + BedtimePolicy.leaving(bedtimeArmedIt = bedtimeArmedTimer) + } + when (action) { + is BedtimeAction.ArmSleepTimer -> { + armSleepTimer(action.minutes) + bedtimeArmedTimer = true + } + BedtimeAction.CancelSleepTimer -> cancelSleepTimer() + BedtimeAction.Nothing -> Unit + } } /** @@ -219,6 +246,7 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) { connection.pause() sleepTimer.cancel() _sleepRemainingMs.value = null + bedtimeArmedTimer = false _banner.value = Banner("Sleep timer finished") break } diff --git a/app/src/main/java/org/terst/listen/ui/RulesViewModel.kt b/app/src/main/java/org/terst/listen/ui/RulesViewModel.kt index 5080011..98ae063 100644 --- a/app/src/main/java/org/terst/listen/ui/RulesViewModel.kt +++ b/app/src/main/java/org/terst/listen/ui/RulesViewModel.kt @@ -24,7 +24,7 @@ class RulesViewModel(app: Application) : AndroidViewModel(app) { /** For the "only this show" condition, and for naming a scoped rule in the list. */ val subscriptions: StateFlow> = - graph.subscriptions.observeAll() + graph.subscriptions.observeEvery() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyList()) /** diff --git a/app/src/main/java/org/terst/listen/ui/SettingRows.kt b/app/src/main/java/org/terst/listen/ui/SettingRows.kt new file mode 100644 index 0000000..a1a6db9 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/SettingRows.kt @@ -0,0 +1,59 @@ +package org.terst.listen.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp + +/** + * A labelled switch, with the accessibility name every switch in this app has to carry. + * + * A bare `Switch` announces nothing to TalkBack and cannot be found by `android-ui tap`, and + * [description] has to differ from [label] -- two identical strings beside each other make the + * control ambiguous to pick, for a screen reader and the UI checker alike. + */ +@Composable +fun SwitchRow( + label: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + detail: String? = null, +) { + Row( + Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Switch( + modifier = Modifier.semantics { contentDescription = description }, + checked = checked, + onCheckedChange = onCheckedChange, + ) + Column(Modifier.padding(start = 12.dp)) { + Text(label, style = MaterialTheme.typography.bodyLarge) + detail?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + } + } +} + +/** A label and its current value on one line -- the shape every read-only readout here uses. */ +@Composable +fun ReadoutRow(label: String, value: String) { + Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + Text( + value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt b/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt new file mode 100644 index 0000000..b387a2c --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt @@ -0,0 +1,109 @@ +package org.terst.listen.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.terst.listen.data.SubscriptionEntity + +/** + * Everything that is a setting for one show, in a sheet rather than stacked above its episodes. + * + * These used to sit inline at the top of the show screen, which meant scrolling past drip + * cadence and two paragraphs of explanation to reach the episodes -- the thing the screen is + * for. Peter's call, and the prose went with it: this is a single-user app and he wrote the + * requirements. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShowSettingsSheet( + show: SubscriptionEntity, + settings: ShowSettingsViewModel, + onDismiss: () -> Unit, +) { + val busy by settings.busy.collectAsState() + val detection by settings.detection.collectAsState() + + // Read once per opening. Nothing can move it while the sheet is up: detection only advances + // in a background worker. + LaunchedEffect(show.id) { settings.loadDetection(show.id) } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState()) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(show.title, style = MaterialTheme.typography.titleMedium) + + SwitchRow( + label = "Bedtime", + description = "Move this show in or out of bedtime", + checked = show.bedtime, + detail = if (show.bedtime) "Only listed in bedtime mode" else "In the main library", + onCheckedChange = { settings.setBedtime(show.id, it) }, + ) + + HorizontalDivider() + + DripSettings( + subscription = show, + busy = busy, + onEnable = { intervalMs, count, oldestFirst -> + settings.enableDrip(show.id, intervalMs, count, oldestFirst) + }, + onDisable = { settings.disableDrip(show.id) }, + onFetchArchive = { settings.fetchArchive(show.id) }, + ) + + HorizontalDivider() + + SkipSettings( + subscription = show, + onChange = { introMs, outroMs -> settings.setSkips(show.id, introMs, outroMs) }, + onSkipAdChapters = { settings.setSkipAdChapters(show.id, it) }, + ) + + HorizontalDivider() + + DetectionReadout(detection) + } + } +} + +/** + * What the automatic detection has worked out for this show. + * + * Read-only, and the point of it: until this existed, a show whose intro had been confirmed and + * one that had never been looked at were indistinguishable -- both simply played. + */ +@Composable +private fun DetectionReadout(detection: ShowDetection?) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Detected skips", style = MaterialTheme.typography.titleSmall) + if (detection == null) { + Text("Reading…", style = MaterialTheme.typography.bodySmall) + return@Column + } + ReadoutRow("Intro", detection.intro) + ReadoutRow("Outro", detection.outro) + ReadoutRow("Ad breaks", detection.midrolls) + } +} diff --git a/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt b/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt index 1e972f9..a84fa40 100644 --- a/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt +++ b/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt @@ -8,7 +8,11 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.terst.listen.data.ArchiveOutcome +import org.terst.listen.data.FingerprintKind import org.terst.listen.data.Graph +import org.terst.listen.fingerprint.IntroOutroDetector +import org.terst.listen.fingerprint.MidrollDetector +import org.terst.listen.fingerprint.SkipSummary /** * An archive walk, reported in full. @@ -109,4 +113,50 @@ class ShowSettingsViewModel(app: Application) : AndroidViewModel(app) { fun setSkipAdChapters(subscriptionId: String, enabled: Boolean) { viewModelScope.launch { graph.subscriptions.setSkipAdChapters(subscriptionId, enabled) } } + + /** Moves a show in or out of the bedtime list. */ + fun setBedtime(subscriptionId: String, enabled: Boolean) { + viewModelScope.launch { + graph.subscriptions.setBedtime(subscriptionId, enabled) + _banner.value = if (enabled) { + Banner("Bedtime — hidden until you switch to bedtime mode") + } else { + Banner("Back in the main library") + } + } + } + + private val _detection = MutableStateFlow(null) + + /** What Tier 3 has worked out for the show currently on screen. */ + val detection: StateFlow = _detection.asStateFlow() + + /** + * Read on demand rather than observed. + * + * Detection only ever moves when a background worker runs, which is per download for + * intro/outro and nightly for midrolls -- never while this sheet is open. A Flow would buy + * nothing and would mean three more queries running for every show screen. + */ + fun loadDetection(subscriptionId: String) { + viewModelScope.launch { + _detection.value = ShowDetection( + intro = SkipSummary.candidateState( + graph.fingerprints.get(subscriptionId, FingerprintKind.INTRO), + IntroOutroDetector.CONFIRM_THRESHOLD, + ), + outro = SkipSummary.candidateState( + graph.fingerprints.get(subscriptionId, FingerprintKind.OUTRO), + IntroOutroDetector.CONFIRM_THRESHOLD, + ), + midrolls = SkipSummary.midrollState( + graph.midrolls.getCandidates(subscriptionId), + MidrollDetector.CONFIRM_THRESHOLD, + ), + ) + } + } } + +/** The three lines of Tier 3 state a show has, already worded. */ +data class ShowDetection(val intro: String, val outro: String, val midrolls: String) diff --git a/app/src/main/java/org/terst/listen/ui/SkipSettings.kt b/app/src/main/java/org/terst/listen/ui/SkipSettings.kt index 106e3fd..fdee5d0 100644 --- a/app/src/main/java/org/terst/listen/ui/SkipSettings.kt +++ b/app/src/main/java/org/terst/listen/ui/SkipSettings.kt @@ -4,17 +4,12 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import org.terst.listen.data.SubscriptionEntity @@ -22,11 +17,11 @@ import org.terst.listen.data.SubscriptionEntity private const val MS_PER_SECOND = 1000L /** - * Per-show intro and outro offsets. + * Per-show intro and outro offsets, and the chapter-break opt-in. * * The cheap tier of the skip feature, and the one that always works: a show whose theme runs * forty-seven seconds gets forty-seven seconds skipped, with no detection involved. It is also - * the override for when detection eventually arrives and gets a show wrong. + * the override for when Tier 3 gets a show wrong. */ @Composable fun SkipSettings( @@ -35,7 +30,7 @@ fun SkipSettings( onSkipAdChapters: (Boolean) -> Unit, ) { Column( - Modifier.fillMaxWidth().padding(horizontal = 16.dp), + Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text("Skip", style = MaterialTheme.typography.titleSmall) @@ -51,25 +46,11 @@ fun SkipSettings( modifier = Modifier.weight(1f), ) { onChange(subscription.skipIntroMs, it) } } - Text( - "Starts each new episode past the intro and treats it as finished before the outro. " + - "Resuming something part-heard still lands where you left it.", - style = MaterialTheme.typography.bodySmall, - ) - - Row(verticalAlignment = Alignment.CenterVertically) { - Switch( - modifier = Modifier.semantics { contentDescription = "Skip marked ad chapters" }, - checked = subscription.skipAdChapters, - onCheckedChange = onSkipAdChapters, - ) - Text("Skip marked breaks", Modifier.padding(start = 12.dp)) - } - Text( - "On by default. Only acts where the show publishes chapters and marks a section " + - "as not part of the episode -- nothing is guessed from chapter titles, so a " + - "show discussing its own sponsors keeps playing.", - style = MaterialTheme.typography.bodySmall, + SwitchRow( + label = "Skip marked breaks", + description = "Skip marked ad chapters", + checked = subscription.skipAdChapters, + onCheckedChange = onSkipAdChapters, ) } } diff --git a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt index 6018e56..c93f353 100644 --- a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt @@ -15,11 +15,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -66,6 +69,7 @@ fun SubscriptionScreen( nav: SubscriptionNav, ) { var confirming by remember { mutableStateOf(false) } + var showingSettings by remember { mutableStateOf(false) } val refreshing by vm.refreshing.collectAsState() val nowPlaying by player.nowPlaying.collectAsState() val subscription = vm.subscriptions.collectAsState().value.firstOrNull { it.id == subscriptionId } @@ -83,9 +87,12 @@ fun SubscriptionScreen( SubscriptionTopBar( title = subscription?.title, refreshing = refreshing, - onRefresh = { vm.refresh(subscriptionId) }, - onUnsubscribe = { confirming = true }, - onBack = nav.back, + actions = SubscriptionTopBarActions( + refresh = { vm.refresh(subscriptionId) }, + openSettings = { showingSettings = true }, + unsubscribe = { confirming = true }, + back = nav.back, + ), ) }, snackbarHost = { SnackbarHost(snackbar) { Snackbar(it) } }, @@ -111,10 +118,10 @@ fun SubscriptionScreen( modifier = Modifier.padding(padding), ) { PlayedToggle(showPlayed, vm::setShowPlayed) - subscription?.let { show -> - HorizontalDivider(Modifier.padding(vertical = 12.dp)) - ShowSettings(show, settings) - } + } + + subscription?.takeIf { showingSettings }?.let { show -> + ShowSettingsSheet(show, settings) { showingSettings = false } } } } @@ -192,50 +199,16 @@ private fun SubscriptionBody( } } -/** Slow drip and the skip offsets, which are settings for this show rather than for the app. */ -@Composable -private fun ShowSettings(show: SubscriptionEntity, settings: ShowSettingsViewModel) { - val busy by settings.busy.collectAsState() - - DripSettings( - subscription = show, - busy = busy, - onEnable = { intervalMs, count, oldestFirst -> - settings.enableDrip(show.id, intervalMs, count, oldestFirst) - }, - onDisable = { settings.disableDrip(show.id) }, - onFetchArchive = { settings.fetchArchive(show.id) }, - ) - HorizontalDivider(Modifier.padding(vertical = 12.dp)) - SkipSettings( - subscription = show, - onChange = { introMs, outroMs -> settings.setSkips(show.id, introMs, outroMs) }, - onSkipAdChapters = { settings.setSkipAdChapters(show.id, it) }, - ) - HorizontalDivider(Modifier.padding(top = 12.dp)) -} - /** A view preference rather than a show setting, so it says so and applies everywhere. */ @Composable private fun PlayedToggle(showPlayed: Boolean, onShowPlayed: (Boolean) -> Unit) { - Row( - Modifier.fillMaxWidth().padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Switch( - modifier = Modifier.semantics { // Distinct from the visible label: identical strings make the switch ambiguous to - // pick, for a screen reader and for the UI checker alike. - contentDescription = "Show or hide played episodes" }, + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + SwitchRow( + label = "Show played episodes", + description = "Show or hide played episodes", checked = showPlayed, onCheckedChange = onShowPlayed, ) - Column(Modifier.padding(start = 12.dp)) { - Text("Show played episodes", style = MaterialTheme.typography.titleSmall) - Text( - if (showPlayed) "Listed in every show." else "Hidden in every show.", - style = MaterialTheme.typography.bodySmall, - ) - } } } @@ -295,28 +268,52 @@ data class SubscriptionNav( val back: () -> Unit, ) +/** The top bar's four callbacks, bundled so the signature stays readable. */ +private data class SubscriptionTopBarActions( + val refresh: () -> Unit, + val openSettings: () -> Unit, + val unsubscribe: () -> Unit, + val back: () -> Unit, +) + +/** + * Refresh stays an icon; everything else is behind the overflow. + * + * Unsubscribe in particular: it was one tap from a bin icon next to Refresh, which is a hard + * action to undo sitting next to one you reach for constantly. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable private fun SubscriptionTopBar( title: String?, refreshing: Boolean, - onRefresh: () -> Unit, - onUnsubscribe: () -> Unit, - onBack: () -> Unit, + actions: SubscriptionTopBarActions, ) { + var expanded by remember { mutableStateOf(false) } + TopAppBar( title = { Text(title ?: "Podcast", maxLines = 1, overflow = TextOverflow.Ellipsis) }, navigationIcon = { - IconButton(onClick = onBack) { + IconButton(onClick = actions.back) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, actions = { - IconButton(onClick = onRefresh, enabled = !refreshing) { + IconButton(onClick = actions.refresh, enabled = !refreshing) { Icon(Icons.Default.Refresh, contentDescription = "Refresh this podcast") } - IconButton(onClick = onUnsubscribe) { - Icon(Icons.Default.Delete, contentDescription = "Unsubscribe") + IconButton(onClick = { expanded = true }) { + Icon(Icons.Default.MoreVert, contentDescription = "Podcast menu") + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text("Settings") }, + onClick = { expanded = false; actions.openSettings() }, + ) + DropdownMenuItem( + text = { Text("Unsubscribe") }, + onClick = { expanded = false; actions.unsubscribe() }, + ) } }, ) diff --git a/app/src/main/java/org/terst/listen/work/Workers.kt b/app/src/main/java/org/terst/listen/work/Workers.kt index a085070..6ce26c6 100644 --- a/app/src/main/java/org/terst/listen/work/Workers.kt +++ b/app/src/main/java/org/terst/listen/work/Workers.kt @@ -84,7 +84,7 @@ class FingerprintWorker(context: Context, params: WorkerParameters) : CoroutineW val graph = Graph.get(applicationContext) val episode = graph.episodes.getById(episodeId) ?: return Result.success() - FingerprintUpdater(graph.fingerprints, MediaCodecClipSource()).process(episode) + FingerprintUpdater(graph.fingerprints, graph.episodes, MediaCodecClipSource()).process(episode) return Result.success() } 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 16f46f4..863f265 100644 --- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt +++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt @@ -12,7 +12,13 @@ import kotlinx.coroutines.flow.flowOf class FakeSubscriptionDao : SubscriptionDao { val rows = mutableListOf() - override fun observeAll(): Flow> = flowOf(rows.toList()) + // Ordering and hasWaiting are both SQL, so neither is reproduced here -- LibraryOrderTest + // pins them against a real database instead. This only honours the bedtime split, which + // callers do branch on. + override fun observeAll(bedtime: Boolean): Flow> = + flowOf(rows.filter { it.bedtime == bedtime }.map { SubscriptionListing(it, hasWaiting = true) }) + + override fun observeEvery(): Flow> = flowOf(rows.toList()) override suspend fun getAll() = rows.toList() override suspend fun getById(id: String) = rows.firstOrNull { it.id == id } override suspend fun getByFeedUrl(feedUrl: String) = rows.firstOrNull { it.feedUrl == feedUrl } @@ -48,6 +54,10 @@ class FakeSubscriptionDao : SubscriptionDao { replace(id) { it.copy(skipAdChapters = enabled) } } + override suspend fun setBedtime(id: String, enabled: Boolean) { + replace(id) { it.copy(bedtime = enabled) } + } + override suspend fun updateSkips(id: String, introMs: Long, outroMs: Long) { replace(id) { it.copy(skipIntroMs = introMs, skipOutroMs = outroMs) } } diff --git a/app/src/test/java/org/terst/listen/data/ShowFingerprintEntityTest.kt b/app/src/test/java/org/terst/listen/data/ShowFingerprintEntityTest.kt index 0cc9d7f..2139215 100644 --- a/app/src/test/java/org/terst/listen/data/ShowFingerprintEntityTest.kt +++ b/app/src/test/java/org/terst/listen/data/ShowFingerprintEntityTest.kt @@ -12,12 +12,11 @@ import org.junit.Test */ class ShowFingerprintEntityTest { - private fun entity(pcm: ByteArray?, pending: ByteArray? = null) = ShowFingerprintEntity( + private fun entity(pcm: ByteArray?, pendingEpisodeId: String? = null) = ShowFingerprintEntity( subscriptionId = "s1", kind = FingerprintKind.INTRO, referenceClipPcm = pcm, - pendingClipPcm = pending, - pendingEpisodeId = null, + pendingEpisodeId = pendingEpisodeId, sampleRate = 8_000, confirmedCount = 3, lastConfirmedEpisodeId = "e1", @@ -43,15 +42,15 @@ class ShowFingerprintEntityTest { } @Test - fun `two rows with both clips null are equal`() { - assertEquals(entity(null, null), entity(null, null)) + fun `two rows with no clip at all are equal`() { + assertEquals(entity(null), entity(null)) } @Test - fun `differing pending clips are not equal even when the reference clip matches`() { + fun `differing pending episodes are not equal even when the reference clip matches`() { assertNotEquals( - entity(byteArrayOf(1, 2, 3), pending = byteArrayOf(9)), - entity(byteArrayOf(1, 2, 3), pending = byteArrayOf(8)), + entity(byteArrayOf(1, 2, 3), pendingEpisodeId = "e9"), + entity(byteArrayOf(1, 2, 3), pendingEpisodeId = "e8"), ) } } diff --git a/app/src/test/java/org/terst/listen/data/StoredClipSizeTest.kt b/app/src/test/java/org/terst/listen/data/StoredClipSizeTest.kt new file mode 100644 index 0000000..7992098 --- /dev/null +++ b/app/src/test/java/org/terst/listen/data/StoredClipSizeTest.kt @@ -0,0 +1,60 @@ +package org.terst.listen.data + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.terst.listen.fingerprint.FingerprintUpdater +import org.terst.listen.fingerprint.MediaCodecClipSource +import org.terst.listen.fingerprint.StoredClip + +/** + * Guards the bug class that took Tier 3 down: a blob big enough to write but too big to read. + * + * Android reads every Room row through a CursorWindow, and a row over 2MB throws + * `SQLiteBlobTooBigException` on the way *out*. Nothing complains on insert, nothing complains + * at build time, and the unit tests were perfectly happy -- it only surfaced on a device, as a + * hard crash the moment a show's second episode was fingerprinted. + * + * Decoded audio is the only thing in this schema anywhere near that size, so these pin the two + * clips that get persisted against the limit rather than trusting anyone to remember it. + */ +class StoredClipSizeTest { + + private fun encodedBytes(seconds: Int, sampleRate: Int) = seconds * sampleRate * 2 + + /** The reference clip is short by design, and is the one fingerprint blob that is kept. */ + @Test + fun aStoredFingerprintReferenceClipFitsInARow() { + val bytes = encodedBytes(FingerprintUpdater.DEFAULT_REFERENCE_SECONDS, FingerprintUpdater.DEFAULT_SAMPLE_RATE) + + assertTrue("reference clip is $bytes bytes", StoredClip.fits(bytes)) + } + + /** + * The whole head/tail window emphatically does not fit, which is why only the pending + * *episode id* is stored and the audio re-decoded. If this ever starts passing, the window + * shrank and the comment explaining that indirection needs revisiting -- it does not mean + * the clip may now be stored. + */ + @Test + fun awholeHeadWindowDoesNotFitWhichIsWhyItIsNeverStored() { + val bytes = encodedBytes(MediaCodecClipSource.CLIP_SECONDS, MediaCodecClipSource.TARGET_SAMPLE_RATE) + + assertFalse("head window is $bytes bytes", StoredClip.fits(bytes)) + } + + /** A midroll candidate is a matched segment of unbounded length, so it has to be capped. */ + @Test + fun aMidrollCandidateIsCappedBelowTheLimit() { + val cap = encodedBytes(StoredClip.MAX_CANDIDATE_SECONDS, MediaCodecClipSource.TARGET_SAMPLE_RATE) + + assertTrue("cap is $cap bytes", StoredClip.fits(cap)) + } + + /** Half the hard limit, so the rest of the row can never tip an accepted clip over it. */ + @Test + fun theSafeSizeLeavesHeadroomUnderTheHardLimit() { + assertTrue(StoredClip.SAFE_BYTES * 2 <= StoredClip.CURSOR_WINDOW_LIMIT_BYTES) + assertFalse(StoredClip.fits(StoredClip.SAFE_BYTES + 1)) + } +} diff --git a/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt b/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt index 4881f88..7dd7d1d 100644 --- a/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt +++ b/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt @@ -25,7 +25,7 @@ class FingerprintUpdaterTest { episodes = FakeEpisodeDao() fingerprints = FakeShowFingerprintDao().apply { episodeRows = episodes.rows } clips = FakeClipSource() - updater = FingerprintUpdater(fingerprints, clips, referenceLengthSamples = 40) + updater = FingerprintUpdater(fingerprints, episodes, clips, referenceLengthSamples = 40) } private fun noise(seed: Int, n: Int) = Random(seed).let { r -> DoubleArray(n) { r.nextDouble(-50.0, 50.0) } } diff --git a/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt b/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt new file mode 100644 index 0000000..bf351ae --- /dev/null +++ b/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt @@ -0,0 +1,209 @@ +package org.terst.listen.fingerprint + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.terst.listen.data.EpisodeEntity +import org.terst.listen.data.EpisodeMidrollEntity +import org.terst.listen.data.FingerprintKind +import org.terst.listen.data.ShowFingerprintEntity +import org.terst.listen.data.ShowMidrollEntity +import org.terst.listen.data.SubscriptionEntity + +/** + * The words the app uses to say what the skip detection has worked out. + * + * Worth pinning rather than writing inline in a composable: the whole point of this readout is + * that Peter can tell "confirmed and acting" apart from "still gathering evidence", and a + * summary that blurred the two would be worse than no readout at all. + */ +class SkipSummaryTest { + + private fun fingerprint( + confirmedCount: Int, + hasReference: Boolean = true, + pendingEpisodeId: String? = null, + ) = ShowFingerprintEntity( + subscriptionId = "s1", + kind = FingerprintKind.INTRO, + referenceClipPcm = if (hasReference) byteArrayOf(1, 2) else null, + pendingEpisodeId = pendingEpisodeId, + sampleRate = 8000, + confirmedCount = confirmedCount, + lastConfirmedEpisodeId = null, + updatedAtMs = 0, + ) + + private fun episodeRow( + detectedIntroMs: Long? = null, + detectedOutroMs: Long? = null, + midrollCheckedAtMs: Long? = null, + ) = EpisodeEntity( + id = "e1", + subscriptionId = "s1", + guid = "g1", + title = "One", + mediaUrl = "https://cdn/1.mp3", + mediaType = "audio/mpeg", + discoveredAtMs = 0, + detectedIntroMs = detectedIntroMs, + detectedOutroMs = detectedOutroMs, + midrollCheckedAtMs = midrollCheckedAtMs, + ) + + private fun showRow(skipIntroMs: Long = 0, skipOutroMs: Long = 0) = SubscriptionEntity( + id = "s1", + feedUrl = "https://example.com/feed.xml", + title = "A Show", + addedAtMs = 0, + skipIntroMs = skipIntroMs, + skipOutroMs = skipOutroMs, + ) + + private fun candidate(id: String, confirmedCount: Int) = ShowMidrollEntity( + subscriptionId = "s1", + id = id, + referenceClipPcm = byteArrayOf(1, 2), + sampleRate = 8000, + confirmedCount = confirmedCount, + lastConfirmedEpisodeId = null, + updatedAtMs = 0, + ) + + @Test + fun nothingSeenYet() { + assertEquals("Nothing found yet", SkipSummary.candidateState(null, threshold = 3)) + } + + /** One episode in, there is a clip set aside but nothing to compare it against. */ + @Test + fun waitingForASecondEpisode() { + assertEquals( + "Nothing found yet", + SkipSummary.candidateState(fingerprint(0, hasReference = false, pendingEpisodeId = "e1"), 3), + ) + } + + @Test + fun gatheringNamesHowFarOffItIs() { + assertEquals("Found, 2 of 3 episodes agree", SkipSummary.candidateState(fingerprint(2), 3)) + } + + /** The distinction that matters: below the threshold nothing is being skipped. */ + @Test + fun confirmedSaysSo() { + assertEquals("Confirmed by 3 episodes", SkipSummary.candidateState(fingerprint(3), 3)) + } + + @Test + fun confirmedKeepsCountingPastTheThreshold() { + assertEquals("Confirmed by 7 episodes", SkipSummary.candidateState(fingerprint(7), 3)) + } + + @Test + fun noMidrollCandidates() { + assertEquals("None found yet", SkipSummary.midrollState(emptyList(), threshold = 3)) + } + + @Test + fun midrollsCountBothStates() { + val candidates = listOf(candidate("a", 4), candidate("b", 3), candidate("c", 2)) + assertEquals("2 confirmed, 1 still gathering", SkipSummary.midrollState(candidates, 3)) + } + + @Test + fun midrollsGatheringOnly() { + assertEquals("1 still gathering", SkipSummary.midrollState(listOf(candidate("a", 2)), 3)) + } + + @Test + fun midrollsConfirmedOnly() { + assertEquals("1 confirmed", SkipSummary.midrollState(listOf(candidate("a", 3)), 3)) + } + + /** Detection wins over the manual offset, and the readout has to say which is in force. */ + @Test + fun offsetNamesWhereTheNumberCameFrom() { + val detected = SkipSummary.episode(episodeRow(detectedIntroMs = 47_000), showRow(skipIntroMs = 30_000), emptyList()) + assertEquals("47s, detected", detected.intro) + + val manual = SkipSummary.episode(episodeRow(), showRow(skipIntroMs = 30_000), emptyList()) + assertEquals("30s, set by hand", manual.intro) + + val neither = SkipSummary.episode(episodeRow(), showRow(), emptyList()) + assertEquals("None", neither.intro) + } + + /** A detected zero is a real answer -- this show has no intro -- not a missing one. */ + @Test + fun aDetectedZeroIsNotTheSameAsNothingDetected() { + val skips = SkipSummary.episode(episodeRow(detectedIntroMs = 0), showRow(skipIntroMs = 30_000), emptyList()) + + assertEquals("None, detected", skips.intro) + } + + /** Earliest first, whatever order the rows come back in. */ + @Test + fun rangesReadAsClockTimes() { + val ranges = listOf( + EpisodeMidrollEntity(episodeId = "e1", candidateId = "a", startMs = 750_000, endMs = 825_000), + EpisodeMidrollEntity(episodeId = "e1", candidateId = "b", startMs = 61_000, endMs = 91_500), + ) + + assertEquals( + listOf("1:01 - 1:31", "12:30 - 13:45"), + SkipSummary.episode(episodeRow(), showRow(), ranges).breaks, + ) + } + + /** + * "Not checked yet" and "none found" are different answers. + * + * Midroll scanning runs nightly while charging, so a freshly downloaded episode has + * legitimately not been looked at -- reporting that as "no ad breaks" would read as the + * detection having run and found nothing, which is the opposite of true. + */ + @Test + fun anUncheckedEpisodeSaysSoRatherThanClaimingNoBreaks() { + val skips = SkipSummary.episode(episodeRow(), showRow(), ranges = emptyList()) + + assertEquals(listOf("Not checked yet"), skips.breaks) + } + + @Test + fun aCheckedEpisodeWithNothingInItSaysNoneFound() { + val skips = SkipSummary.episode( + episodeRow(midrollCheckedAtMs = 1_000), + showRow(), + ranges = emptyList(), + ) + + assertEquals(listOf("None found"), skips.breaks) + } + + @Test + fun episodeReportsTheOffsetsActuallyInForce() { + val skips = SkipSummary.episode( + episodeRow(detectedIntroMs = 47_000, midrollCheckedAtMs = 1_000), + showRow(skipIntroMs = 30_000, skipOutroMs = 20_000), + ranges = listOf( + EpisodeMidrollEntity(episodeId = "e1", candidateId = "a", startMs = 61_000, endMs = 91_500) + ), + ) + + assertEquals("47s, detected", skips.intro) + assertEquals("20s, set by hand", skips.outro) + assertEquals(listOf("1:01 - 1:31"), skips.breaks) + } + + @Test + fun rangesPastAnHourKeepTheHour() { + val ranges = listOf( + EpisodeMidrollEntity(episodeId = "e1", candidateId = "a", startMs = 3_723_000, endMs = 3_780_000) + ) + + assertEquals( + listOf("1:02:03 - 1:03:00"), + SkipSummary.episode(episodeRow(), showRow(), ranges).breaks, + ) + } +} diff --git a/app/src/test/java/org/terst/listen/playback/BedtimePolicyTest.kt b/app/src/test/java/org/terst/listen/playback/BedtimePolicyTest.kt new file mode 100644 index 0000000..e8bc0a3 --- /dev/null +++ b/app/src/test/java/org/terst/listen/playback/BedtimePolicyTest.kt @@ -0,0 +1,48 @@ +package org.terst.listen.playback + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * What entering and leaving bedtime mode does to the sleep timer. + * + * Four cases, and two of them are the reason this is a class rather than an `if` in the view + * model: stomping a timer the listener set by hand, and leaving one armed after they have gone + * back to ordinary listening. + */ +class BedtimePolicyTest { + + @Test + fun enteringArmsTheTimer() { + assertEquals( + BedtimeAction.ArmSleepTimer(BedtimePolicy.DEFAULT_MINUTES), + BedtimePolicy.entering(timerAlreadyArmed = false), + ) + } + + /** A timer set by hand was set deliberately; bedtime mode must not overwrite the duration. */ + @Test + fun enteringLeavesATimerAlreadyRunningAlone() { + assertEquals(BedtimeAction.Nothing, BedtimePolicy.entering(timerAlreadyArmed = true)) + } + + @Test + fun leavingCancelsTheTimerBedtimeArmed() { + assertEquals(BedtimeAction.CancelSleepTimer, BedtimePolicy.leaving(bedtimeArmedIt = true)) + } + + /** + * The inverse of the case above. Leaving bedtime mode must not cancel a timer it never + * set -- otherwise flicking the moon icon on and off silently kills a manual sleep timer. + */ + @Test + fun leavingLeavesAManualTimerRunning() { + assertEquals(BedtimeAction.Nothing, BedtimePolicy.leaving(bedtimeArmedIt = false)) + } + + /** Long rather than short, deliberately: a timer that stops early is the worse failure. */ + @Test + fun theDefaultOutlastsATypicalSleepStory() { + assertEquals(45L, BedtimePolicy.DEFAULT_MINUTES) + } +} -- cgit v1.2.3