mirror of
https://github.com/GrakovNe/lissen-android.git
synced 2026-08-01 10:58:32 -04:00
chore: automated stabilization pass (5 review rounds, 51 fixes) (#468)
This commit is contained in:
@@ -0,0 +1,437 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 21,
|
||||
"identityHash": "b5bacfcab06282fe9d1d6af28cb7e9ca",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "detailed_books",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `subtitle` TEXT, `author` TEXT, `narrator` TEXT, `year` TEXT, `abstract` TEXT, `publisher` TEXT, `duration` INTEGER NOT NULL, `libraryId` TEXT, `seriesJson` TEXT, `seriesNames` TEXT, `seriesId` TEXT, `authorsJson` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subtitle",
|
||||
"columnName": "subtitle",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "author",
|
||||
"columnName": "author",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "narrator",
|
||||
"columnName": "narrator",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "year",
|
||||
"columnName": "year",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "abstract",
|
||||
"columnName": "abstract",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "publisher",
|
||||
"columnName": "publisher",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "duration",
|
||||
"columnName": "duration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "libraryId",
|
||||
"columnName": "libraryId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "seriesJson",
|
||||
"columnName": "seriesJson",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "seriesNames",
|
||||
"columnName": "seriesNames",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "seriesId",
|
||||
"columnName": "seriesId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "authorsJson",
|
||||
"columnName": "authorsJson",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_detailed_books_libraryId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"libraryId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_detailed_books_libraryId` ON `${TABLE_NAME}` (`libraryId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_detailed_books_seriesId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"seriesId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_detailed_books_seriesId` ON `${TABLE_NAME}` (`seriesId`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "book_files",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookFileId` TEXT NOT NULL, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `duration` REAL NOT NULL, `mimeType` TEXT NOT NULL, `bookId` TEXT NOT NULL, FOREIGN KEY(`bookId`) REFERENCES `detailed_books`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookFileId",
|
||||
"columnName": "bookFileId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "size",
|
||||
"columnName": "size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "duration",
|
||||
"columnName": "duration",
|
||||
"affinity": "REAL",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookId",
|
||||
"columnName": "bookId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_book_files_bookId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"bookId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_book_files_bookId` ON `${TABLE_NAME}` (`bookId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "detailed_books",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"bookId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "book_chapters",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookChapterId` TEXT NOT NULL, `duration` REAL NOT NULL, `start` REAL NOT NULL, `end` REAL NOT NULL, `title` TEXT NOT NULL, `bookId` TEXT NOT NULL, `isCached` INTEGER NOT NULL, FOREIGN KEY(`bookId`) REFERENCES `detailed_books`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookChapterId",
|
||||
"columnName": "bookChapterId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "duration",
|
||||
"columnName": "duration",
|
||||
"affinity": "REAL",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "start",
|
||||
"columnName": "start",
|
||||
"affinity": "REAL",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "end",
|
||||
"columnName": "end",
|
||||
"affinity": "REAL",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookId",
|
||||
"columnName": "bookId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isCached",
|
||||
"columnName": "isCached",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_book_chapters_bookId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"bookId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_book_chapters_bookId` ON `${TABLE_NAME}` (`bookId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "detailed_books",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"bookId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "media_progress",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookId` TEXT NOT NULL, `currentTime` REAL NOT NULL, `isFinished` INTEGER NOT NULL, `lastUpdate` INTEGER NOT NULL, PRIMARY KEY(`bookId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "bookId",
|
||||
"columnName": "bookId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "currentTime",
|
||||
"columnName": "currentTime",
|
||||
"affinity": "REAL",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isFinished",
|
||||
"columnName": "isFinished",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastUpdate",
|
||||
"columnName": "lastUpdate",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"bookId"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_media_progress_bookId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"bookId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_media_progress_bookId` ON `${TABLE_NAME}` (`bookId`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "libraries",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_bookmark",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `libraryItemId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `totalPosition` INTEGER NOT NULL, `syncState` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "libraryItemId",
|
||||
"columnName": "libraryItemId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "totalPosition",
|
||||
"columnName": "totalPosition",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "syncState",
|
||||
"columnName": "syncState",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_cached_bookmark_libraryItemId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"libraryItemId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_cached_bookmark_libraryItemId` ON `${TABLE_NAME}` (`libraryItemId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_cached_bookmark_createdAt",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"createdAt"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_cached_bookmark_createdAt` ON `${TABLE_NAME}` (`createdAt`)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"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, 'b5bacfcab06282fe9d1d6af28cb7e9ca')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ class CachedBookGroupingTest {
|
||||
insert("o1", "Other", "ser-other", """[{"title":"Other","sequence":"1","id":"ser-other"}]""")
|
||||
insert("z1", "Zulu")
|
||||
|
||||
val all = repository.fetchLibraryGrouped(LIBRARY, pageSize = 20, pageNumber = 0)
|
||||
val all = repository.fetchLibraryGrouped(LIBRARY, pageSize = 20, pageNumber = 0, libraryType = null)
|
||||
assertEquals(4, all.totalItems)
|
||||
assertEquals(listOf("a1", "ser-dune", "ser-other", "z1"), all.items.map { it.keyOf() })
|
||||
|
||||
@@ -103,10 +103,10 @@ class CachedBookGroupingTest {
|
||||
assertEquals("Dune", dune.title)
|
||||
assertEquals(listOf("d1", "d2", "d3"), dune.coverItemIds)
|
||||
|
||||
val page0 = repository.fetchLibraryGrouped(LIBRARY, pageSize = 2, pageNumber = 0)
|
||||
val page0 = repository.fetchLibraryGrouped(LIBRARY, pageSize = 2, pageNumber = 0, libraryType = null)
|
||||
assertEquals(listOf("a1", "ser-dune"), page0.items.map { it.keyOf() })
|
||||
|
||||
val page1 = repository.fetchLibraryGrouped(LIBRARY, pageSize = 2, pageNumber = 1)
|
||||
val page1 = repository.fetchLibraryGrouped(LIBRARY, pageSize = 2, pageNumber = 1, libraryType = null)
|
||||
assertEquals(listOf("ser-other", "z1"), page1.items.map { it.keyOf() })
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ class CachedBookGroupingTest {
|
||||
insert("d2", "Dune B", "ser-dune")
|
||||
insert("z1", "Zulu")
|
||||
|
||||
val books = repository.fetchSeriesItems(LIBRARY, "ser-dune")
|
||||
val books = repository.fetchSeriesItems(LIBRARY, "ser-dune", libraryType = null)
|
||||
assertEquals(setOf("d1", "d2"), books.map { it.id }.toSet())
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import io.mockk.mockk
|
||||
import org.grakovne.lissen.channel.common.OperationError
|
||||
import org.grakovne.lissen.channel.common.OperationResult
|
||||
import org.grakovne.lissen.content.LissenMediaProvider
|
||||
import org.grakovne.lissen.playback.service.apply
|
||||
import org.grakovne.lissen.playback.service.unapply
|
||||
import org.grakovne.lissen.playback.service.parseLissenUri
|
||||
import org.grakovne.lissen.playback.service.toLissenUri
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -24,7 +24,7 @@ class LissenDataSourceFactoryIntegrationTest {
|
||||
|
||||
@Test
|
||||
fun apply_creates_valid_lissen_uri() {
|
||||
val uri = apply("book-123", "file-456")
|
||||
val uri = toLissenUri("book-123", "file-456")
|
||||
assertEquals("lissen", uri.scheme)
|
||||
assertEquals(2, uri.pathSegments.size)
|
||||
assertEquals("book-123", uri.pathSegments[0])
|
||||
@@ -33,8 +33,8 @@ class LissenDataSourceFactoryIntegrationTest {
|
||||
|
||||
@Test
|
||||
fun unapply_parses_valid_lissen_uri() {
|
||||
val uri = apply("book-abc", "file-xyz")
|
||||
val result = unapply(uri)
|
||||
val uri = toLissenUri("book-abc", "file-xyz")
|
||||
val result = parseLissenUri(uri)
|
||||
assertNotNull(result)
|
||||
assertEquals("book-abc", result!!.first)
|
||||
assertEquals("file-xyz", result.second)
|
||||
@@ -44,8 +44,8 @@ class LissenDataSourceFactoryIntegrationTest {
|
||||
fun apply_unapply_roundtrip() {
|
||||
val bookId = "li_abcdef123456"
|
||||
val fileId = "ino_ghijkl789012"
|
||||
val uri = apply(bookId, fileId)
|
||||
val (parsedBookId, parsedFileId) = unapply(uri)!!
|
||||
val uri = toLissenUri(bookId, fileId)
|
||||
val (parsedBookId, parsedFileId) = parseLissenUri(uri)!!
|
||||
assertEquals(bookId, parsedBookId)
|
||||
assertEquals(fileId, parsedFileId)
|
||||
}
|
||||
@@ -53,38 +53,38 @@ class LissenDataSourceFactoryIntegrationTest {
|
||||
@Test
|
||||
fun unapply_returns_null_for_http_uri() {
|
||||
val uri = Uri.parse("https://server/api/items/book1/file/file1")
|
||||
assertNull(unapply(uri))
|
||||
assertNull(parseLissenUri(uri))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unapply_returns_null_for_file_uri() {
|
||||
val uri = Uri.parse("file:///data/media_cache/book1/file1")
|
||||
assertNull(unapply(uri))
|
||||
assertNull(parseLissenUri(uri))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unapply_returns_null_for_lissen_uri_with_single_segment() {
|
||||
val uri = Uri.parse("lissen://book1")
|
||||
assertNull(unapply(uri))
|
||||
assertNull(parseLissenUri(uri))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unapply_returns_null_for_lissen_uri_with_three_segments() {
|
||||
val uri = Uri.parse("lissen://host/book1/file1/extra")
|
||||
val result = unapply(uri)
|
||||
val result = parseLissenUri(uri)
|
||||
// 3 segments → null
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uri_resolution_returns_local_file_uri_when_cached() {
|
||||
val lissenUri = apply("book-1", "file-1")
|
||||
val lissenUri = toLissenUri("book-1", "file-1")
|
||||
val localUri = Uri.parse("file:///data/media_cache/book-1/file-1")
|
||||
|
||||
every { mediaProvider.provideFileUri("book-1", "file-1") } returns
|
||||
OperationResult.Success(localUri)
|
||||
|
||||
val (bookId, fileId) = unapply(lissenUri)!!
|
||||
val (bookId, fileId) = parseLissenUri(lissenUri)!!
|
||||
val resolved =
|
||||
mediaProvider.provideFileUri(bookId, fileId).fold(
|
||||
onSuccess = { it },
|
||||
@@ -97,13 +97,13 @@ class LissenDataSourceFactoryIntegrationTest {
|
||||
|
||||
@Test
|
||||
fun uri_resolution_returns_remote_uri_when_not_cached() {
|
||||
val lissenUri = apply("book-1", "file-1")
|
||||
val lissenUri = toLissenUri("book-1", "file-1")
|
||||
val remoteUri = Uri.parse("https://server:8080/api/items/book-1/file/file-1")
|
||||
|
||||
every { mediaProvider.provideFileUri("book-1", "file-1") } returns
|
||||
OperationResult.Success(remoteUri)
|
||||
|
||||
val (bookId, fileId) = unapply(lissenUri)!!
|
||||
val (bookId, fileId) = parseLissenUri(lissenUri)!!
|
||||
val resolved =
|
||||
mediaProvider.provideFileUri(bookId, fileId).fold(
|
||||
onSuccess = { it },
|
||||
@@ -116,12 +116,12 @@ class LissenDataSourceFactoryIntegrationTest {
|
||||
|
||||
@Test
|
||||
fun uri_resolution_falls_back_to_lissen_uri_on_error() {
|
||||
val lissenUri = apply("book-1", "file-1")
|
||||
val lissenUri = toLissenUri("book-1", "file-1")
|
||||
|
||||
every { mediaProvider.provideFileUri("book-1", "file-1") } returns
|
||||
OperationResult.Error(OperationError.InternalError)
|
||||
|
||||
val (bookId, fileId) = unapply(lissenUri)!!
|
||||
val (bookId, fileId) = parseLissenUri(lissenUri)!!
|
||||
val resolved =
|
||||
mediaProvider.provideFileUri(bookId, fileId).fold(
|
||||
onSuccess = { it },
|
||||
@@ -133,7 +133,7 @@ class LissenDataSourceFactoryIntegrationTest {
|
||||
|
||||
@Test
|
||||
fun dataspec_preserves_position_after_uri_replacement() {
|
||||
val lissenUri = apply("book-1", "file-1")
|
||||
val lissenUri = toLissenUri("book-1", "file-1")
|
||||
val localUri = Uri.parse("file:///data/media_cache/book-1/file-1")
|
||||
|
||||
val original =
|
||||
@@ -155,7 +155,7 @@ class LissenDataSourceFactoryIntegrationTest {
|
||||
|
||||
@Test
|
||||
fun dataspec_preserves_length_after_uri_replacement() {
|
||||
val lissenUri = apply("book-1", "file-1")
|
||||
val lissenUri = toLissenUri("book-1", "file-1")
|
||||
val remoteUri = Uri.parse("https://server/api/items/book-1/file/file-1")
|
||||
|
||||
val original =
|
||||
@@ -181,8 +181,8 @@ class LissenDataSourceFactoryIntegrationTest {
|
||||
fun apply_handles_ids_with_underscores_and_dashes() {
|
||||
val bookId = "li_abc-123_def"
|
||||
val fileId = "ino_xyz-789_ghi"
|
||||
val uri = apply(bookId, fileId)
|
||||
val result = unapply(uri)
|
||||
val uri = toLissenUri(bookId, fileId)
|
||||
val result = parseLissenUri(uri)
|
||||
assertNotNull(result)
|
||||
assertEquals(bookId, result!!.first)
|
||||
assertEquals(fileId, result.second)
|
||||
@@ -190,8 +190,8 @@ class LissenDataSourceFactoryIntegrationTest {
|
||||
|
||||
@Test
|
||||
fun apply_handles_numeric_ids() {
|
||||
val uri = apply("123456", "789012")
|
||||
val result = unapply(uri)
|
||||
val uri = toLissenUri("123456", "789012")
|
||||
val result = parseLissenUri(uri)
|
||||
assertNotNull(result)
|
||||
assertEquals("123456", result!!.first)
|
||||
assertEquals("789012", result.second)
|
||||
|
||||
@@ -13,7 +13,7 @@ import io.mockk.mockk
|
||||
import org.grakovne.lissen.channel.common.OperationResult
|
||||
import org.grakovne.lissen.content.LissenMediaProvider
|
||||
import org.grakovne.lissen.playback.service.LocalFallbackDataSource
|
||||
import org.grakovne.lissen.playback.service.apply
|
||||
import org.grakovne.lissen.playback.service.toLissenUri
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
@@ -29,7 +29,7 @@ class LocalFallbackDataSourceTest {
|
||||
private val mediaProvider = mockk<LissenMediaProvider>()
|
||||
|
||||
private val payload = ByteArray(1000) { (it % 251).toByte() }
|
||||
private val lissenUri = apply("book-1", "file-1")
|
||||
private val lissenUri = toLissenUri("book-1", "file-1")
|
||||
private val remoteUri = Uri.parse("https://server:8080/api/items/book-1/file/file-1")
|
||||
|
||||
@Test
|
||||
|
||||
@@ -58,7 +58,7 @@ class MediaLibraryTreeTest {
|
||||
OperationResult.Error(OperationError.InternalError)
|
||||
coEvery { lissenMediaProvider.fetchBooks(any(), any(), any()) } returns
|
||||
OperationResult.Error(OperationError.InternalError)
|
||||
coEvery { localCacheRepository.fetchDetailedItems() } returns
|
||||
coEvery { localCacheRepository.fetchDetailedItems(any(), any()) } returns
|
||||
OperationResult.Error(OperationError.InternalError)
|
||||
|
||||
tree = MediaLibraryTree(context, playbackPreferences, libraryPreferences, localCacheRepository, lissenMediaProvider)
|
||||
@@ -231,7 +231,7 @@ class MediaLibraryTreeTest {
|
||||
@Test
|
||||
fun getChildren_downloads_returnsEmptyWhenRepositoryFails() =
|
||||
runBlocking {
|
||||
coEvery { localCacheRepository.fetchDetailedItems() } returns
|
||||
coEvery { localCacheRepository.fetchDetailedItems(any(), any()) } returns
|
||||
OperationResult.Error(OperationError.InternalError)
|
||||
val result = tree.getChildren("root/downloads", 0, 100).get()
|
||||
assertEquals(SessionResult.RESULT_SUCCESS, result.resultCode)
|
||||
@@ -241,7 +241,7 @@ class MediaLibraryTreeTest {
|
||||
@Test
|
||||
fun getChildren_downloads_returnsDownloadedBooks() =
|
||||
runBlocking {
|
||||
coEvery { localCacheRepository.fetchDetailedItems() } returns
|
||||
coEvery { localCacheRepository.fetchDetailedItems(any(), any()) } returns
|
||||
OperationResult.Success(
|
||||
PagedItems(
|
||||
items =
|
||||
|
||||
@@ -15,6 +15,8 @@ import org.grakovne.lissen.channel.audiobookshelf.common.converter.ConnectionInf
|
||||
import org.grakovne.lissen.channel.audiobookshelf.common.converter.LibraryResponseConverter
|
||||
import org.grakovne.lissen.channel.audiobookshelf.common.converter.PlaybackSessionResponseConverter
|
||||
import org.grakovne.lissen.channel.audiobookshelf.common.converter.RecentListeningResponseConverter
|
||||
import org.grakovne.lissen.channel.audiobookshelf.common.model.playback.DeviceInfo
|
||||
import org.grakovne.lissen.channel.audiobookshelf.common.model.playback.PlaybackStartRequest
|
||||
import org.grakovne.lissen.channel.common.ConnectionInfo
|
||||
import org.grakovne.lissen.channel.common.MediaChannel
|
||||
import org.grakovne.lissen.channel.common.OperationError
|
||||
@@ -132,4 +134,21 @@ abstract class AudiobookshelfChannel(
|
||||
.map { connectionInfoResponseConverter.apply(it) }
|
||||
|
||||
protected fun getClientName() = "Lissen App ${BuildConfig.VERSION_NAME}"
|
||||
|
||||
protected fun buildPlaybackStartRequest(
|
||||
supportedMimeTypes: List<String>,
|
||||
deviceId: String,
|
||||
): PlaybackStartRequest =
|
||||
PlaybackStartRequest(
|
||||
supportedMimeTypes = supportedMimeTypes,
|
||||
deviceInfo =
|
||||
DeviceInfo(
|
||||
clientName = getClientName(),
|
||||
deviceId = deviceId,
|
||||
deviceName = getClientName(),
|
||||
),
|
||||
forceTranscode = false,
|
||||
forceDirectPlay = false,
|
||||
mediaPlayer = getClientName(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.core.net.toUri
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Call
|
||||
@@ -52,6 +53,8 @@ class AudiobookshelfAuthService
|
||||
private val contextCache: OAuthContextCache,
|
||||
private val authMethodResponseConverter: AuthMethodResponseConverter,
|
||||
) : ChannelAuthService(session) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override suspend fun authorize(
|
||||
host: String,
|
||||
username: String,
|
||||
@@ -196,30 +199,38 @@ class AudiobookshelfAuthService
|
||||
call: Call,
|
||||
response: Response,
|
||||
) {
|
||||
Timber.d("OAuth redirect received from ABS: status=${response.code}")
|
||||
response.use {
|
||||
Timber.d("OAuth redirect received from ABS: status=${response.code}")
|
||||
|
||||
if (response.code != 302) {
|
||||
onFailure(examineError(response.body.string()))
|
||||
return
|
||||
}
|
||||
if (response.code != 302) {
|
||||
val body =
|
||||
try {
|
||||
response.body.string()
|
||||
} catch (ex: IOException) {
|
||||
ex.message ?: ""
|
||||
}
|
||||
onFailure(examineError(body))
|
||||
return
|
||||
}
|
||||
|
||||
val location =
|
||||
response
|
||||
.header("Location")
|
||||
?: kotlin.run {
|
||||
onFailure(examineError("invalid_redirect"))
|
||||
return
|
||||
}
|
||||
val location =
|
||||
response
|
||||
.header("Location")
|
||||
?: kotlin.run {
|
||||
onFailure(examineError("invalid_redirect"))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val cookieHeaders: List<String> = response.headers("Set-Cookie")
|
||||
contextCache.storeCookies(cookieHeaders)
|
||||
try {
|
||||
val cookieHeaders: List<String> = response.headers("Set-Cookie")
|
||||
contextCache.storeCookies(cookieHeaders)
|
||||
|
||||
onSuccess()
|
||||
forwardAuthRequest(location)
|
||||
} catch (ex: Exception) {
|
||||
Timber.e("Unable to process OAuth redirect due to: ${ex.message}")
|
||||
onFailure(examineError(ex.message ?: ""))
|
||||
onSuccess()
|
||||
forwardAuthRequest(location)
|
||||
} catch (ex: Exception) {
|
||||
Timber.e("Unable to process OAuth redirect due to: ${ex.message}")
|
||||
onFailure(examineError(ex.message ?: ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -293,6 +304,7 @@ class AudiobookshelfAuthService
|
||||
?.let { loginResponseConverter.apply(it) }
|
||||
?: run {
|
||||
Timber.e("OAuth token exchange returned an empty or unparseable user payload for $host (status=${response.code})")
|
||||
onFailure("empty_user")
|
||||
return
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
@@ -301,7 +313,7 @@ class AudiobookshelfAuthService
|
||||
return
|
||||
}
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch { onSuccess(user) }
|
||||
scope.launch { onSuccess(user) }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -49,6 +49,15 @@ class AudiobookshelfOAuthCallbackActivity : ComponentActivity() {
|
||||
val code = data.getQueryParameter("code") ?: ""
|
||||
Timber.d("Got Exchange code from ABS")
|
||||
|
||||
val returnedState = data.getQueryParameter("state").orEmpty()
|
||||
val expectedState = contextCache.readPkce().state
|
||||
|
||||
if (expectedState.isEmpty() || returnedState != expectedState) {
|
||||
Timber.e("OAuth state validation failed: returned state does not match the stored state")
|
||||
onLoginFailed("invalid_state")
|
||||
return
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
authService.exchangeToken(
|
||||
host =
|
||||
|
||||
@@ -18,8 +18,6 @@ import org.grakovne.lissen.channel.audiobookshelf.common.converter.LibraryPageRe
|
||||
import org.grakovne.lissen.channel.audiobookshelf.common.converter.LibraryResponseConverter
|
||||
import org.grakovne.lissen.channel.audiobookshelf.common.converter.PlaybackSessionResponseConverter
|
||||
import org.grakovne.lissen.channel.audiobookshelf.common.converter.RecentListeningResponseConverter
|
||||
import org.grakovne.lissen.channel.audiobookshelf.common.model.playback.DeviceInfo
|
||||
import org.grakovne.lissen.channel.audiobookshelf.common.model.playback.PlaybackStartRequest
|
||||
import org.grakovne.lissen.channel.audiobookshelf.library.converter.BookResponseConverter
|
||||
import org.grakovne.lissen.channel.audiobookshelf.library.converter.LibraryFilteringRequestConverter
|
||||
import org.grakovne.lissen.channel.audiobookshelf.library.converter.LibraryOrderingRequestConverter
|
||||
@@ -183,8 +181,11 @@ class LibraryAudiobookshelfChannel
|
||||
searchResult
|
||||
.map { it.authors }
|
||||
.map { authors -> authors.map { it.id } }
|
||||
.map { ids -> ids.map { id -> async { dataRepository.fetchAuthorItems(id) } } }
|
||||
.map { it.awaitAll() }
|
||||
.map { ids ->
|
||||
ids.map { id ->
|
||||
async { concurrentFetchSemaphore.withPermit { dataRepository.fetchAuthorItems(id) } }
|
||||
}
|
||||
}.map { it.awaitAll() }
|
||||
.map { result ->
|
||||
result
|
||||
.flatMap { authorResponse ->
|
||||
@@ -264,27 +265,12 @@ class LibraryAudiobookshelfChannel
|
||||
episodeId: String,
|
||||
supportedMimeTypes: List<String>,
|
||||
deviceId: String,
|
||||
): OperationResult<PlaybackSession> {
|
||||
val request =
|
||||
PlaybackStartRequest(
|
||||
supportedMimeTypes = supportedMimeTypes,
|
||||
deviceInfo =
|
||||
DeviceInfo(
|
||||
clientName = getClientName(),
|
||||
deviceId = deviceId,
|
||||
deviceName = getClientName(),
|
||||
),
|
||||
forceTranscode = false,
|
||||
forceDirectPlay = false,
|
||||
mediaPlayer = getClientName(),
|
||||
)
|
||||
|
||||
return dataRepository
|
||||
): OperationResult<PlaybackSession> =
|
||||
dataRepository
|
||||
.startPlayback(
|
||||
itemId = bookId,
|
||||
request = request,
|
||||
request = buildPlaybackStartRequest(supportedMimeTypes, deviceId),
|
||||
).map { sessionResponseConverter.apply(it) }
|
||||
}
|
||||
|
||||
override suspend fun fetchBook(bookId: String): OperationResult<DetailedItem> =
|
||||
coroutineScope {
|
||||
|
||||
@@ -12,8 +12,6 @@ import org.grakovne.lissen.channel.audiobookshelf.common.converter.ConnectionInf
|
||||
import org.grakovne.lissen.channel.audiobookshelf.common.converter.LibraryResponseConverter
|
||||
import org.grakovne.lissen.channel.audiobookshelf.common.converter.PlaybackSessionResponseConverter
|
||||
import org.grakovne.lissen.channel.audiobookshelf.common.converter.RecentListeningResponseConverter
|
||||
import org.grakovne.lissen.channel.audiobookshelf.common.model.playback.DeviceInfo
|
||||
import org.grakovne.lissen.channel.audiobookshelf.common.model.playback.PlaybackStartRequest
|
||||
import org.grakovne.lissen.channel.audiobookshelf.podcast.converter.PodcastOrderingRequestConverter
|
||||
import org.grakovne.lissen.channel.audiobookshelf.podcast.converter.PodcastPageResponseConverter
|
||||
import org.grakovne.lissen.channel.audiobookshelf.podcast.converter.PodcastResponseConverter
|
||||
@@ -100,28 +98,13 @@ class PodcastAudiobookshelfChannel
|
||||
episodeId: String,
|
||||
supportedMimeTypes: List<String>,
|
||||
deviceId: String,
|
||||
): OperationResult<PlaybackSession> {
|
||||
val request =
|
||||
PlaybackStartRequest(
|
||||
supportedMimeTypes = supportedMimeTypes,
|
||||
deviceInfo =
|
||||
DeviceInfo(
|
||||
clientName = getClientName(),
|
||||
deviceId = deviceId,
|
||||
deviceName = getClientName(),
|
||||
),
|
||||
forceTranscode = false,
|
||||
forceDirectPlay = false,
|
||||
mediaPlayer = getClientName(),
|
||||
)
|
||||
|
||||
return dataRepository
|
||||
): OperationResult<PlaybackSession> =
|
||||
dataRepository
|
||||
.startPodcastPlayback(
|
||||
itemId = bookId,
|
||||
episodeId = episodeId,
|
||||
request = request,
|
||||
request = buildPlaybackStartRequest(supportedMimeTypes, deviceId),
|
||||
).map { sessionResponseConverter.apply(it) }
|
||||
}
|
||||
|
||||
override suspend fun fetchBook(bookId: String): OperationResult<DetailedItem> =
|
||||
coroutineScope {
|
||||
|
||||
@@ -21,7 +21,6 @@ import org.grakovne.lissen.domain.PlaybackProgress
|
||||
import org.grakovne.lissen.domain.PlaybackSession
|
||||
import org.grakovne.lissen.domain.RecentBook
|
||||
import org.grakovne.lissen.domain.UserAccount
|
||||
import org.grakovne.lissen.domain.isSame
|
||||
import org.grakovne.lissen.persistence.preferences.LibraryPreferences
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
@@ -60,14 +59,16 @@ class LissenMediaProvider
|
||||
suspend fun provideBookmarks(playingItemId: String): List<Bookmark> =
|
||||
cachedBookmarkProvider
|
||||
.provideBookmarks(playingItemId)
|
||||
.sortedByDescending { it.createdAt }
|
||||
.fold(emptyList()) { acc, item -> if (acc.any { it.isSame(item) }) acc else acc + item }
|
||||
.sortedDeduplicated()
|
||||
|
||||
suspend fun updateAndProvideBookmarks(playingItemId: String): List<Bookmark> =
|
||||
cachedBookmarkProvider
|
||||
.fetchBookmarks(playingItemId)
|
||||
.sortedByDescending { it.createdAt }
|
||||
.fold(emptyList()) { acc, b -> if (acc.any { it.isSame(b) }) acc else acc + b }
|
||||
.sortedDeduplicated()
|
||||
|
||||
private fun List<Bookmark>.sortedDeduplicated(): List<Bookmark> =
|
||||
sortedByDescending { it.createdAt }
|
||||
.distinctBy { it.libraryItemId to it.totalPosition }
|
||||
|
||||
fun provideFileUri(
|
||||
libraryItemId: String,
|
||||
@@ -75,21 +76,20 @@ class LissenMediaProvider
|
||||
): OperationResult<Uri> {
|
||||
Timber.d("Resolving file URI: bookId=$libraryItemId, chapterId=$chapterId")
|
||||
|
||||
return when (preferences.isForceCache()) {
|
||||
val cached =
|
||||
localCacheRepository
|
||||
.provideFileUri(libraryItemId, chapterId)
|
||||
?.let { OperationResult.Success(it) }
|
||||
|
||||
return cached ?: when (preferences.isForceCache()) {
|
||||
true -> {
|
||||
localCacheRepository
|
||||
.provideFileUri(libraryItemId, chapterId)
|
||||
?.let { OperationResult.Success(it) }
|
||||
?: OperationResult.Error(OperationError.InternalError)
|
||||
OperationResult.Error(OperationError.InternalError)
|
||||
}
|
||||
|
||||
false -> {
|
||||
localCacheRepository
|
||||
providePreferredChannel()
|
||||
.provideFileUri(libraryItemId, chapterId)
|
||||
?.let { OperationResult.Success(it) }
|
||||
?: providePreferredChannel()
|
||||
.provideFileUri(libraryItemId, chapterId)
|
||||
.let { OperationResult.Success(it) }
|
||||
.let { OperationResult.Success(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,7 +156,7 @@ class LissenMediaProvider
|
||||
|
||||
return when (preferences.isForceCache()) {
|
||||
true -> {
|
||||
localCacheRepository.searchBooks(libraryId = libraryId, query = query)
|
||||
localCacheRepository.searchBooks(libraryId = libraryId, query = query, limit = limit)
|
||||
}
|
||||
|
||||
false -> {
|
||||
@@ -292,7 +292,7 @@ class LissenMediaProvider
|
||||
false -> {
|
||||
providePreferredChannel()
|
||||
.fetchRecentListenedBooks(libraryId)
|
||||
.map { items -> syncFromLocalProgress(libraryId = libraryId, detailedItems = items) }
|
||||
.map { items -> mergeLocalRecentProgress(libraryId = libraryId, recentBooks = items) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -311,7 +311,7 @@ class LissenMediaProvider
|
||||
false -> {
|
||||
providePreferredChannel()
|
||||
.fetchBook(bookId)
|
||||
.map { syncFromLocalProgress(it) }
|
||||
.map { mergeLocalItemProgress(it) }
|
||||
.map { trimProgress(it) }
|
||||
}
|
||||
}
|
||||
@@ -388,21 +388,21 @@ class LissenMediaProvider
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun syncFromLocalProgress(
|
||||
private suspend fun mergeLocalRecentProgress(
|
||||
libraryId: String,
|
||||
detailedItems: List<RecentBook>,
|
||||
recentBooks: List<RecentBook>,
|
||||
): List<RecentBook> {
|
||||
val localRecentlyBooks =
|
||||
val localRecentBooks =
|
||||
localCacheRepository
|
||||
.fetchRecentListenedBooks(libraryId)
|
||||
.fold(
|
||||
onSuccess = { it },
|
||||
onFailure = { return@fold detailedItems },
|
||||
onFailure = { return@fold recentBooks },
|
||||
)
|
||||
|
||||
val syncedRecentlyBooks =
|
||||
detailedItems
|
||||
.mapNotNull { item -> localRecentlyBooks.find { it.id == item.id }?.let { item to it } }
|
||||
val syncedRecentBooks =
|
||||
recentBooks
|
||||
.mapNotNull { item -> localRecentBooks.find { it.id == item.id }?.let { item to it } }
|
||||
.map { (remote, local) ->
|
||||
val localTimestamp = local.listenedLastUpdate ?: return@map remote
|
||||
val remoteTimestamp = remote.listenedLastUpdate ?: return@map remote
|
||||
@@ -413,9 +413,9 @@ class LissenMediaProvider
|
||||
}
|
||||
}
|
||||
|
||||
return detailedItems
|
||||
return recentBooks
|
||||
.map { item ->
|
||||
syncedRecentlyBooks
|
||||
syncedRecentBooks
|
||||
.find { item.id == it.id }
|
||||
?.let { local -> item.copy(listenedPercentage = local.listenedPercentage) }
|
||||
?: item
|
||||
@@ -433,7 +433,7 @@ class LissenMediaProvider
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncFromLocalProgress(detailedItem: DetailedItem): DetailedItem {
|
||||
private suspend fun mergeLocalItemProgress(detailedItem: DetailedItem): DetailedItem {
|
||||
val cachedProgress = localCacheRepository.fetchPlayingItemProgress(detailedItem.id)
|
||||
val channelProgress = detailedItem.progress
|
||||
|
||||
|
||||
9
app/src/main/kotlin/org/grakovne/lissen/content/cache/common/FileKey.kt
vendored
Normal file
9
app/src/main/kotlin/org/grakovne/lissen/content/cache/common/FileKey.kt
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
package org.grakovne.lissen.content.cache.common
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
fun String.toFileKey(): String =
|
||||
MessageDigest
|
||||
.getInstance("SHA-256")
|
||||
.digest(toByteArray(Charsets.UTF_8))
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
@@ -27,8 +27,14 @@ class CachingSessionRegistry {
|
||||
return true
|
||||
}
|
||||
|
||||
fun settle(itemId: String) {
|
||||
pending.remove(itemId)
|
||||
fun settle(
|
||||
itemId: String,
|
||||
job: Job,
|
||||
) {
|
||||
if (jobs[itemId] === job) {
|
||||
pending.remove(itemId)
|
||||
jobs.remove(itemId)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateStatus(
|
||||
|
||||
@@ -13,6 +13,7 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import org.grakovne.lissen.common.NetworkService
|
||||
import org.grakovne.lissen.common.NetworkTypeAutoCache
|
||||
@@ -56,9 +57,11 @@ class ContentAutoCachingService
|
||||
mediaRepository.playingBook,
|
||||
mediaRepository.isPlaying,
|
||||
mediaRepository.currentChapterIndex,
|
||||
) { playingItem: DetailedItem?, isPlaying: Boolean, _: Int ->
|
||||
playingItem to isPlaying
|
||||
}.collectLatest { (playingItem, isPlaying) ->
|
||||
) { playingItem: DetailedItem?, isPlaying: Boolean, chapterIndex: Int ->
|
||||
Triple(playingItem, isPlaying, chapterIndex)
|
||||
}.distinctUntilChanged { old, new ->
|
||||
old.first?.id == new.first?.id && old.second == new.second && old.third == new.third
|
||||
}.collectLatest { (playingItem, isPlaying, _) ->
|
||||
delayedJob?.cancel()
|
||||
delayedJob = updatePlaybackCache(playingItem, isPlaying)
|
||||
}
|
||||
|
||||
@@ -137,6 +137,8 @@ class ContentCachingManager
|
||||
chapterId: String,
|
||||
) = bookRepository.provideCacheState(mediaItemId, chapterId)
|
||||
|
||||
fun provideCachedChapterIds(mediaItemId: String) = bookRepository.provideCachedChapterIds(mediaItemId)
|
||||
|
||||
private suspend fun cacheBookMedia(
|
||||
bookId: String,
|
||||
files: List<BookFile>,
|
||||
@@ -168,6 +170,7 @@ class ContentCachingManager
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
Timber.e("Unable to cache media content: $response")
|
||||
response.close()
|
||||
return@withContext CacheState(CacheStatus.Error)
|
||||
}
|
||||
|
||||
@@ -221,14 +224,13 @@ class ContentCachingManager
|
||||
.writeToFile(file)
|
||||
} catch (ex: Exception) {
|
||||
Timber.e("Unable to cache cover for ${book.id} due to: ${ex.message}")
|
||||
return@fold CacheState(CacheStatus.Error)
|
||||
}
|
||||
CacheState(CacheStatus.Completed)
|
||||
},
|
||||
onFailure = {
|
||||
CacheState(CacheStatus.Completed)
|
||||
},
|
||||
)
|
||||
|
||||
CacheState(CacheStatus.Completed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ import android.os.Build
|
||||
import androidx.lifecycle.LifecycleService
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.job
|
||||
import kotlinx.coroutines.launch
|
||||
import org.grakovne.lissen.content.LissenMediaProvider
|
||||
import org.grakovne.lissen.content.cache.persistent.ContentCachingNotificationService.Companion.NOTIFICATION_ID
|
||||
@@ -106,7 +108,7 @@ class ContentCachingService : LifecycleService() {
|
||||
onSuccess = { item -> cacheFetchedItem(item, task) },
|
||||
onFailure = {
|
||||
Timber.e("Unable to fetch book ${task.itemId} for caching: ${it.code}")
|
||||
registry.settle(task.itemId)
|
||||
registry.settle(task.itemId, currentCoroutineContext().job)
|
||||
cacheProgressBus.emit(task.itemId, CacheState(CacheStatus.Error))
|
||||
|
||||
if (registry.inProgress().not()) {
|
||||
@@ -137,7 +139,7 @@ class ContentCachingService : LifecycleService() {
|
||||
Timber.e(error, "Caching failed for ${item.id}, emitting error state")
|
||||
emit(CacheState(CacheStatus.Error))
|
||||
}.onCompletion {
|
||||
registry.settle(item.id)
|
||||
registry.settle(item.id, currentCoroutineContext().job)
|
||||
|
||||
if (registry.inProgress().not()) {
|
||||
finish()
|
||||
@@ -176,7 +178,7 @@ class ContentCachingService : LifecycleService() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CACHE_ITEM_ACTION = "CACHING_TASK_EXTRA"
|
||||
const val CACHE_ITEM_ACTION = "org.grakovne.lissen.CACHE_ITEM_ACTION"
|
||||
const val STOP_CACHING_ACTION = "STOP_CACHING_ACTION"
|
||||
|
||||
const val CACHING_TASK_EXTRA = "CACHING_TASK_EXTRA"
|
||||
|
||||
@@ -49,6 +49,7 @@ object LocalCacheModule {
|
||||
.addMigrations(MIGRATION_17_18)
|
||||
.addMigrations(MIGRATION_18_19)
|
||||
.addMigrations(MIGRATION_19_20)
|
||||
.addMigrations(MIGRATION_20_21)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
@@ -72,24 +72,12 @@ class LocalCacheRepository
|
||||
suspend fun searchBooks(
|
||||
libraryId: String,
|
||||
query: String,
|
||||
limit: Int,
|
||||
): OperationResult<List<Book>> =
|
||||
cachedBookRepository
|
||||
.searchBooks(libraryId = libraryId, query = query)
|
||||
.searchBooks(libraryId = libraryId, query = query, limit = limit)
|
||||
.let { OperationResult.Success(it) }
|
||||
|
||||
suspend fun fetchDetailedItems(): OperationResult<PagedItems<DetailedItem>> {
|
||||
val items = cachedBookRepository.fetchCachedItems()
|
||||
|
||||
return OperationResult
|
||||
.Success(
|
||||
PagedItems(
|
||||
items = items,
|
||||
currentPage = 0,
|
||||
totalItems = cachedBookRepository.countCachedItems(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun fetchDetailedItems(
|
||||
pageSize: Int,
|
||||
pageNumber: Int,
|
||||
@@ -113,16 +101,18 @@ class LocalCacheRepository
|
||||
pageSize: Int,
|
||||
pageNumber: Int,
|
||||
): OperationResult<PagedItems<Book>> {
|
||||
val libraryType = cachedLibraryRepository.fetchLibraryType(libraryId)
|
||||
|
||||
val books =
|
||||
cachedBookRepository
|
||||
.fetchBooks(pageNumber = pageNumber, pageSize = pageSize, libraryId = libraryId)
|
||||
.fetchBooks(pageNumber = pageNumber, pageSize = pageSize, libraryId = libraryId, libraryType = libraryType)
|
||||
|
||||
return OperationResult
|
||||
.Success(
|
||||
PagedItems(
|
||||
items = books,
|
||||
currentPage = pageNumber,
|
||||
totalItems = cachedBookRepository.countBooks(libraryId),
|
||||
totalItems = cachedBookRepository.countBooks(libraryId, libraryType),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -141,22 +131,22 @@ class LocalCacheRepository
|
||||
|
||||
LibraryGrouping.SERIES -> {
|
||||
cachedBookRepository
|
||||
.fetchLibraryGrouped(libraryId = libraryId, pageSize = pageSize, pageNumber = pageNumber)
|
||||
.let { OperationResult.Success(it) }
|
||||
.fetchLibraryGrouped(
|
||||
libraryId = libraryId,
|
||||
pageSize = pageSize,
|
||||
pageNumber = pageNumber,
|
||||
libraryType = cachedLibraryRepository.fetchLibraryType(libraryId),
|
||||
).let { OperationResult.Success(it) }
|
||||
}
|
||||
|
||||
LibraryGrouping.AUTHOR -> {
|
||||
val entries = cachedBookRepository.fetchAuthorsGrouped(libraryId)
|
||||
val fromIndex = (pageNumber * pageSize).coerceIn(0, entries.size)
|
||||
val toIndex = (fromIndex + pageSize).coerceIn(0, entries.size)
|
||||
|
||||
OperationResult.Success(
|
||||
PagedItems(
|
||||
items = entries.subList(fromIndex, toIndex),
|
||||
currentPage = pageNumber,
|
||||
totalItems = entries.size,
|
||||
),
|
||||
)
|
||||
cachedBookRepository
|
||||
.fetchAuthorsGrouped(
|
||||
libraryId = libraryId,
|
||||
pageSize = pageSize,
|
||||
pageNumber = pageNumber,
|
||||
libraryType = cachedLibraryRepository.fetchLibraryType(libraryId),
|
||||
).let { OperationResult.Success(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,16 +155,22 @@ class LocalCacheRepository
|
||||
seriesId: String,
|
||||
): OperationResult<List<Book>> =
|
||||
cachedBookRepository
|
||||
.fetchSeriesItems(libraryId = libraryId, seriesId = seriesId)
|
||||
.let { OperationResult.Success(it) }
|
||||
.fetchSeriesItems(
|
||||
libraryId = libraryId,
|
||||
seriesId = seriesId,
|
||||
libraryType = cachedLibraryRepository.fetchLibraryType(libraryId),
|
||||
).let { OperationResult.Success(it) }
|
||||
|
||||
suspend fun fetchAuthorItems(
|
||||
libraryId: String,
|
||||
authorId: String,
|
||||
): OperationResult<List<Book>> =
|
||||
cachedBookRepository
|
||||
.fetchAuthorItems(libraryId = libraryId, authorId = authorId)
|
||||
.let { OperationResult.Success(it) }
|
||||
.fetchAuthorItems(
|
||||
libraryId = libraryId,
|
||||
authorId = authorId,
|
||||
libraryType = cachedLibraryRepository.fetchLibraryType(libraryId),
|
||||
).let { OperationResult.Success(it) }
|
||||
|
||||
suspend fun fetchLibraries(): OperationResult<List<Library>> =
|
||||
cachedLibraryRepository
|
||||
|
||||
@@ -21,7 +21,7 @@ import org.grakovne.lissen.content.cache.persistent.entity.MediaProgressEntity
|
||||
CachedLibraryEntity::class,
|
||||
CachedBookmarkEntity::class,
|
||||
],
|
||||
version = 20,
|
||||
version = 21,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class LocalCacheStorage : RoomDatabase() {
|
||||
|
||||
@@ -371,3 +371,11 @@ val MIGRATION_19_20 =
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_20_21 =
|
||||
object : Migration(20, 21) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS index_detailed_books_libraryId ON detailed_books(libraryId)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS index_detailed_books_seriesId ON detailed_books(seriesId)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ package org.grakovne.lissen.content.cache.persistent
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.grakovne.lissen.content.cache.common.toFileKey
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -29,19 +29,19 @@ class OfflineBookStorageProperties
|
||||
}
|
||||
}
|
||||
|
||||
fun provideBookCache(bookId: String): File = baseFolder().resolve(bookId)
|
||||
fun provideBookCache(bookId: String): File = baseFolder().resolve(bookId.toFileKey())
|
||||
|
||||
fun provideMediaCachePatch(
|
||||
bookId: String,
|
||||
fileId: String,
|
||||
): File =
|
||||
baseFolder()
|
||||
.resolve(bookId)
|
||||
.resolve(fileId)
|
||||
.resolve(bookId.toFileKey())
|
||||
.resolve(fileId.toFileKey())
|
||||
|
||||
fun provideBookCoverPath(bookId: String): File =
|
||||
baseFolder()
|
||||
.resolve(bookId)
|
||||
.resolve(bookId.toFileKey())
|
||||
.resolve("cover.img")
|
||||
|
||||
fun provideAuthorImagePath(authorName: String): File =
|
||||
@@ -49,12 +49,6 @@ class OfflineBookStorageProperties
|
||||
.resolve(AUTHORS_FOLDER)
|
||||
.resolve("${authorName.toFileKey()}.img")
|
||||
|
||||
private fun String.toFileKey(): String =
|
||||
MessageDigest
|
||||
.getInstance("SHA-256")
|
||||
.digest(toByteArray(Charsets.UTF_8))
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
companion object {
|
||||
const val MEDIA_CACHE_FOLDER = "media_cache"
|
||||
private const val AUTHORS_FOLDER = "authors"
|
||||
|
||||
@@ -4,11 +4,9 @@ import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import com.squareup.moshi.Types
|
||||
import org.grakovne.lissen.common.LibraryOrderingDirection
|
||||
import org.grakovne.lissen.common.LibraryOrderingOption
|
||||
import org.grakovne.lissen.common.mergeAuthorNames
|
||||
import org.grakovne.lissen.common.moshi
|
||||
import org.grakovne.lissen.content.cache.persistent.OfflineBookStorageProperties
|
||||
import org.grakovne.lissen.content.cache.persistent.converter.CachedBookEntityConverter
|
||||
import org.grakovne.lissen.content.cache.persistent.converter.CachedBookEntityDetailedConverter
|
||||
@@ -16,11 +14,11 @@ import org.grakovne.lissen.content.cache.persistent.converter.CachedBookEntityRe
|
||||
import org.grakovne.lissen.content.cache.persistent.converter.MediaProgressEntityConverter
|
||||
import org.grakovne.lissen.content.cache.persistent.dao.CachedBookDao
|
||||
import org.grakovne.lissen.content.cache.persistent.entity.BookEntity
|
||||
import org.grakovne.lissen.content.cache.persistent.entity.BookSeriesDto
|
||||
import org.grakovne.lissen.content.cache.persistent.entity.MediaProgressEntity
|
||||
import org.grakovne.lissen.domain.Book
|
||||
import org.grakovne.lissen.domain.DetailedItem
|
||||
import org.grakovne.lissen.domain.LibraryEntry
|
||||
import org.grakovne.lissen.domain.LibraryType
|
||||
import org.grakovne.lissen.domain.PagedItems
|
||||
import org.grakovne.lissen.domain.PlaybackProgress
|
||||
import org.grakovne.lissen.domain.PlayingChapter
|
||||
@@ -31,6 +29,10 @@ import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val FINISHED_POSITION_EPSILON = 1.0
|
||||
|
||||
private const val AUTHOR_KEY = "TRIM(CASE WHEN instr(author, ',') > 0 THEN substr(author, 1, instr(author, ',') - 1) ELSE author END)"
|
||||
|
||||
@Singleton
|
||||
class CachedBookRepository
|
||||
@Inject
|
||||
@@ -79,6 +81,8 @@ class CachedBookRepository
|
||||
chapterId: String,
|
||||
) = bookDao.isBookChapterCached(bookId, chapterId)
|
||||
|
||||
fun provideCachedChapterIds(bookId: String) = bookDao.cachedChapterIds(bookId)
|
||||
|
||||
suspend fun fetchCachedItems() =
|
||||
bookDao
|
||||
.fetchCachedItems()
|
||||
@@ -99,17 +103,16 @@ class CachedBookRepository
|
||||
libraryId: String,
|
||||
pageNumber: Int,
|
||||
pageSize: Int,
|
||||
libraryType: LibraryType?,
|
||||
): List<Book> {
|
||||
val (option, direction) = buildOrdering()
|
||||
|
||||
val request =
|
||||
FetchRequestBuilder()
|
||||
.libraryId(libraryId)
|
||||
fetchRequest(libraryId, libraryType)
|
||||
.pageNumber(pageNumber)
|
||||
.pageSize(pageSize)
|
||||
.orderField(option)
|
||||
.orderDirection(direction)
|
||||
.hideCompleted(preferences.getHideCompleted())
|
||||
.build()
|
||||
|
||||
return bookDao
|
||||
@@ -117,20 +120,36 @@ class CachedBookRepository
|
||||
.map { cachedBookEntityConverter.apply(it) }
|
||||
}
|
||||
|
||||
suspend fun countBooks(libraryId: String): Int = bookDao.countCachedBooks(libraryId = libraryId)
|
||||
suspend fun countBooks(
|
||||
libraryId: String,
|
||||
libraryType: LibraryType?,
|
||||
): Int = bookDao.countRaw(fetchRequest(libraryId, libraryType).buildCount())
|
||||
|
||||
private fun fetchRequest(
|
||||
libraryId: String,
|
||||
libraryType: LibraryType?,
|
||||
): FetchRequestBuilder =
|
||||
FetchRequestBuilder()
|
||||
.libraryId(libraryId)
|
||||
.libraryType(libraryType)
|
||||
.hideCompleted(preferences.getHideCompleted())
|
||||
|
||||
private fun hideCompletedClauses(libraryType: LibraryType?): Pair<String, String> =
|
||||
hideCompletedSql(hideCompletedApplies(preferences.getHideCompleted(), libraryType), "id")
|
||||
|
||||
suspend fun fetchLibraryGrouped(
|
||||
libraryId: String,
|
||||
pageSize: Int,
|
||||
pageNumber: Int,
|
||||
libraryType: LibraryType?,
|
||||
): PagedItems<LibraryEntry> {
|
||||
val total = bookDao.countGroupedEntries(libraryId)
|
||||
val total = bookDao.countRaw(buildGroupedCountQuery(libraryId, libraryType))
|
||||
|
||||
if (total == 0) {
|
||||
return PagedItems(items = emptyList(), currentPage = pageNumber, totalItems = 0)
|
||||
}
|
||||
|
||||
val headers = bookDao.fetchGroupedEntries(buildGroupedPageQuery(libraryId, pageSize, pageNumber))
|
||||
val headers = bookDao.fetchGroupedEntries(buildGroupedPageQuery(libraryId, pageSize, pageNumber, libraryType))
|
||||
|
||||
val standaloneBooks =
|
||||
headers
|
||||
@@ -175,34 +194,42 @@ class CachedBookRepository
|
||||
suspend fun fetchSeriesItems(
|
||||
libraryId: String,
|
||||
seriesId: String,
|
||||
): List<Book> =
|
||||
bookDao
|
||||
.fetchBooksBySeriesIds(listOf(seriesId))
|
||||
libraryType: LibraryType?,
|
||||
): List<Book> {
|
||||
val (join, filter) = hideCompletedClauses(libraryType)
|
||||
|
||||
val sql =
|
||||
"""
|
||||
SELECT detailed_books.* FROM detailed_books
|
||||
$join
|
||||
WHERE seriesId = ? $filter
|
||||
""".trimIndent()
|
||||
|
||||
return bookDao
|
||||
.fetchCachedBooks(SimpleSQLiteQuery(sql, arrayOf<Any>(seriesId)))
|
||||
.map { cachedBookEntityConverter.apply(it) }
|
||||
}
|
||||
|
||||
private fun buildGroupedPageQuery(
|
||||
libraryId: String,
|
||||
pageSize: Int,
|
||||
pageNumber: Int,
|
||||
libraryType: LibraryType?,
|
||||
): SupportSQLiteQuery {
|
||||
val (option, direction) = buildOrdering()
|
||||
|
||||
val field =
|
||||
when (option) {
|
||||
"author" -> "author"
|
||||
"duration" -> "duration"
|
||||
else -> "title"
|
||||
}
|
||||
|
||||
val descending = direction.equals("desc", ignoreCase = true)
|
||||
val field = resolveOrderField(option)
|
||||
val descending = resolveOrderDirection(direction) == "DESC"
|
||||
val aggregate = if (descending) "MAX" else "MIN"
|
||||
val sortDirection = if (descending) "DESC" else "ASC"
|
||||
val (join, filter) = hideCompletedClauses(libraryType)
|
||||
|
||||
val sql =
|
||||
"""
|
||||
SELECT COALESCE(seriesId, id) AS groupKey, seriesId AS seriesId, COUNT(*) AS bookCount
|
||||
FROM detailed_books
|
||||
WHERE libraryId = ?
|
||||
$join
|
||||
WHERE libraryId = ? $filter
|
||||
GROUP BY COALESCE(seriesId, id)
|
||||
ORDER BY $aggregate($field) $sortDirection
|
||||
LIMIT ? OFFSET ?
|
||||
@@ -211,60 +238,116 @@ class CachedBookRepository
|
||||
return SimpleSQLiteQuery(sql, arrayOf<Any>(libraryId, pageSize, pageNumber * pageSize))
|
||||
}
|
||||
|
||||
suspend fun fetchAuthorsGrouped(libraryId: String): List<LibraryEntry> =
|
||||
fetchAllEntities(libraryId)
|
||||
.groupingBy { it.primaryAuthor() }
|
||||
.eachCount()
|
||||
.mapNotNull { (author, count) ->
|
||||
author?.let { LibraryEntry.AuthorEntry(id = it, name = it, bookCount = count) }
|
||||
}.sortedBy { it.name.lowercase() }
|
||||
private fun buildGroupedCountQuery(
|
||||
libraryId: String,
|
||||
libraryType: LibraryType?,
|
||||
): SupportSQLiteQuery {
|
||||
val (join, filter) = hideCompletedClauses(libraryType)
|
||||
|
||||
val sql =
|
||||
"""
|
||||
SELECT COUNT(DISTINCT COALESCE(seriesId, id))
|
||||
FROM detailed_books
|
||||
$join
|
||||
WHERE libraryId = ? $filter
|
||||
""".trimIndent()
|
||||
|
||||
return SimpleSQLiteQuery(sql, arrayOf<Any>(libraryId))
|
||||
}
|
||||
|
||||
suspend fun fetchAuthorsGrouped(
|
||||
libraryId: String,
|
||||
pageSize: Int,
|
||||
pageNumber: Int,
|
||||
libraryType: LibraryType?,
|
||||
): PagedItems<LibraryEntry> {
|
||||
val total = bookDao.countRaw(buildAuthorCountQuery(libraryId, libraryType))
|
||||
|
||||
if (total == 0) {
|
||||
return PagedItems(items = emptyList(), currentPage = pageNumber, totalItems = 0)
|
||||
}
|
||||
|
||||
val items =
|
||||
bookDao
|
||||
.fetchAuthorEntries(buildAuthorPageQuery(libraryId, pageSize, pageNumber, libraryType))
|
||||
.map { LibraryEntry.AuthorEntry(id = it.author, name = it.author, bookCount = it.bookCount) }
|
||||
|
||||
return PagedItems(items = items, currentPage = pageNumber, totalItems = total)
|
||||
}
|
||||
|
||||
suspend fun fetchAuthorItems(
|
||||
libraryId: String,
|
||||
authorId: String,
|
||||
): List<Book> =
|
||||
fetchAllEntities(libraryId)
|
||||
.filter { it.primaryAuthor() == authorId }
|
||||
.map { cachedBookEntityConverter.apply(it) }
|
||||
|
||||
private fun BookEntity.primaryAuthor(): String? =
|
||||
author
|
||||
?.substringBefore(",")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private suspend fun fetchAllEntities(libraryId: String): List<BookEntity> {
|
||||
libraryType: LibraryType?,
|
||||
): List<Book> {
|
||||
val (option, direction) = buildOrdering()
|
||||
val total = countBooks(libraryId)
|
||||
|
||||
if (total == 0) {
|
||||
return emptyList()
|
||||
}
|
||||
val field = resolveOrderField(option)
|
||||
val sortDirection = resolveOrderDirection(direction)
|
||||
val (join, filter) = hideCompletedClauses(libraryType)
|
||||
|
||||
val request =
|
||||
FetchRequestBuilder()
|
||||
.libraryId(libraryId)
|
||||
.pageNumber(0)
|
||||
.pageSize(total)
|
||||
.orderField(option)
|
||||
.orderDirection(direction)
|
||||
.hideCompleted(preferences.getHideCompleted())
|
||||
.build()
|
||||
val sql =
|
||||
"""
|
||||
SELECT detailed_books.* FROM detailed_books
|
||||
$join
|
||||
WHERE libraryId = ? AND $AUTHOR_KEY = ? $filter
|
||||
ORDER BY $field $sortDirection
|
||||
""".trimIndent()
|
||||
|
||||
return bookDao.fetchCachedBooks(request)
|
||||
return bookDao
|
||||
.fetchCachedBooks(SimpleSQLiteQuery(sql, arrayOf<Any>(libraryId, authorId)))
|
||||
.map { cachedBookEntityConverter.apply(it) }
|
||||
}
|
||||
|
||||
private fun buildAuthorPageQuery(
|
||||
libraryId: String,
|
||||
pageSize: Int,
|
||||
pageNumber: Int,
|
||||
libraryType: LibraryType?,
|
||||
): SupportSQLiteQuery {
|
||||
val (join, filter) = hideCompletedClauses(libraryType)
|
||||
|
||||
val sql =
|
||||
"""
|
||||
SELECT $AUTHOR_KEY AS author, COUNT(*) AS bookCount
|
||||
FROM detailed_books
|
||||
$join
|
||||
WHERE libraryId = ? AND $AUTHOR_KEY IS NOT NULL AND $AUTHOR_KEY != '' $filter
|
||||
GROUP BY $AUTHOR_KEY
|
||||
ORDER BY LOWER($AUTHOR_KEY) ASC
|
||||
LIMIT ? OFFSET ?
|
||||
""".trimIndent()
|
||||
|
||||
return SimpleSQLiteQuery(sql, arrayOf<Any>(libraryId, pageSize, pageNumber * pageSize))
|
||||
}
|
||||
|
||||
private fun buildAuthorCountQuery(
|
||||
libraryId: String,
|
||||
libraryType: LibraryType?,
|
||||
): SupportSQLiteQuery {
|
||||
val (join, filter) = hideCompletedClauses(libraryType)
|
||||
|
||||
val sql =
|
||||
"""
|
||||
SELECT COUNT(DISTINCT $AUTHOR_KEY)
|
||||
FROM detailed_books
|
||||
$join
|
||||
WHERE libraryId = ? AND $AUTHOR_KEY IS NOT NULL AND $AUTHOR_KEY != '' $filter
|
||||
""".trimIndent()
|
||||
|
||||
return SimpleSQLiteQuery(sql, arrayOf<Any>(libraryId))
|
||||
}
|
||||
|
||||
private fun BookEntity.primarySeriesName(): String? =
|
||||
seriesJson
|
||||
?.let {
|
||||
val type = Types.newParameterizedType(List::class.java, BookSeriesDto::class.java)
|
||||
moshi.adapter<List<BookSeriesDto>>(type).fromJson(it)
|
||||
}?.firstOrNull()
|
||||
?.let { CachedBookDao.adapter.fromJson(it) }
|
||||
?.firstOrNull()
|
||||
?.title
|
||||
|
||||
suspend fun searchBooks(
|
||||
libraryId: String,
|
||||
query: String,
|
||||
limit: Int,
|
||||
): List<Book> {
|
||||
val (option, direction) = buildOrdering()
|
||||
|
||||
@@ -274,6 +357,7 @@ class CachedBookRepository
|
||||
.libraryId(libraryId)
|
||||
.orderField(option)
|
||||
.orderDirection(direction)
|
||||
.limit(limit)
|
||||
.build()
|
||||
|
||||
return bookDao
|
||||
@@ -288,9 +372,8 @@ class CachedBookRepository
|
||||
)
|
||||
|
||||
val progress =
|
||||
recentBooks
|
||||
.map { it.id }
|
||||
.mapNotNull { bookDao.fetchMediaProgress(it) }
|
||||
bookDao
|
||||
.fetchMediaProgress(recentBooks.map { it.id })
|
||||
.associate { it.bookId to (it.lastUpdate to it.currentTime) }
|
||||
|
||||
return recentBooks
|
||||
@@ -311,11 +394,12 @@ class CachedBookRepository
|
||||
playingItem: DetailedItem,
|
||||
progress: PlaybackProgress,
|
||||
) {
|
||||
val totalDuration = playingItem.chapters.sumOf { it.duration }
|
||||
val entity =
|
||||
MediaProgressEntity(
|
||||
bookId = playingItem.id,
|
||||
currentTime = progress.currentTotalTime,
|
||||
isFinished = progress.currentTotalTime == playingItem.chapters.sumOf { it.duration },
|
||||
isFinished = progress.currentTotalTime >= totalDuration - FINISHED_POSITION_EPSILON,
|
||||
lastUpdate = Instant.now().toEpochMilli(),
|
||||
)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.grakovne.lissen.content.cache.persistent.api
|
||||
import org.grakovne.lissen.content.cache.persistent.converter.CachedLibraryEntityConverter
|
||||
import org.grakovne.lissen.content.cache.persistent.dao.CachedLibraryDao
|
||||
import org.grakovne.lissen.domain.Library
|
||||
import org.grakovne.lissen.domain.LibraryType
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -15,6 +16,8 @@ class CachedLibraryRepository
|
||||
) {
|
||||
suspend fun cacheLibraries(libraries: List<Library>) = dao.updateLibraries(libraries)
|
||||
|
||||
suspend fun fetchLibraryType(libraryId: String): LibraryType? = dao.fetchLibrary(libraryId)?.type
|
||||
|
||||
suspend fun fetchLibraries() =
|
||||
dao
|
||||
.fetchLibraries()
|
||||
|
||||
@@ -29,7 +29,43 @@ class FetchRequestBuilder {
|
||||
|
||||
fun build(): SupportSQLiteQuery {
|
||||
val args = mutableListOf<Any>()
|
||||
val (join, whereClause) = clauses(args)
|
||||
|
||||
val field = "b.${resolveOrderField(orderField)}"
|
||||
val direction = resolveOrderDirection(orderDirection)
|
||||
|
||||
args.add(pageSize)
|
||||
args.add(pageNumber * pageSize)
|
||||
|
||||
val sql =
|
||||
"""
|
||||
SELECT b.*
|
||||
FROM detailed_books b
|
||||
$join
|
||||
WHERE $whereClause
|
||||
ORDER BY $field $direction
|
||||
LIMIT ? OFFSET ?
|
||||
""".trimIndent()
|
||||
|
||||
return SimpleSQLiteQuery(sql, args.toTypedArray())
|
||||
}
|
||||
|
||||
fun buildCount(): SupportSQLiteQuery {
|
||||
val args = mutableListOf<Any>()
|
||||
val (join, whereClause) = clauses(args)
|
||||
|
||||
val sql =
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM detailed_books b
|
||||
$join
|
||||
WHERE $whereClause
|
||||
""".trimIndent()
|
||||
|
||||
return SimpleSQLiteQuery(sql, args.toTypedArray())
|
||||
}
|
||||
|
||||
private fun clauses(args: MutableList<Any>): Pair<String, String> {
|
||||
val libraryWhereClause =
|
||||
when (val id = libraryId) {
|
||||
null -> {
|
||||
@@ -42,39 +78,8 @@ class FetchRequestBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
val hideCompletedWhereClause =
|
||||
when {
|
||||
hideCompleted && libraryType == LibraryType.LIBRARY -> "AND (mp.isFinished = 0 OR mp.isFinished IS NULL)"
|
||||
else -> ""
|
||||
}
|
||||
val (join, filter) = hideCompletedSql(hideCompletedApplies(hideCompleted, libraryType), "b.id")
|
||||
|
||||
val field =
|
||||
when (orderField) {
|
||||
"title" -> "b.title"
|
||||
"author" -> "b.author"
|
||||
"duration" -> "b.duration"
|
||||
else -> "b.title"
|
||||
}
|
||||
|
||||
val direction =
|
||||
when (orderDirection.uppercase()) {
|
||||
"ASC", "DESC" -> orderDirection.uppercase()
|
||||
else -> "ASC"
|
||||
}
|
||||
|
||||
args.add(pageSize)
|
||||
args.add(pageNumber * pageSize)
|
||||
|
||||
val sql =
|
||||
"""
|
||||
SELECT b.*
|
||||
FROM detailed_books b
|
||||
LEFT JOIN media_progress mp ON mp.bookId = b.id
|
||||
WHERE $libraryWhereClause $hideCompletedWhereClause
|
||||
ORDER BY $field $direction
|
||||
LIMIT ? OFFSET ?
|
||||
""".trimIndent()
|
||||
|
||||
return SimpleSQLiteQuery(sql, args.toTypedArray())
|
||||
return join to "$libraryWhereClause $filter"
|
||||
}
|
||||
}
|
||||
|
||||
17
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/HideCompletedSql.kt
vendored
Normal file
17
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/HideCompletedSql.kt
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
package org.grakovne.lissen.content.cache.persistent.api
|
||||
|
||||
import org.grakovne.lissen.domain.LibraryType
|
||||
|
||||
fun hideCompletedApplies(
|
||||
hideCompleted: Boolean,
|
||||
libraryType: LibraryType?,
|
||||
): Boolean = hideCompleted && libraryType == LibraryType.LIBRARY
|
||||
|
||||
fun hideCompletedSql(
|
||||
apply: Boolean,
|
||||
idColumn: String,
|
||||
): Pair<String, String> =
|
||||
when (apply) {
|
||||
true -> "LEFT JOIN media_progress mp ON mp.bookId = $idColumn" to "AND (mp.isFinished = 0 OR mp.isFinished IS NULL)"
|
||||
false -> "" to ""
|
||||
}
|
||||
13
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/OrderingSql.kt
vendored
Normal file
13
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/OrderingSql.kt
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
package org.grakovne.lissen.content.cache.persistent.api
|
||||
|
||||
fun resolveOrderField(field: String): String =
|
||||
when (field) {
|
||||
"author", "createdAt", "updatedAt" -> field
|
||||
else -> "title"
|
||||
}
|
||||
|
||||
fun resolveOrderDirection(direction: String): String =
|
||||
when (direction.uppercase()) {
|
||||
"ASC", "DESC" -> direction.uppercase()
|
||||
else -> "ASC"
|
||||
}
|
||||
@@ -8,6 +8,7 @@ class SearchRequestBuilder {
|
||||
private var searchQuery: String = ""
|
||||
private var orderField: String = "title"
|
||||
private var orderDirection: String = "ASC"
|
||||
private var limit: Int? = null
|
||||
|
||||
fun libraryId(id: String) = apply { this.libraryId = id }
|
||||
|
||||
@@ -17,6 +18,8 @@ class SearchRequestBuilder {
|
||||
|
||||
fun orderDirection(direction: String) = apply { this.orderDirection = direction }
|
||||
|
||||
fun limit(value: Int) = apply { this.limit = value }
|
||||
|
||||
fun build(): SupportSQLiteQuery {
|
||||
val args = mutableListOf<Any>()
|
||||
|
||||
@@ -32,23 +35,23 @@ class SearchRequestBuilder {
|
||||
args.add(pattern)
|
||||
}
|
||||
|
||||
val field =
|
||||
when (orderField) {
|
||||
"title", "author", "duration" -> orderField
|
||||
else -> "title"
|
||||
}
|
||||
val field = resolveOrderField(orderField)
|
||||
val direction = resolveOrderDirection(orderDirection)
|
||||
|
||||
val direction =
|
||||
when (orderDirection.uppercase()) {
|
||||
"ASC", "DESC" -> orderDirection.uppercase()
|
||||
else -> "ASC"
|
||||
}
|
||||
val limitClause =
|
||||
limit
|
||||
?.let {
|
||||
args.add(it)
|
||||
"LIMIT ?"
|
||||
}
|
||||
?: ""
|
||||
|
||||
val sql =
|
||||
"""
|
||||
SELECT * FROM detailed_books
|
||||
WHERE $whereClause
|
||||
ORDER BY $field $direction
|
||||
$limitClause
|
||||
""".trimIndent()
|
||||
|
||||
return SimpleSQLiteQuery(sql, args.toTypedArray())
|
||||
|
||||
@@ -21,11 +21,8 @@ class CachedBookEntityConverter
|
||||
series =
|
||||
entity
|
||||
.seriesJson
|
||||
?.let {
|
||||
val type = Types.newParameterizedType(List::class.java, BookSeriesDto::class.java)
|
||||
val adapter = moshi.adapter<List<BookSeriesDto>>(type)
|
||||
adapter.fromJson(it)
|
||||
}?.joinToString(", ") { series ->
|
||||
?.let { seriesAdapter.fromJson(it) }
|
||||
?.joinToString(", ") { series ->
|
||||
buildString {
|
||||
append(series.title)
|
||||
series.sequence
|
||||
@@ -34,4 +31,9 @@ class CachedBookEntityConverter
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val seriesType = Types.newParameterizedType(List::class.java, BookSeriesDto::class.java)
|
||||
val seriesAdapter = moshi.adapter<List<BookSeriesDto>>(seriesType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,11 +29,8 @@ class CachedBookEntityDetailedConverter
|
||||
entity
|
||||
.detailedBook
|
||||
.authorsJson
|
||||
?.let {
|
||||
val type = Types.newParameterizedType(List::class.java, BookAuthorDto::class.java)
|
||||
val adapter = moshi.adapter<List<BookAuthorDto>>(type)
|
||||
adapter.fromJson(it)
|
||||
}?.map { BookAuthor(id = it.id, name = it.name) }
|
||||
?.let { authorsAdapter.fromJson(it) }
|
||||
?.map { BookAuthor(id = it.id, name = it.name) }
|
||||
?: emptyList(),
|
||||
narrator = entity.detailedBook.narrator,
|
||||
libraryId = entity.detailedBook.libraryId,
|
||||
@@ -69,11 +66,8 @@ class CachedBookEntityDetailedConverter
|
||||
entity
|
||||
.detailedBook
|
||||
.seriesJson
|
||||
?.let {
|
||||
val type = Types.newParameterizedType(List::class.java, BookSeriesDto::class.java)
|
||||
val adapter = moshi.adapter<List<BookSeriesDto>>(type)
|
||||
adapter.fromJson(it)
|
||||
}?.map {
|
||||
?.let { seriesAdapter.fromJson(it) }
|
||||
?.map {
|
||||
BookSeries(
|
||||
id = it.id,
|
||||
name = it.title,
|
||||
@@ -82,4 +76,11 @@ class CachedBookEntityDetailedConverter
|
||||
} ?: emptyList(),
|
||||
progress = entity.progress?.let { mediaProgressEntityConverter.apply(it) },
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val authorsType = Types.newParameterizedType(List::class.java, BookAuthorDto::class.java)
|
||||
val authorsAdapter = moshi.adapter<List<BookAuthorDto>>(authorsType)
|
||||
val seriesType = Types.newParameterizedType(List::class.java, BookSeriesDto::class.java)
|
||||
val seriesAdapter = moshi.adapter<List<BookSeriesDto>>(seriesType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import com.squareup.moshi.Types
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.grakovne.lissen.common.moshi
|
||||
import org.grakovne.lissen.content.cache.persistent.entity.AuthorEntry
|
||||
import org.grakovne.lissen.content.cache.persistent.entity.BookAuthorDto
|
||||
import org.grakovne.lissen.content.cache.persistent.entity.BookChapterEntity
|
||||
import org.grakovne.lissen.content.cache.persistent.entity.BookEntity
|
||||
@@ -139,13 +140,11 @@ interface CachedBookDao {
|
||||
@RawQuery
|
||||
suspend fun fetchGroupedEntries(query: SupportSQLiteQuery): List<GroupedEntry>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT COALESCE(seriesId, id)) FROM detailed_books
|
||||
WHERE libraryId = :libraryId
|
||||
""",
|
||||
)
|
||||
suspend fun countGroupedEntries(libraryId: String): Int
|
||||
@RawQuery
|
||||
suspend fun fetchAuthorEntries(query: SupportSQLiteQuery): List<AuthorEntry>
|
||||
|
||||
@RawQuery
|
||||
suspend fun countRaw(query: SupportSQLiteQuery): Int
|
||||
|
||||
@Query("SELECT * FROM detailed_books WHERE id IN (:ids)")
|
||||
suspend fun fetchBooksByIds(ids: List<String>): List<BookEntity>
|
||||
@@ -153,14 +152,6 @@ interface CachedBookDao {
|
||||
@Query("SELECT * FROM detailed_books WHERE seriesId IN (:seriesIds)")
|
||||
suspend fun fetchBooksBySeriesIds(seriesIds: List<String>): List<BookEntity>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT COUNT(*) FROM detailed_books
|
||||
WHERE (libraryId = :libraryId)
|
||||
""",
|
||||
)
|
||||
suspend fun countCachedBooks(libraryId: String?): Int
|
||||
|
||||
@Transaction
|
||||
@RawQuery
|
||||
suspend fun searchBooks(query: SupportSQLiteQuery): List<BookEntity>
|
||||
@@ -224,6 +215,16 @@ interface CachedBookDao {
|
||||
chapterId: String,
|
||||
): Flow<Boolean>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT bookChapterId
|
||||
FROM book_chapters
|
||||
WHERE bookId = :bookId
|
||||
AND isCached = 1
|
||||
""",
|
||||
)
|
||||
fun cachedChapterIds(bookId: String): Flow<List<String>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT MAX(mp.lastUpdate)
|
||||
@@ -254,6 +255,10 @@ interface CachedBookDao {
|
||||
@Query("SELECT * FROM media_progress WHERE bookId = :bookId")
|
||||
suspend fun fetchMediaProgress(bookId: String): MediaProgressEntity?
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM media_progress WHERE bookId IN (:bookIds)")
|
||||
suspend fun fetchMediaProgress(bookIds: List<String>): List<MediaProgressEntity>
|
||||
|
||||
@Delete
|
||||
suspend fun deleteBook(book: BookEntity)
|
||||
|
||||
|
||||
9
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/AuthorEntry.kt
vendored
Normal file
9
app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/AuthorEntry.kt
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
package org.grakovne.lissen.content.cache.persistent.entity
|
||||
|
||||
import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
data class AuthorEntry(
|
||||
val author: String,
|
||||
val bookCount: Int,
|
||||
)
|
||||
@@ -32,7 +32,10 @@ data class CachedBookEntity(
|
||||
)
|
||||
|
||||
@Keep
|
||||
@Entity(tableName = "detailed_books")
|
||||
@Entity(
|
||||
tableName = "detailed_books",
|
||||
indices = [Index(value = ["libraryId"]), Index(value = ["seriesId"])],
|
||||
)
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class BookEntity(
|
||||
@PrimaryKey val id: String,
|
||||
|
||||
@@ -14,7 +14,6 @@ import org.grakovne.lissen.content.cache.common.SeriesCoverComposer
|
||||
import org.grakovne.lissen.content.cache.common.writeToFile
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -97,9 +96,5 @@ class SeriesCoverProvider
|
||||
private fun cacheKey(
|
||||
seriesId: String,
|
||||
coverItemIds: List<String>,
|
||||
): String =
|
||||
MessageDigest
|
||||
.getInstance("SHA-256")
|
||||
.digest((seriesId + coverItemIds.joinToString(",")).toByteArray())
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
): String = seriesId + coverItemIds.joinToString(",")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.grakovne.lissen.content.cache.temporary
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.grakovne.lissen.content.cache.common.toFileKey
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -14,11 +15,11 @@ class ShortTermCacheStorageProperties
|
||||
) {
|
||||
fun provideCoverCacheFolder(): File = coverCacheFolder()
|
||||
|
||||
fun provideCoverPath(itemId: String): File = coverCacheFolder().resolve(itemId)
|
||||
fun provideCoverPath(itemId: String): File = coverCacheFolder().resolve(itemId.toFileKey())
|
||||
|
||||
fun provideSeriesCoverCacheFolder(): File = seriesCoverCacheFolder()
|
||||
|
||||
fun provideSeriesCoverPath(key: String): File = seriesCoverCacheFolder().resolve(key)
|
||||
fun provideSeriesCoverPath(key: String): File = seriesCoverCacheFolder().resolve(key.toFileKey())
|
||||
|
||||
private fun coverCacheFolder(): File =
|
||||
baseFolder()
|
||||
|
||||
@@ -31,6 +31,6 @@ fun String?.makeDownloadOption(): DownloadOption? =
|
||||
this == "all_items" -> AllItemsDownloadOption
|
||||
this == "current_item" -> CurrentItemDownloadOption
|
||||
this == "remaining_items" -> RemainingItemsDownloadOption
|
||||
startsWith("number_items_") -> NumberItemDownloadOption(substringAfter("number_items_").toInt())
|
||||
startsWith("number_items_") -> substringAfter("number_items_").toIntOrNull()?.let { NumberItemDownloadOption(it) }
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ class AppearancePreferences
|
||||
fun getColorScheme(): ColorScheme =
|
||||
store
|
||||
.getString(KEY_PREFERRED_COLOR_SCHEME, ColorScheme.FOLLOW_SYSTEM.name)
|
||||
?.let { ColorScheme.valueOf(it) }
|
||||
?.let { runCatching { ColorScheme.valueOf(it) }.getOrNull() }
|
||||
?: ColorScheme.FOLLOW_SYSTEM
|
||||
|
||||
fun saveColorScheme(colorScheme: ColorScheme) = store.putString(KEY_PREFERRED_COLOR_SCHEME, colorScheme.name)
|
||||
|
||||
@@ -33,15 +33,13 @@ class ConnectionPreferences
|
||||
fun clearClientCertAlias() = store.remove(KEY_CLIENT_CERT_ALIAS)
|
||||
|
||||
fun saveCustomHeaders(headers: List<ServerRequestHeader>) {
|
||||
val type = Types.newParameterizedType(List::class.java, ServerRequestHeader::class.java)
|
||||
val adapter = moshi.adapter<List<ServerRequestHeader>>(type)
|
||||
val adapter = moshi.adapter<List<ServerRequestHeader>>(customHeadersType)
|
||||
store.putString(KEY_CUSTOM_HEADERS, adapter.toJson(headers))
|
||||
}
|
||||
|
||||
fun getCustomHeaders(): List<ServerRequestHeader> {
|
||||
val json = store.getString(KEY_CUSTOM_HEADERS) ?: return emptyList()
|
||||
val type = Types.newParameterizedType(List::class.java, ServerRequestHeader::class.java)
|
||||
val adapter = moshi.adapter<List<ServerRequestHeader>>(type)
|
||||
val adapter = moshi.adapter<List<ServerRequestHeader>>(customHeadersType)
|
||||
return adapter.fromJson(json) ?: emptyList()
|
||||
}
|
||||
|
||||
@@ -77,6 +75,9 @@ class ConnectionPreferences
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val customHeadersType =
|
||||
Types.newParameterizedType(List::class.java, ServerRequestHeader::class.java)
|
||||
|
||||
private const val KEY_CUSTOM_HEADERS = "custom_headers"
|
||||
private const val KEY_BYPASS_SSL = "bypass_ssl"
|
||||
private const val KEY_LOCAL_URLS = "local_urls"
|
||||
|
||||
@@ -23,7 +23,7 @@ class DownloadPreferences
|
||||
fun getAutoDownloadNetworkType(): NetworkTypeAutoCache =
|
||||
store
|
||||
.getString(KEY_PREFERRED_AUTO_DOWNLOAD_NETWORK_TYPE, NetworkTypeAutoCache.WIFI_ONLY.name)
|
||||
?.let { NetworkTypeAutoCache.valueOf(it) }
|
||||
?.let { runCatching { NetworkTypeAutoCache.valueOf(it) }.getOrNull() }
|
||||
?: NetworkTypeAutoCache.WIFI_ONLY
|
||||
|
||||
fun saveAutoDownloadNetworkType(networkTypeAutoCache: NetworkTypeAutoCache) =
|
||||
|
||||
@@ -85,7 +85,7 @@ class LibraryPreferences
|
||||
private fun getPreferredLibraryType(): LibraryType =
|
||||
store
|
||||
.getString(KEY_PREFERRED_LIBRARY_TYPE)
|
||||
?.let { LibraryType.valueOf(it) }
|
||||
?.let { runCatching { LibraryType.valueOf(it) }.getOrNull() }
|
||||
?: LibraryType.LIBRARY
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -27,7 +27,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import org.grakovne.lissen.content.LissenMediaProvider
|
||||
import org.grakovne.lissen.persistence.preferences.PlaybackPreferences
|
||||
import org.grakovne.lissen.playback.service.PlaybackService
|
||||
@@ -206,10 +205,8 @@ class MediaLibrarySessionCallback
|
||||
.fetchBook(bookId)
|
||||
.foldAsync(
|
||||
onSuccess = {
|
||||
async {
|
||||
preferences.savePlayingItem(it)
|
||||
playbackSynchronizationService.startPlaybackSynchronization(it)
|
||||
}
|
||||
preferences.savePlayingItem(it)
|
||||
playbackSynchronizationService.startPlaybackSynchronization(it)
|
||||
mediaRepository.registerPlayingBook(it)
|
||||
PlaybackService.bookToChapterMediaItems(it)
|
||||
},
|
||||
@@ -256,7 +253,9 @@ class MediaLibrarySessionCallback
|
||||
pageSize: Int,
|
||||
params: MediaLibraryService.LibraryParams?,
|
||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||
val searchFuture = searchCache.get(query)
|
||||
val searchFuture =
|
||||
searchCache.get(query)
|
||||
?: return Futures.immediateFuture(LibraryResult.ofItemList(ImmutableList.of(), params))
|
||||
return Futures.transform(
|
||||
searchFuture,
|
||||
{ items ->
|
||||
|
||||
@@ -109,6 +109,14 @@ class MediaLibraryTree
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default)
|
||||
|
||||
private suspend fun libraries(): List<Library> =
|
||||
lissenMediaProvider
|
||||
.fetchLibraries()
|
||||
.fold(
|
||||
onSuccess = { it },
|
||||
onFailure = { emptyList() },
|
||||
)
|
||||
|
||||
private val root: MediaTreeNode by lazy { buildTree() }
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@@ -134,7 +142,7 @@ class MediaLibraryTree
|
||||
}
|
||||
|
||||
+mediaTreeNode(folderItem("$ROOT/$DOWNLOADS", context.getString(R.string.tree_node_downloads))) {
|
||||
pagedChildren { _, _ -> downloadedBooksItems() }
|
||||
pagedChildren { page, pageSize -> downloadedBooksItems(page, pageSize) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,21 +270,9 @@ class MediaLibraryTree
|
||||
)
|
||||
} ?: emptyList()
|
||||
|
||||
private suspend fun libraryItems(): List<MediaItem> =
|
||||
lissenMediaProvider
|
||||
.fetchLibraries()
|
||||
.fold(
|
||||
onSuccess = { libs -> libs.map { libraryFolderItem("$ROOT/$LIBRARY/${it.id}", it) } },
|
||||
onFailure = { emptyList() },
|
||||
)
|
||||
private suspend fun libraryItems(): List<MediaItem> = libraries().map { libraryFolderItem("$ROOT/$LIBRARY/${it.id}", it) }
|
||||
|
||||
private suspend fun resolveLibrary(libId: String): Library? =
|
||||
lissenMediaProvider
|
||||
.fetchLibraries()
|
||||
.fold(
|
||||
onSuccess = { libs -> libs.find { it.id == libId } },
|
||||
onFailure = { null },
|
||||
)
|
||||
private suspend fun resolveLibrary(libId: String): Library? = libraries().find { it.id == libId }
|
||||
|
||||
private suspend fun booksFromLibraryItems(
|
||||
libraryId: String,
|
||||
@@ -290,9 +286,12 @@ class MediaLibraryTree
|
||||
onFailure = { emptyList() },
|
||||
)
|
||||
|
||||
private suspend fun downloadedBooksItems(): List<MediaItem> =
|
||||
private suspend fun downloadedBooksItems(
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
): List<MediaItem> =
|
||||
localCacheRepository
|
||||
.fetchDetailedItems()
|
||||
.fetchDetailedItems(pageSize = pageSize, pageNumber = page)
|
||||
.fold(
|
||||
onSuccess = { paged -> paged.items.map { bookItem(it) } },
|
||||
onFailure = { emptyList() },
|
||||
|
||||
@@ -14,10 +14,8 @@ import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -37,7 +35,6 @@ import org.grakovne.lissen.playback.service.DefaultTimerActivator
|
||||
import org.grakovne.lissen.playback.service.PlaybackService
|
||||
import org.grakovne.lissen.playback.service.calculateChapterIndex
|
||||
import org.grakovne.lissen.playback.service.calculateChapterIndexAndPosition
|
||||
import org.grakovne.lissen.playback.service.calculateChapterPosition
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -126,7 +123,7 @@ class MediaRepository
|
||||
is PlaybackEvent.PlaybackReady -> {
|
||||
val book = preferences.getPlayingItem()
|
||||
book?.let {
|
||||
updateProgress(book).await()
|
||||
updateProgress(book)
|
||||
|
||||
if (mediaController.isPlaying) {
|
||||
progressPoller.start()
|
||||
@@ -438,19 +435,21 @@ class MediaRepository
|
||||
preferences.savePlayingItem(book)
|
||||
|
||||
eventBus.send(PlaybackCommand.PreparePlayback)
|
||||
} else {
|
||||
_isPlaybackReady.value = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateProgress(detailedItem: DetailedItem): Deferred<Unit> =
|
||||
scope.async {
|
||||
val currentIndex = mediaController.currentMediaItemIndex
|
||||
val accumulated = detailedItem.chapters.take(currentIndex).sumOf { it.duration }
|
||||
val currentFilePosition = mediaController.currentPosition / 1000.0
|
||||
private fun updateProgress(detailedItem: DetailedItem) {
|
||||
val currentIndex = mediaController.currentMediaItemIndex
|
||||
val chapters = detailedItem.chapters
|
||||
val accumulated = chapters.take(currentIndex.coerceIn(0, chapters.size)).sumOf { it.duration }
|
||||
val currentFilePosition = mediaController.currentPosition / 1000.0
|
||||
|
||||
val newPosition = accumulated + currentFilePosition
|
||||
_totalPosition.value = newPosition
|
||||
updateCurrentTrackData()
|
||||
}
|
||||
val newPosition = accumulated + currentFilePosition
|
||||
_totalPosition.value = newPosition
|
||||
updateCurrentTrackData()
|
||||
}
|
||||
|
||||
private fun play() {
|
||||
withMain {
|
||||
@@ -497,18 +496,18 @@ class MediaRepository
|
||||
|
||||
var safePosition = minOf(overallDuration, maxOf(0.0, position))
|
||||
|
||||
while (book.chapters[calculateChapterIndex(book, safePosition)].available.not()) {
|
||||
val chapterIndex =
|
||||
val startIndex = calculateChapterIndex(book, safePosition)
|
||||
if (startIndex in book.chapters.indices && book.chapters[startIndex].available.not()) {
|
||||
val forward = (startIndex..book.chapters.lastIndex).firstOrNull { book.chapters[it].available }
|
||||
val backward = (startIndex downTo 0).firstOrNull { book.chapters[it].available }
|
||||
|
||||
val target =
|
||||
when (direction) {
|
||||
ScrollingDirection.FORWARD -> calculateChapterIndex(book, safePosition) + 1
|
||||
ScrollingDirection.BACKWARD -> calculateChapterIndex(book, safePosition) - 1
|
||||
ScrollingDirection.FORWARD -> forward ?: backward
|
||||
ScrollingDirection.BACKWARD -> backward ?: forward
|
||||
}
|
||||
|
||||
safePosition =
|
||||
when {
|
||||
chapterIndex in 0..book.chapters.lastIndex -> book.chapters[chapterIndex].start
|
||||
else -> break
|
||||
}
|
||||
target?.let { safePosition = book.chapters[it].start }
|
||||
}
|
||||
|
||||
val (chapterIndex, chapterPosition) = calculateChapterIndexAndPosition(book, safePosition)
|
||||
@@ -542,8 +541,7 @@ class MediaRepository
|
||||
val book = playingBook.value ?: return
|
||||
val totalPosition = totalPosition.value
|
||||
|
||||
val trackIndex = calculateChapterIndex(book, totalPosition)
|
||||
val trackPosition = calculateChapterPosition(book, totalPosition)
|
||||
val (trackIndex, trackPosition) = calculateChapterIndexAndPosition(book, totalPosition)
|
||||
|
||||
_currentChapterIndex.value = trackIndex
|
||||
_currentChapterPosition.value = trackPosition
|
||||
@@ -560,7 +558,12 @@ class MediaRepository
|
||||
val playingBook = _playingBook.value ?: return
|
||||
val totalPosition = _totalPosition.value
|
||||
|
||||
val currentChapter = playingBook.chapters[calculateChapterIndex(playingBook, totalPosition)].title
|
||||
val chapterIndex = calculateChapterIndex(playingBook, totalPosition)
|
||||
if (chapterIndex !in playingBook.chapters.indices) {
|
||||
Timber.w("Unable to create bookmark: chapter index $chapterIndex out of bounds")
|
||||
return
|
||||
}
|
||||
val currentChapter = playingBook.chapters[chapterIndex].title
|
||||
val chapterPosition = _currentChapterPosition.value
|
||||
|
||||
val bookmarkTitle =
|
||||
|
||||
@@ -51,11 +51,15 @@ class PlaybackEnhancerService
|
||||
attachEqualizer(player.audioSessionId, sharedPreferences.getEqualizer())
|
||||
|
||||
scope.launch {
|
||||
sharedPreferences.playbackVolumeBoostFlow.collectLatest { updateGain(it) }
|
||||
sharedPreferences.playbackVolumeBoostFlow.collectLatest {
|
||||
withContext(Dispatchers.Main) { updateGain(it) }
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
sharedPreferences.equalizerFlow.collectLatest { applyEqualizer(it) }
|
||||
sharedPreferences.equalizerFlow.collectLatest {
|
||||
withContext(Dispatchers.Main) { applyEqualizer(it) }
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
|
||||
@@ -83,7 +83,7 @@ internal class LocalFallbackDataSource(
|
||||
private var usingLocal: Boolean = false
|
||||
|
||||
override fun open(dataSpec: DataSpec): Long {
|
||||
val (itemId, fileId) = unapply(dataSpec.uri) ?: return openPassthrough(dataSpec)
|
||||
val (itemId, fileId) = parseLissenUri(dataSpec.uri) ?: return openPassthrough(dataSpec)
|
||||
|
||||
val resolvedUri =
|
||||
mediaProvider
|
||||
|
||||
@@ -2,7 +2,7 @@ package org.grakovne.lissen.playback.service
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
fun apply(
|
||||
fun toLissenUri(
|
||||
mediaItemId: String,
|
||||
fileId: String,
|
||||
): Uri =
|
||||
@@ -13,7 +13,7 @@ fun apply(
|
||||
.appendPath(fileId)
|
||||
.build()
|
||||
|
||||
fun unapply(uri: Uri): Pair<String, String>? {
|
||||
fun parseLissenUri(uri: Uri): Pair<String, String>? {
|
||||
if (uri.scheme != "lissen") return null
|
||||
|
||||
val segments = uri.pathSegments
|
||||
|
||||
@@ -68,7 +68,7 @@ class LissenMediaSourceFactory(
|
||||
.createMediaSource(
|
||||
MediaItem
|
||||
.Builder()
|
||||
.setUri(apply(bookId, fileId))
|
||||
.setUri(toLissenUri(bookId, fileId))
|
||||
.apply { metadata?.let { setMediaMetadata(it) } }
|
||||
.build(),
|
||||
).let {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.grakovne.lissen.playback.service
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.os.BundleCompat
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
@@ -52,7 +53,7 @@ class PlaybackNavigationService
|
||||
return
|
||||
}
|
||||
|
||||
if (exoPlayer.currentMediaItem?.mediaId?.let { isTrackAvailable(it) } != false) {
|
||||
if (isTrackAvailable(exoPlayer.currentMediaItemIndex)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -66,10 +67,16 @@ class PlaybackNavigationService
|
||||
false -> Direction.BACKWARD
|
||||
}
|
||||
|
||||
val nextTrack = findAvailableTrackIndex(exoPlayer.currentMediaItemIndex, direction, exoPlayer, 0)
|
||||
val nextTrack = findAvailableTrackIndex(exoPlayer.currentMediaItemIndex, direction, exoPlayer)
|
||||
nextTrack?.let { exoPlayer.seekTo(it, 0) }
|
||||
|
||||
if (nextTrack == null || nextTrack < currentIndex) {
|
||||
val wrapped =
|
||||
when (direction) {
|
||||
Direction.FORWARD -> nextTrack != null && nextTrack < currentIndex
|
||||
Direction.BACKWARD -> nextTrack != null && nextTrack > currentIndex
|
||||
}
|
||||
|
||||
if (nextTrack == null || wrapped) {
|
||||
exoPlayer.pause()
|
||||
playbackSynchronizationService.cancelSynchronization()
|
||||
}
|
||||
@@ -80,42 +87,63 @@ class PlaybackNavigationService
|
||||
}
|
||||
|
||||
private fun findAvailableTrackIndex(
|
||||
currentItem: Int,
|
||||
startIndex: Int,
|
||||
direction: Direction,
|
||||
exoPlayer: ExoPlayer,
|
||||
iteration: Int,
|
||||
): Int? {
|
||||
if (isTrackAvailable(exoPlayer.getMediaItemAt(currentItem).mediaId)) {
|
||||
return currentItem
|
||||
}
|
||||
|
||||
if (iteration > 4096) {
|
||||
val count = exoPlayer.mediaItemCount
|
||||
if (count == 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
val foundItem =
|
||||
when (direction) {
|
||||
Direction.FORWARD -> (currentItem + 1) % exoPlayer.mediaItemCount
|
||||
Direction.BACKWARD -> if (currentItem - 1 < 0) exoPlayer.mediaItemCount - 1 else currentItem - 1
|
||||
var index = startIndex
|
||||
repeat(count) {
|
||||
if (isTrackAvailable(index)) {
|
||||
return index
|
||||
}
|
||||
|
||||
return findAvailableTrackIndex(foundItem, direction, exoPlayer, iteration + 1)
|
||||
index =
|
||||
when (direction) {
|
||||
Direction.FORWARD -> (index + 1) % count
|
||||
Direction.BACKWARD -> if (index - 1 < 0) count - 1 else index - 1
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun isTrackAvailable(fileId: String): Boolean {
|
||||
val mediaItem =
|
||||
exoPlayer
|
||||
.currentMediaItem
|
||||
?.localConfiguration
|
||||
?.tag as? DetailedItem
|
||||
private fun isTrackAvailable(index: Int): Boolean {
|
||||
if (index < 0 || index >= exoPlayer.mediaItemCount) {
|
||||
return false
|
||||
}
|
||||
|
||||
val mediaItem = exoPlayer.getMediaItemAt(index)
|
||||
|
||||
val bookId =
|
||||
LissenMediaSourceFactory.MediaId
|
||||
.fromString(mediaItem.mediaId)
|
||||
?.bookId
|
||||
?: return false
|
||||
|
||||
return mediaProvider
|
||||
.provideFileUri(mediaItem.id, fileId)
|
||||
.fold(
|
||||
onSuccess = { true },
|
||||
onFailure = { false },
|
||||
)
|
||||
val segments =
|
||||
mediaItem
|
||||
.requestMetadata
|
||||
.extras
|
||||
?.let { BundleCompat.getParcelableArrayList(it, PlaybackService.FILE_SEGMENTS, FileClip::class.java) }
|
||||
?: return false
|
||||
|
||||
if (segments.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return segments.all { clip ->
|
||||
mediaProvider
|
||||
.provideFileUri(bookId, clip.fileId)
|
||||
.fold(
|
||||
onSuccess = { true },
|
||||
onFailure = { false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -228,7 +228,10 @@ class PlaybackService : MediaLibraryService() {
|
||||
?: ChapterPosition(0, 0.0)
|
||||
|
||||
val negativeChapter = chapterIndex < 0
|
||||
val lastMoments = chapterIndex == book.chapters.lastIndex && (book.chapters.last().end - 5) < chapterOffset
|
||||
val lastMoments =
|
||||
!negativeChapter &&
|
||||
book.chapters.isNotEmpty() &&
|
||||
(book.chapters.last().end - 5) < (book.progress?.currentTime ?: 0.0)
|
||||
|
||||
if (negativeChapter || lastMoments) {
|
||||
chapterIndex = 0
|
||||
|
||||
@@ -125,10 +125,10 @@ class PlaybackSynchronizationService
|
||||
currentChapterIndex = currentIndex
|
||||
}
|
||||
|
||||
playbackSession?.let {
|
||||
playbackSession?.let { session ->
|
||||
requestSync(
|
||||
item = currentItem,
|
||||
it = it,
|
||||
session = session,
|
||||
overallProgress = overallProgress,
|
||||
)
|
||||
}
|
||||
@@ -136,12 +136,12 @@ class PlaybackSynchronizationService
|
||||
|
||||
private suspend fun requestSync(
|
||||
item: DetailedItem,
|
||||
it: PlaybackSession,
|
||||
session: PlaybackSession,
|
||||
overallProgress: PlaybackProgress,
|
||||
): Unit? =
|
||||
mediaChannel
|
||||
.syncProgress(
|
||||
sessionId = it.sessionId,
|
||||
sessionId = session.sessionId,
|
||||
detailedItem = item,
|
||||
progress = overallProgress,
|
||||
).foldAsync(
|
||||
@@ -196,14 +196,15 @@ class PlaybackSynchronizationService
|
||||
|
||||
internal const val SYNC_INTERVAL_LONG = 45_000L
|
||||
internal const val SYNC_INTERVAL_SHORT = 5_000L
|
||||
internal const val SHORT_SYNC_WINDOW = SYNC_INTERVAL_LONG * 2 - 1
|
||||
|
||||
private const val SHORT_SYNC_WINDOW_MS = 90_000L
|
||||
|
||||
internal fun chooseSyncInterval(
|
||||
durationMs: Long,
|
||||
positionMs: Long,
|
||||
): Long {
|
||||
val nearStart = positionMs < SHORT_SYNC_WINDOW
|
||||
val nearEnd = durationMs != C.TIME_UNSET && durationMs - positionMs < SHORT_SYNC_WINDOW
|
||||
val nearStart = positionMs < SHORT_SYNC_WINDOW_MS
|
||||
val nearEnd = durationMs != C.TIME_UNSET && durationMs - positionMs < SHORT_SYNC_WINDOW_MS
|
||||
|
||||
return when (nearStart || nearEnd) {
|
||||
true -> SYNC_INTERVAL_SHORT
|
||||
|
||||
@@ -137,8 +137,8 @@ fun LibraryScreen(
|
||||
val library = libraryViewModel.getPager(searchRequested).collectAsLazyPagingItems()
|
||||
val libraryCount by libraryViewModel.totalCount.collectAsState()
|
||||
val expandedGroups by libraryViewModel.expandedGroups.collectAsState()
|
||||
val groupBooks by libraryViewModel.groupBooks.collectAsState()
|
||||
val groupLoading by libraryViewModel.groupLoading.collectAsState()
|
||||
val groupBooks = libraryViewModel.groupBooks
|
||||
val groupLoading = libraryViewModel.groupLoading
|
||||
val libraryGrouping by settingsViewModel.libraryGrouping.collectAsState(LibraryGrouping.NONE)
|
||||
|
||||
val libraryListState = rememberLazyGridState()
|
||||
|
||||
@@ -234,11 +234,13 @@ private fun PlaybackProgressButton(
|
||||
val isPlaying by playerViewModel.isPlaying.collectAsState()
|
||||
val totalPosition by playerViewModel.totalPosition.collectAsState()
|
||||
|
||||
val totalDuration = remember(book.id) { book.chapters.sumOf { it.duration } }
|
||||
|
||||
val progress =
|
||||
calculateProgress(
|
||||
item = book,
|
||||
libraryType = libraryType,
|
||||
totalPosition = totalPosition,
|
||||
totalDuration = totalDuration,
|
||||
)
|
||||
|
||||
PlaybackButton(
|
||||
@@ -249,17 +251,14 @@ private fun PlaybackProgressButton(
|
||||
}
|
||||
|
||||
private fun calculateProgress(
|
||||
item: DetailedItem,
|
||||
libraryType: LibraryType?,
|
||||
totalPosition: Double,
|
||||
): Float {
|
||||
val totalDuration = item.chapters.sumOf { it.duration }
|
||||
|
||||
return when (totalDuration > 0 && libraryType == LibraryType.LIBRARY) {
|
||||
totalDuration: Double,
|
||||
): Float =
|
||||
when (totalDuration > 0 && libraryType == LibraryType.LIBRARY) {
|
||||
true -> (totalPosition / totalDuration).toFloat().coerceIn(0f, 1f).snapProgress()
|
||||
false -> 0f
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaybackButton(
|
||||
|
||||
@@ -221,16 +221,6 @@ fun QuickSettingsComposable(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
style = typography.labelMedium,
|
||||
color = colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleRow(
|
||||
title: String,
|
||||
|
||||
@@ -64,7 +64,7 @@ fun NavigationBarComposable(
|
||||
val playingQueueExpanded by playerViewModel.playingQueueExpanded.collectAsState()
|
||||
val hasEpisodes = book.chapters.isNotEmpty()
|
||||
|
||||
val isMetadataCached by contentCachingModelView.provideCacheState(book.id).collectAsState(initial = false)
|
||||
val isMetadataCached by remember(book.id) { contentCachingModelView.provideCacheState(book.id) }.collectAsState(initial = false)
|
||||
val totalPosition by playerViewModel.totalPosition.collectAsState()
|
||||
val remainingChapters = (book.chapters.size - calculateChapterIndex(book, totalPosition)).coerceAtLeast(1)
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.android.awaitFrame
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.grakovne.lissen.R
|
||||
import org.grakovne.lissen.ui.components.withScrollbar
|
||||
@@ -107,6 +109,16 @@ fun PlayingQueueComposable(
|
||||
}
|
||||
}
|
||||
|
||||
val bookId = book?.id ?: ""
|
||||
val cachedChapterIdsFlow =
|
||||
remember(bookId) {
|
||||
when (bookId.isEmpty()) {
|
||||
true -> flowOf(emptySet())
|
||||
false -> cachingModelView.provideCachedChapterIds(bookId).map { it.toSet() }
|
||||
}
|
||||
}
|
||||
val cachedChapterIds by cachedChapterIdsFlow.collectAsState(initial = emptySet())
|
||||
|
||||
val playbackReady by viewModel.isPlaybackReady.collectAsState()
|
||||
val playingQueueExpanded by viewModel.playingQueueExpanded.collectAsState()
|
||||
|
||||
@@ -298,21 +310,13 @@ fun PlayingQueueComposable(
|
||||
showingChapters,
|
||||
key = { _, chapter -> chapter.id },
|
||||
) { index, chapter ->
|
||||
val bookId = book?.id ?: ""
|
||||
|
||||
val cacheStateFlow =
|
||||
remember(bookId, chapter.id) {
|
||||
cachingModelView.provideCacheState(bookId = bookId, chapterId = chapter.id)
|
||||
}
|
||||
val isCached by cacheStateFlow.collectAsState(initial = false)
|
||||
|
||||
PlaylistItemComposable(
|
||||
track = chapter,
|
||||
onClick = { viewModel.setChapter(chapter) },
|
||||
isSelected = chapter.id == currentTrackId?.id,
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
maxDuration = maxDuration,
|
||||
isCached = isCached,
|
||||
isCached = chapter.id in cachedChapterIds,
|
||||
)
|
||||
|
||||
if (index < showingChapters.size - 1) {
|
||||
|
||||
@@ -36,6 +36,7 @@ import org.grakovne.lissen.ui.navigation.AppNavigationService
|
||||
import org.grakovne.lissen.ui.screens.settings.advanced.AdvancedSettingsNavigationItemComposable
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.GitHubLinkComposable
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.LicenseFooterComposable
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
|
||||
import org.grakovne.lissen.viewmodel.SettingsViewModel
|
||||
|
||||
@Composable
|
||||
@@ -53,24 +54,9 @@ fun SettingsScreen(
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_screen_title),
|
||||
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||
modifier = Modifier.semantics { heading() },
|
||||
color = colorScheme.onSurface,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onBack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = stringResource(R.string.a11y_back),
|
||||
tint = colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
},
|
||||
SettingsTopAppBar(
|
||||
title = stringResource(R.string.settings_screen_title),
|
||||
onBack = onBack,
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
|
||||
@@ -39,6 +39,7 @@ import org.grakovne.lissen.common.shareFile
|
||||
import org.grakovne.lissen.ui.navigation.AppNavigationService
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.SettingsInfoBanner
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.SettingsToggleItem
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
|
||||
import org.grakovne.lissen.viewmodel.CachingModelView
|
||||
import org.grakovne.lissen.viewmodel.SettingsViewModel
|
||||
|
||||
@@ -59,24 +60,9 @@ fun AdvancedSettingsComposable(
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_screen_advanced_preferences_title),
|
||||
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||
modifier = Modifier.semantics { heading() },
|
||||
color = colorScheme.onSurface,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onBack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = stringResource(R.string.a11y_back),
|
||||
tint = colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
},
|
||||
SettingsTopAppBar(
|
||||
title = stringResource(R.string.settings_screen_advanced_preferences_title),
|
||||
onBack = onBack,
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
|
||||
@@ -33,6 +33,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import org.grakovne.lissen.R
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.ColorSchemeSettingsComposable
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.SettingsToggleItem
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
|
||||
import org.grakovne.lissen.viewmodel.SettingsViewModel
|
||||
|
||||
@Composable
|
||||
@@ -43,24 +44,9 @@ fun AppearancePreferencesScreen(onBack: () -> Unit) {
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.appearance_preferences_title),
|
||||
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||
modifier = Modifier.semantics { heading() },
|
||||
color = colorScheme.onSurface,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onBack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = stringResource(R.string.a11y_back),
|
||||
tint = colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
},
|
||||
SettingsTopAppBar(
|
||||
title = stringResource(R.string.appearance_preferences_title),
|
||||
onBack = onBack,
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import org.grakovne.lissen.R
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
|
||||
import org.grakovne.lissen.viewmodel.SettingsViewModel
|
||||
|
||||
@Composable
|
||||
@@ -75,22 +76,9 @@ fun ClientCertificateSettingsScreen(onBack: () -> Unit) {
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_screen_client_cert_title),
|
||||
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||
modifier = Modifier.semantics { heading() },
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = stringResource(R.string.a11y_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
SettingsTopAppBar(
|
||||
title = stringResource(R.string.settings_screen_client_cert_title),
|
||||
onBack = onBack,
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
|
||||
@@ -40,6 +40,7 @@ import org.grakovne.lissen.R
|
||||
import org.grakovne.lissen.common.restartApplication
|
||||
import org.grakovne.lissen.common.shareFile
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.SettingsInfoBanner
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
|
||||
import org.grakovne.lissen.viewmodel.SettingsViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -80,24 +81,9 @@ fun ConfigBackupSettingsScreen(onBack: () -> Unit) {
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.config_backup_title),
|
||||
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||
modifier = Modifier.semantics { heading() },
|
||||
color = colorScheme.onSurface,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = stringResource(R.string.a11y_back),
|
||||
tint = colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
},
|
||||
SettingsTopAppBar(
|
||||
title = stringResource(R.string.config_backup_title),
|
||||
onBack = onBack,
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
|
||||
@@ -46,6 +46,7 @@ import org.grakovne.lissen.ui.navigation.AppNavigationService
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.DisconnectServerComposable
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.ServerInfoComposable
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.SettingsToggleItem
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
|
||||
import org.grakovne.lissen.viewmodel.SettingsViewModel
|
||||
|
||||
@Composable
|
||||
@@ -63,22 +64,9 @@ fun ConnectionSettingsScreen(
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.connection_settings_title),
|
||||
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||
modifier = Modifier.semantics { heading() },
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onBack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = stringResource(R.string.a11y_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
SettingsTopAppBar(
|
||||
title = stringResource(R.string.connection_settings_title),
|
||||
onBack = onBack,
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
|
||||
@@ -38,6 +38,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.grakovne.lissen.R
|
||||
import org.grakovne.lissen.domain.connection.ServerRequestHeader
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
|
||||
import org.grakovne.lissen.viewmodel.SettingsViewModel
|
||||
import kotlin.math.max
|
||||
|
||||
@@ -55,26 +56,9 @@ fun CustomHeadersSettingsScreen(onBack: () -> Unit) {
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.custom_headers_title),
|
||||
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||
modifier = Modifier.semantics { heading() },
|
||||
color = colorScheme.onSurface,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {
|
||||
onBack()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = stringResource(R.string.a11y_back),
|
||||
tint = colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
},
|
||||
SettingsTopAppBar(
|
||||
title = stringResource(R.string.custom_headers_title),
|
||||
onBack = onBack,
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
|
||||
@@ -52,6 +52,7 @@ import org.grakovne.lissen.ui.screens.common.hasLocationPermission
|
||||
import org.grakovne.lissen.ui.screens.common.localNetworkPermission
|
||||
import org.grakovne.lissen.ui.screens.common.locationPermission
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.SettingsInfoBanner
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
|
||||
import org.grakovne.lissen.viewmodel.SettingsViewModel
|
||||
import kotlin.math.max
|
||||
|
||||
@@ -76,26 +77,9 @@ fun LocalUrlSettingsScreen(onBack: () -> Unit) {
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_screen_internal_connection_url_title),
|
||||
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||
modifier = Modifier.semantics { heading() },
|
||||
color = colorScheme.onSurface,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {
|
||||
onBack()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = stringResource(R.string.a11y_back),
|
||||
tint = colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
},
|
||||
SettingsTopAppBar(
|
||||
title = stringResource(R.string.settings_screen_internal_connection_url_title),
|
||||
onBack = onBack,
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
|
||||
@@ -39,6 +39,7 @@ import org.grakovne.lissen.ui.screens.settings.composable.EqualizerSettingsCompo
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.PlaybackVolumeBoostSettingsComposable
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.SettingsInfoBanner
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.SettingsToggleItem
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
|
||||
import org.grakovne.lissen.viewmodel.SettingsViewModel
|
||||
|
||||
@Composable
|
||||
@@ -54,24 +55,9 @@ fun PlaybackPreferencesScreen(
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.playback_preferences_title),
|
||||
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||
modifier = Modifier.semantics { heading() },
|
||||
color = colorScheme.onSurface,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onBack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = stringResource(R.string.a11y_back),
|
||||
tint = colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
},
|
||||
SettingsTopAppBar(
|
||||
title = stringResource(R.string.playback_preferences_title),
|
||||
onBack = onBack,
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
|
||||
@@ -50,6 +50,7 @@ import org.grakovne.lissen.R
|
||||
import org.grakovne.lissen.common.withHaptic
|
||||
import org.grakovne.lissen.ui.components.LissenModalBottomSheet
|
||||
import org.grakovne.lissen.ui.components.slider.SeekTimeSlider
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
|
||||
import org.grakovne.lissen.viewmodel.SettingsViewModel
|
||||
|
||||
@Composable
|
||||
@@ -63,22 +64,9 @@ fun SeekSettingsScreen(onBack: () -> Unit) {
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_screen_seek_time_title),
|
||||
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||
modifier = Modifier.semantics { heading() },
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onBack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = stringResource(R.string.a11y_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
SettingsTopAppBar(
|
||||
title = stringResource(R.string.settings_screen_seek_time_title),
|
||||
onBack = onBack,
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
@@ -114,7 +102,7 @@ fun SeekSettingsScreen(onBack: () -> Unit) {
|
||||
title = stringResource(R.string.rewind_interval),
|
||||
currentSeconds = preferredSeekTime.rewind,
|
||||
onDismissRequest = { rewindExpanded = false },
|
||||
onUpdate = { viewModel.preferRewindRewind(it) },
|
||||
onUpdate = { viewModel.preferRewind(it) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -123,7 +111,7 @@ fun SeekSettingsScreen(onBack: () -> Unit) {
|
||||
title = stringResource(R.string.forward_interval),
|
||||
currentSeconds = preferredSeekTime.forward,
|
||||
onDismissRequest = { forwardExpanded = false },
|
||||
onUpdate = { viewModel.preferForwardRewind(it) },
|
||||
onUpdate = { viewModel.preferForward(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import org.grakovne.lissen.R
|
||||
import org.grakovne.lissen.ui.navigation.AppNavigationService
|
||||
import org.grakovne.lissen.ui.screens.settings.advanced.AdvancedSettingsNavigationItemComposable
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.SettingsToggleItem
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
|
||||
import org.grakovne.lissen.viewmodel.SettingsViewModel
|
||||
|
||||
@Composable
|
||||
@@ -47,24 +48,9 @@ fun CacheSettingsScreen(
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.download_settings_title),
|
||||
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||
modifier = Modifier.semantics { heading() },
|
||||
color = colorScheme.onSurface,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onBack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = stringResource(R.string.a11y_back),
|
||||
tint = colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
},
|
||||
SettingsTopAppBar(
|
||||
title = stringResource(R.string.download_settings_title),
|
||||
onBack = onBack,
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
|
||||
@@ -78,6 +78,7 @@ import org.grakovne.lissen.ui.components.BookCoverKey
|
||||
import org.grakovne.lissen.ui.components.ImageFetcher.Companion.LocalOnlyKey
|
||||
import org.grakovne.lissen.ui.components.withScrollbar
|
||||
import org.grakovne.lissen.ui.extensions.withMinimumTime
|
||||
import org.grakovne.lissen.ui.screens.settings.composable.SettingsTopAppBar
|
||||
import org.grakovne.lissen.viewmodel.CachingModelView
|
||||
import org.grakovne.lissen.viewmodel.PlayerViewModel
|
||||
|
||||
@@ -127,24 +128,9 @@ fun CachedItemsSettingsScreen(
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_screen_cached_items_title),
|
||||
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||
modifier = Modifier.semantics { heading() },
|
||||
color = colorScheme.onSurface,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onBack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = stringResource(R.string.a11y_back),
|
||||
tint = colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
},
|
||||
SettingsTopAppBar(
|
||||
title = stringResource(R.string.settings_screen_cached_items_title),
|
||||
onBack = onBack,
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
|
||||
@@ -80,8 +80,6 @@ fun ServerInfoComposable(
|
||||
IconButton(
|
||||
onClick = {
|
||||
connectionInfoExpanded = true
|
||||
// navController.showLogin()
|
||||
// viewModel.logout()
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.grakovne.lissen.ui.screens.settings.composable
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme.colorScheme
|
||||
import androidx.compose.material3.MaterialTheme.typography
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import org.grakovne.lissen.R
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun SettingsTopAppBar(
|
||||
title: String,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = title,
|
||||
style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||
modifier = Modifier.semantics { heading() },
|
||||
color = colorScheme.onSurface,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = stringResource(R.string.a11y_back),
|
||||
tint = colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -167,6 +167,8 @@ class CachingModelView
|
||||
chapterId: String,
|
||||
): Flow<Boolean> = contentCachingManager.hasMetadataCached(bookId, chapterId)
|
||||
|
||||
fun provideCachedChapterIds(bookId: String): Flow<List<String>> = contentCachingManager.provideCachedChapterIds(bookId)
|
||||
|
||||
fun fetchCachedItems() {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package org.grakovne.lissen.viewmodel
|
||||
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.Pager
|
||||
@@ -68,11 +72,9 @@ class LibraryViewModel
|
||||
private val _expandedGroups = MutableStateFlow<Set<String>>(emptySet())
|
||||
val expandedGroups: StateFlow<Set<String>> = _expandedGroups.asStateFlow()
|
||||
|
||||
private val _groupBooks = MutableStateFlow<Map<String, List<Book>>>(emptyMap())
|
||||
val groupBooks: StateFlow<Map<String, List<Book>>> = _groupBooks.asStateFlow()
|
||||
val groupBooks: SnapshotStateMap<String, List<Book>> = mutableStateMapOf()
|
||||
|
||||
private val _groupLoading = MutableStateFlow<Set<String>>(emptySet())
|
||||
val groupLoading: StateFlow<Set<String>> = _groupLoading.asStateFlow()
|
||||
val groupLoading: SnapshotStateList<String> = mutableStateListOf()
|
||||
|
||||
private val prefetchSemaphore = Semaphore(MAX_CONCURRENT_PREFETCH)
|
||||
|
||||
@@ -171,8 +173,8 @@ class LibraryViewModel
|
||||
|
||||
fun resetGroupExpansion() {
|
||||
_expandedGroups.value = emptySet()
|
||||
_groupBooks.value = emptyMap()
|
||||
_groupLoading.value = emptySet()
|
||||
groupBooks.clear()
|
||||
groupLoading.clear()
|
||||
}
|
||||
|
||||
private fun LibraryEntry.groupId(): String? =
|
||||
@@ -182,7 +184,7 @@ class LibraryViewModel
|
||||
is LibraryEntry.BookEntry -> null
|
||||
}
|
||||
|
||||
private fun alreadyResolved(groupId: String): Boolean = _groupBooks.value.containsKey(groupId) || groupId in _groupLoading.value
|
||||
private fun alreadyResolved(groupId: String): Boolean = groupBooks.containsKey(groupId) || groupId in groupLoading
|
||||
|
||||
private suspend fun fetchGroupBooks(entry: LibraryEntry) {
|
||||
val groupId = entry.groupId() ?: return
|
||||
@@ -192,7 +194,7 @@ class LibraryViewModel
|
||||
|
||||
val libraryId = preferences.getPreferredLibrary()?.id ?: return
|
||||
|
||||
_groupLoading.value = _groupLoading.value + groupId
|
||||
groupLoading.add(groupId)
|
||||
val result =
|
||||
when (entry) {
|
||||
is LibraryEntry.SeriesEntry -> mediaChannel.fetchSeriesItems(libraryId = libraryId, seriesId = entry.id)
|
||||
@@ -207,11 +209,11 @@ class LibraryViewModel
|
||||
is LibraryEntry.SeriesEntry -> books.sortedBySeriesPosition()
|
||||
else -> books
|
||||
}
|
||||
_groupBooks.value = _groupBooks.value + (groupId to ordered)
|
||||
groupBooks[groupId] = ordered
|
||||
},
|
||||
onFailure = { },
|
||||
)
|
||||
_groupLoading.value = _groupLoading.value - groupId
|
||||
groupLoading.remove(groupId)
|
||||
}
|
||||
|
||||
fun applyLinkedSearch(token: String) {
|
||||
|
||||
@@ -94,7 +94,7 @@ class LoginViewModel
|
||||
mediaChannel.startOAuth(
|
||||
host = host,
|
||||
onSuccess = { _loginState.value = LoginState.Idle },
|
||||
onFailure = { onLoginFailure(it) },
|
||||
onFailure = { _loginState.value = onLoginFailure(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -133,12 +133,7 @@ class LoginViewModel
|
||||
}
|
||||
}
|
||||
|
||||
private fun onLoginFailure(error: OperationError): LoginState.Error {
|
||||
viewModelScope.launch {
|
||||
_loginState.value = LoginState.Error(error)
|
||||
}
|
||||
return LoginState.Error(error)
|
||||
}
|
||||
private fun onLoginFailure(error: OperationError): LoginState.Error = LoginState.Error(error)
|
||||
|
||||
sealed class LoginState {
|
||||
data object Idle : LoginState()
|
||||
|
||||
@@ -352,8 +352,8 @@ class SettingsViewModel
|
||||
download.saveAutoDownloadOption(option)
|
||||
}
|
||||
|
||||
fun preferForwardRewind(seconds: Int) {
|
||||
Timber.d("User action: preferForwardSkip $seconds")
|
||||
fun preferForward(seconds: Int) {
|
||||
Timber.d("User action: preferForward $seconds")
|
||||
val current = _seekTime.value
|
||||
val updated = current.copy(forward = seconds)
|
||||
|
||||
@@ -361,8 +361,8 @@ class SettingsViewModel
|
||||
_seekTime.value = updated
|
||||
}
|
||||
|
||||
fun preferRewindRewind(seconds: Int) {
|
||||
Timber.d("User action: preferRewindSkip $seconds")
|
||||
fun preferRewind(seconds: Int) {
|
||||
Timber.d("User action: preferRewind $seconds")
|
||||
val current = _seekTime.value
|
||||
val updated = current.copy(rewind = seconds)
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.grakovne.lissen.widget
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import timber.log.Timber
|
||||
|
||||
suspend fun safelyRun(
|
||||
playingItemId: String,
|
||||
context: Context,
|
||||
action: (WidgetPlaybackController) -> Unit,
|
||||
) {
|
||||
try {
|
||||
val playbackController =
|
||||
EntryPointAccessors
|
||||
.fromApplication(
|
||||
context = context.applicationContext,
|
||||
entryPoint = WidgetPlaybackControllerEntryPoint::class.java,
|
||||
).widgetPlaybackController()
|
||||
|
||||
when (playbackController.providePlayingItem()) {
|
||||
null -> playbackController.prepareAndRun(playingItemId) { action(playbackController) }
|
||||
else -> action(playbackController)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.w("Unable to run $action on $playingItemId due to $ex")
|
||||
}
|
||||
}
|
||||
@@ -35,13 +35,12 @@ import androidx.glance.semantics.testTag
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.media3.session.R.drawable.media3_icon_pause
|
||||
import androidx.media3.session.R.drawable.media3_icon_play
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import org.grakovne.lissen.R.drawable
|
||||
import org.grakovne.lissen.ui.theme.WidgetBackgroundDark
|
||||
import org.grakovne.lissen.ui.theme.WidgetBackgroundLight
|
||||
import org.grakovne.lissen.widget.WidgetPlaybackControllerEntryPoint
|
||||
import org.grakovne.lissen.widget.bitmapFromFile
|
||||
import org.grakovne.lissen.widget.bitmapFromResource
|
||||
import org.grakovne.lissen.widget.safelyRun
|
||||
import org.grakovne.lissen.widget.state.PlayerStateWidget
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
@@ -167,21 +166,7 @@ class PlayerCoverTogglePlaybackAction : ActionCallback {
|
||||
) {
|
||||
val playingItemId = parameters[PlayerCoverWidget.bookIdActionKey] ?: return
|
||||
|
||||
try {
|
||||
val playbackController =
|
||||
EntryPointAccessors
|
||||
.fromApplication(
|
||||
context = context.applicationContext,
|
||||
entryPoint = WidgetPlaybackControllerEntryPoint::class.java,
|
||||
).widgetPlaybackController()
|
||||
|
||||
when (playbackController.providePlayingItem()) {
|
||||
null -> playbackController.prepareAndRun(playingItemId) { playbackController.togglePlayPause() }
|
||||
else -> playbackController.togglePlayPause()
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Unable to toggle playback from PlayerCoverWidget for %s", playingItemId)
|
||||
}
|
||||
safelyRun(playingItemId = playingItemId, context = context) { it.togglePlayPause() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,13 +42,12 @@ import androidx.glance.text.FontFamily.Companion.SansSerif
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import androidx.media3.session.R
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import org.grakovne.lissen.R.drawable
|
||||
import org.grakovne.lissen.ui.theme.LightBackground
|
||||
import org.grakovne.lissen.ui.theme.MediumBackground
|
||||
import org.grakovne.lissen.widget.WidgetPlaybackControllerEntryPoint
|
||||
import org.grakovne.lissen.widget.bitmapFromFile
|
||||
import org.grakovne.lissen.widget.bitmapFromResource
|
||||
import org.grakovne.lissen.widget.safelyRun
|
||||
import org.grakovne.lissen.widget.state.PlayerStateWidget.Companion.bookIdKey
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
@@ -332,25 +331,3 @@ private fun provideAppLaunchIntent(context: Context): Intent? =
|
||||
.packageManager
|
||||
.getLaunchIntentForPackage(context.packageName)
|
||||
?.apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP }
|
||||
|
||||
private suspend fun safelyRun(
|
||||
playingItemId: String,
|
||||
context: Context,
|
||||
action: (org.grakovne.lissen.widget.WidgetPlaybackController) -> Unit,
|
||||
) {
|
||||
try {
|
||||
val playbackController =
|
||||
EntryPointAccessors
|
||||
.fromApplication(
|
||||
context = context.applicationContext,
|
||||
entryPoint = WidgetPlaybackControllerEntryPoint::class.java,
|
||||
).widgetPlaybackController()
|
||||
|
||||
when (playbackController.providePlayingItem()) {
|
||||
null -> playbackController.prepareAndRun(playingItemId) { action(playbackController) }
|
||||
else -> action(playbackController)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.w("Unable to run $action on $playingItemId due to $ex")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,12 +200,12 @@ class LissenMediaProviderTest {
|
||||
runBlocking {
|
||||
every { preferences.isForceCache() } returns true
|
||||
coEvery {
|
||||
localCacheRepository.searchBooks(libraryId = "l1", query = "test")
|
||||
localCacheRepository.searchBooks(libraryId = "l1", query = "test", limit = 10)
|
||||
} returns OperationResult.Success(emptyList())
|
||||
|
||||
provider.searchBooks("l1", "test", 10)
|
||||
|
||||
coVerify { localCacheRepository.searchBooks("l1", "test") }
|
||||
coVerify { localCacheRepository.searchBooks("l1", "test", 10) }
|
||||
coVerify(exactly = 0) { mediaChannel.searchBooks(any(), any(), any()) }
|
||||
}
|
||||
|
||||
|
||||
@@ -78,13 +78,28 @@ class CachingSessionRegistryTest {
|
||||
@Test
|
||||
fun `settling a registration without status leaves nothing in progress`() {
|
||||
val registry = CachingSessionRegistry()
|
||||
val job = Job()
|
||||
|
||||
registry.register("book-1", Job())
|
||||
registry.settle("book-1")
|
||||
registry.register("book-1", job)
|
||||
registry.settle("book-1", job)
|
||||
|
||||
assertFalse(registry.inProgress())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `settling a stale job does not clear the newer registration`() {
|
||||
val registry = CachingSessionRegistry()
|
||||
val first = Job()
|
||||
val second = Job()
|
||||
|
||||
registry.register("book-1", first)
|
||||
registry.register("book-1", second)
|
||||
|
||||
registry.settle("book-1", first)
|
||||
|
||||
assertTrue(registry.inProgress())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first status update replaces the pending registration`() {
|
||||
val registry = CachingSessionRegistry()
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package org.grakovne.lissen.content.cache.persistent.api
|
||||
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.grakovne.lissen.common.LibraryOrderingConfiguration
|
||||
import org.grakovne.lissen.content.cache.persistent.OfflineBookStorageProperties
|
||||
@@ -11,9 +14,11 @@ import org.grakovne.lissen.content.cache.persistent.converter.CachedBookEntityDe
|
||||
import org.grakovne.lissen.content.cache.persistent.converter.CachedBookEntityRecentConverter
|
||||
import org.grakovne.lissen.content.cache.persistent.converter.MediaProgressEntityConverter
|
||||
import org.grakovne.lissen.content.cache.persistent.dao.CachedBookDao
|
||||
import org.grakovne.lissen.content.cache.persistent.entity.AuthorEntry
|
||||
import org.grakovne.lissen.content.cache.persistent.entity.BookEntity
|
||||
import org.grakovne.lissen.content.cache.persistent.entity.GroupedEntry
|
||||
import org.grakovne.lissen.domain.LibraryEntry
|
||||
import org.grakovne.lissen.domain.LibraryType
|
||||
import org.grakovne.lissen.persistence.preferences.LibraryPreferences
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
@@ -85,23 +90,18 @@ class CachedBookRepositoryTest {
|
||||
val byId = books.associateBy { it.id }
|
||||
val bySeries = books.filter { it.seriesId != null }.groupBy { it.seriesId }
|
||||
|
||||
coEvery { bookDao.countGroupedEntries(any()) } returns headers.size
|
||||
coEvery { bookDao.countRaw(any()) } returns headers.size
|
||||
coEvery { bookDao.fetchGroupedEntries(any()) } returns headers
|
||||
coEvery { bookDao.fetchBooksByIds(any()) } answers { firstArg<List<String>>().mapNotNull { byId[it] } }
|
||||
coEvery { bookDao.fetchBooksBySeriesIds(any()) } answers { firstArg<List<String>>().flatMap { bySeries[it].orEmpty() } }
|
||||
}
|
||||
|
||||
private fun stubBooks(books: List<BookEntity>) {
|
||||
coEvery { bookDao.countCachedBooks(libraryId = any()) } returns books.size
|
||||
coEvery { bookDao.fetchCachedBooks(any()) } returns books
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty library produces no entries`() =
|
||||
runBlocking {
|
||||
coEvery { bookDao.countGroupedEntries(any()) } returns 0
|
||||
coEvery { bookDao.countRaw(any()) } returns 0
|
||||
|
||||
assertTrue(repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0).items.isEmpty())
|
||||
assertTrue(repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = null).items.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -123,7 +123,7 @@ class CachedBookRepositoryTest {
|
||||
),
|
||||
)
|
||||
|
||||
val entries = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0).items
|
||||
val entries = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = null).items
|
||||
|
||||
assertEquals(2, entries.size)
|
||||
|
||||
@@ -146,7 +146,15 @@ class CachedBookRepositoryTest {
|
||||
books = listOf(entity("b1", seriesId = "ser-x", seriesJson = null)),
|
||||
)
|
||||
|
||||
val series = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0).items.single() as LibraryEntry.SeriesEntry
|
||||
val series =
|
||||
repository
|
||||
.fetchLibraryGrouped(
|
||||
LIBRARY_ID,
|
||||
pageSize = 20,
|
||||
pageNumber = 0,
|
||||
libraryType = null,
|
||||
).items
|
||||
.single() as LibraryEntry.SeriesEntry
|
||||
assertEquals("ser-x", series.title)
|
||||
}
|
||||
|
||||
@@ -163,7 +171,7 @@ class CachedBookRepositoryTest {
|
||||
books = listOf(entity("a"), entity("b"), entity("c")),
|
||||
)
|
||||
|
||||
val entries = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0).items
|
||||
val entries = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = null).items
|
||||
assertEquals(
|
||||
listOf("a", "b", "c"),
|
||||
entries.map { (it as LibraryEntry.BookEntry).book.id },
|
||||
@@ -178,7 +186,7 @@ class CachedBookRepositoryTest {
|
||||
books = (1..5).map { entity("b$it", seriesId = "ser", seriesJson = seriesJson("Series", "$it", "ser")) },
|
||||
)
|
||||
|
||||
val page = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0)
|
||||
val page = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = null)
|
||||
assertEquals(1, page.totalItems)
|
||||
assertEquals(1, page.items.size)
|
||||
}
|
||||
@@ -186,13 +194,13 @@ class CachedBookRepositoryTest {
|
||||
@Test
|
||||
fun `fetchSeriesItems returns only books of the requested series`() =
|
||||
runBlocking {
|
||||
coEvery { bookDao.fetchBooksBySeriesIds(listOf("ser-dune")) } returns
|
||||
coEvery { bookDao.fetchCachedBooks(any()) } returns
|
||||
listOf(
|
||||
entity("b1", seriesId = "ser-dune", seriesJson = seriesJson("Dune", "1", "ser-dune")),
|
||||
entity("b2", seriesId = "ser-dune", seriesJson = seriesJson("Dune", "2", "ser-dune")),
|
||||
)
|
||||
|
||||
val books = repository.fetchSeriesItems(LIBRARY_ID, "ser-dune")
|
||||
val books = repository.fetchSeriesItems(LIBRARY_ID, "ser-dune", libraryType = null)
|
||||
assertEquals(listOf("b1", "b2"), books.map { it.id })
|
||||
}
|
||||
|
||||
@@ -204,49 +212,92 @@ class CachedBookRepositoryTest {
|
||||
books = (1..5).map { entity("b$it", seriesId = "ser", seriesJson = seriesJson("Series", "$it", "ser")) },
|
||||
)
|
||||
|
||||
val series = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0).items.single()
|
||||
val series = repository.fetchLibraryGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = null).items.single()
|
||||
assertInstanceOf(LibraryEntry.SeriesEntry::class.java, series)
|
||||
assertEquals(listOf("b1", "b2", "b3", "b4", "b5"), (series as LibraryEntry.SeriesEntry).coverItemIds)
|
||||
assertEquals(5, series.bookCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `groups cached books by primary author sorted by name and skips authorless books`() =
|
||||
fun `maps author rows into paged author entries`() =
|
||||
runBlocking {
|
||||
stubBooks(
|
||||
coEvery { bookDao.countRaw(any()) } returns 2
|
||||
coEvery { bookDao.fetchAuthorEntries(any()) } returns
|
||||
listOf(
|
||||
entity("b1", author = "Frank Herbert"),
|
||||
entity("b2", author = "Frank Herbert, Brian Herbert"),
|
||||
entity("b3", author = "Andy Weir"),
|
||||
entity("b4", author = null),
|
||||
),
|
||||
)
|
||||
AuthorEntry(author = "Andy Weir", bookCount = 1),
|
||||
AuthorEntry(author = "Frank Herbert", bookCount = 2),
|
||||
)
|
||||
|
||||
val entries = repository.fetchAuthorsGrouped(LIBRARY_ID)
|
||||
assertEquals(2, entries.size)
|
||||
val page = repository.fetchAuthorsGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = null)
|
||||
assertEquals(2, page.totalItems)
|
||||
assertEquals(2, page.items.size)
|
||||
|
||||
val first = entries[0] as LibraryEntry.AuthorEntry
|
||||
val first = page.items[0] as LibraryEntry.AuthorEntry
|
||||
assertEquals("Andy Weir", first.id)
|
||||
assertEquals(1, first.bookCount)
|
||||
|
||||
val second = entries[1] as LibraryEntry.AuthorEntry
|
||||
val second = page.items[1] as LibraryEntry.AuthorEntry
|
||||
assertEquals("Frank Herbert", second.id)
|
||||
assertEquals(2, second.bookCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetchAuthorItems returns only the requested author's books`() =
|
||||
fun `author grouping hides completed books only for library type`() =
|
||||
runBlocking {
|
||||
stubBooks(
|
||||
every { preferences.getHideCompleted() } returns true
|
||||
val queries = mutableListOf<SupportSQLiteQuery>()
|
||||
coEvery { bookDao.countRaw(capture(queries)) } returns 0
|
||||
|
||||
repository.fetchAuthorsGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = LibraryType.LIBRARY)
|
||||
repository.fetchAuthorsGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = LibraryType.PODCAST)
|
||||
|
||||
assertTrue(queries[0].sql.contains("isFinished"))
|
||||
assertFalse(queries[1].sql.contains("isFinished"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty author grouping skips the page query`() =
|
||||
runBlocking {
|
||||
coEvery { bookDao.countRaw(any()) } returns 0
|
||||
|
||||
val page = repository.fetchAuthorsGrouped(LIBRARY_ID, pageSize = 20, pageNumber = 0, libraryType = null)
|
||||
assertEquals(0, page.totalItems)
|
||||
assertTrue(page.items.isEmpty())
|
||||
coVerify(exactly = 0) { bookDao.fetchAuthorEntries(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetchAuthorItems filters by author key in sql`() =
|
||||
runBlocking {
|
||||
val query = slot<SupportSQLiteQuery>()
|
||||
coEvery { bookDao.fetchCachedBooks(capture(query)) } returns
|
||||
listOf(
|
||||
entity("b1", author = "Frank Herbert"),
|
||||
entity("b2", author = "Andy Weir"),
|
||||
entity("b3", author = "Frank Herbert, Brian Herbert"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
val books = repository.fetchAuthorItems(LIBRARY_ID, "Frank Herbert")
|
||||
val books = repository.fetchAuthorItems(LIBRARY_ID, "Frank Herbert", libraryType = null)
|
||||
assertEquals(listOf("b1", "b3"), books.map { it.id })
|
||||
assertTrue(query.captured.sql.contains("WHERE libraryId = ?"))
|
||||
assertTrue(query.captured.sql.contains("instr(author, ',')"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `series and author drill-down hide completed only for library type`() =
|
||||
runBlocking {
|
||||
every { preferences.getHideCompleted() } returns true
|
||||
val queries = mutableListOf<SupportSQLiteQuery>()
|
||||
coEvery { bookDao.fetchCachedBooks(capture(queries)) } returns emptyList()
|
||||
|
||||
repository.fetchSeriesItems(LIBRARY_ID, "ser", libraryType = LibraryType.LIBRARY)
|
||||
repository.fetchSeriesItems(LIBRARY_ID, "ser", libraryType = LibraryType.PODCAST)
|
||||
repository.fetchAuthorItems(LIBRARY_ID, "author", libraryType = LibraryType.LIBRARY)
|
||||
repository.fetchAuthorItems(LIBRARY_ID, "author", libraryType = LibraryType.PODCAST)
|
||||
|
||||
assertTrue(queries[0].sql.contains("isFinished"))
|
||||
assertFalse(queries[1].sql.contains("isFinished"))
|
||||
assertTrue(queries[2].sql.contains("isFinished"))
|
||||
assertFalse(queries[3].sql.contains("isFinished"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -372,9 +423,9 @@ class CachedBookRepositoryTest {
|
||||
@Test
|
||||
fun `countBooks delegates to the dao`() =
|
||||
runBlocking {
|
||||
coEvery { bookDao.countCachedBooks(libraryId = LIBRARY_ID) } returns 3
|
||||
coEvery { bookDao.countRaw(any()) } returns 3
|
||||
|
||||
assertEquals(3, repository.countBooks(LIBRARY_ID))
|
||||
assertEquals(3, repository.countBooks(LIBRARY_ID, libraryType = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -383,7 +434,7 @@ class CachedBookRepositoryTest {
|
||||
every { preferences.getHideCompleted() } returns true
|
||||
coEvery { bookDao.fetchCachedBooks(any()) } returns listOf(entity("b1"), entity("b2"))
|
||||
|
||||
val books = repository.fetchBooks(LIBRARY_ID, pageNumber = 0, pageSize = 20)
|
||||
val books = repository.fetchBooks(LIBRARY_ID, pageNumber = 0, pageSize = 20, libraryType = null)
|
||||
|
||||
assertEquals(listOf("b1", "b2"), books.map { it.id })
|
||||
}
|
||||
@@ -393,7 +444,7 @@ class CachedBookRepositoryTest {
|
||||
runBlocking {
|
||||
coEvery { bookDao.searchBooks(any()) } returns listOf(entity("b1"))
|
||||
|
||||
val books = repository.searchBooks(LIBRARY_ID, "query")
|
||||
val books = repository.searchBooks(LIBRARY_ID, "query", limit = 20)
|
||||
|
||||
assertEquals(listOf("b1"), books.map { it.id })
|
||||
}
|
||||
@@ -404,14 +455,15 @@ class CachedBookRepositoryTest {
|
||||
val book1 = entity("b1")
|
||||
val book2 = entity("b2")
|
||||
coEvery { bookDao.fetchRecentlyListenedCachedBooks(libraryId = LIBRARY_ID) } returns listOf(book1, book2)
|
||||
coEvery { bookDao.fetchMediaProgress("b1") } returns
|
||||
org.grakovne.lissen.content.cache.persistent.entity.MediaProgressEntity(
|
||||
bookId = "b1",
|
||||
currentTime = 10.0,
|
||||
isFinished = false,
|
||||
lastUpdate = 100L,
|
||||
coEvery { bookDao.fetchMediaProgress(listOf("b1", "b2")) } returns
|
||||
listOf(
|
||||
org.grakovne.lissen.content.cache.persistent.entity.MediaProgressEntity(
|
||||
bookId = "b1",
|
||||
currentTime = 10.0,
|
||||
isFinished = false,
|
||||
lastUpdate = 100L,
|
||||
),
|
||||
)
|
||||
coEvery { bookDao.fetchMediaProgress("b2") } returns null
|
||||
val recent1 = mockk<org.grakovne.lissen.domain.RecentBook>()
|
||||
val recent2 = mockk<org.grakovne.lissen.domain.RecentBook>()
|
||||
every { cachedBookEntityRecentConverter.apply(book1, 100L to 10.0) } returns recent1
|
||||
|
||||
27
app/src/test/kotlin/org/grakovne/lissen/content/cache/persistent/api/OrderingSqlTest.kt
vendored
Normal file
27
app/src/test/kotlin/org/grakovne/lissen/content/cache/persistent/api/OrderingSqlTest.kt
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
package org.grakovne.lissen.content.cache.persistent.api
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class OrderingSqlTest {
|
||||
@Test
|
||||
fun `maps every library ordering option to its own column`() {
|
||||
assertEquals("title", resolveOrderField("title"))
|
||||
assertEquals("author", resolveOrderField("author"))
|
||||
assertEquals("createdAt", resolveOrderField("createdAt"))
|
||||
assertEquals("updatedAt", resolveOrderField("updatedAt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to title for unknown fields`() {
|
||||
assertEquals("title", resolveOrderField("duration"))
|
||||
assertEquals("title", resolveOrderField("nonsense"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalizes direction and rejects garbage`() {
|
||||
assertEquals("ASC", resolveOrderDirection("asc"))
|
||||
assertEquals("DESC", resolveOrderDirection("desc"))
|
||||
assertEquals("ASC", resolveOrderDirection("sideways"))
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,12 @@ class DownloadOptionTest {
|
||||
@Test
|
||||
fun `unknown string produces null`() = assertNull("something_else".makeDownloadOption())
|
||||
|
||||
@Test
|
||||
fun `malformed number_items suffix produces null instead of crashing`() {
|
||||
assertNull("number_items_".makeDownloadOption())
|
||||
assertNull("number_items_abc".makeDownloadOption())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `singleton options roundtrip through makeId and makeDownloadOption`() {
|
||||
listOf(AllItemsDownloadOption, CurrentItemDownloadOption, RemainingItemsDownloadOption).forEach { option ->
|
||||
|
||||
@@ -114,21 +114,21 @@ class LissenDataSourceFactoryTest {
|
||||
@Test
|
||||
fun `unapply returns null for non-lissen URI`() {
|
||||
val uri = stubUri(scheme = "https", segments = listOf("api", "items"))
|
||||
val result = unapply(uri)
|
||||
val result = parseLissenUri(uri)
|
||||
assertEquals(null, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unapply returns null for file URI`() {
|
||||
val uri = stubUri(scheme = "file", segments = listOf("data", "media_cache", "book1", "file1"))
|
||||
val result = unapply(uri)
|
||||
val result = parseLissenUri(uri)
|
||||
assertEquals(null, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unapply extracts bookId and fileId from valid lissen URI`() {
|
||||
val uri = stubUri(scheme = "lissen", segments = listOf("book-abc", "file-xyz"))
|
||||
val result = unapply(uri)
|
||||
val result = parseLissenUri(uri)
|
||||
assertEquals("book-abc" to "file-xyz", result)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,55 +15,55 @@ class LissenMediaSchemeConverterTest {
|
||||
@Test
|
||||
fun `returns null for non-lissen scheme`() {
|
||||
val uri = stubUri(scheme = "https", segments = listOf("api", "items"))
|
||||
assertNull(unapply(uri))
|
||||
assertNull(parseLissenUri(uri))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null for null scheme`() {
|
||||
val uri = stubUri(scheme = null, segments = listOf("book1", "file1"))
|
||||
assertNull(unapply(uri))
|
||||
assertNull(parseLissenUri(uri))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null for lissen URI with single segment`() {
|
||||
val uri = stubUri(scheme = "lissen", segments = listOf("book1"))
|
||||
assertNull(unapply(uri))
|
||||
assertNull(parseLissenUri(uri))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null for lissen URI with three segments`() {
|
||||
val uri = stubUri(scheme = "lissen", segments = listOf("book1", "file1", "extra"))
|
||||
assertNull(unapply(uri))
|
||||
assertNull(parseLissenUri(uri))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null for lissen URI with empty segments`() {
|
||||
val uri = stubUri(scheme = "lissen", segments = listOf("", ""))
|
||||
assertNull(unapply(uri))
|
||||
assertNull(parseLissenUri(uri))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null for lissen URI with empty first segment`() {
|
||||
val uri = stubUri(scheme = "lissen", segments = listOf("", "file1"))
|
||||
assertNull(unapply(uri))
|
||||
assertNull(parseLissenUri(uri))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null for lissen URI with empty second segment`() {
|
||||
val uri = stubUri(scheme = "lissen", segments = listOf("book1", ""))
|
||||
assertNull(unapply(uri))
|
||||
assertNull(parseLissenUri(uri))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null for empty segment list`() {
|
||||
val uri = stubUri(scheme = "lissen", segments = emptyList())
|
||||
assertNull(unapply(uri))
|
||||
assertNull(parseLissenUri(uri))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses valid lissen URI`() {
|
||||
val uri = stubUri(scheme = "lissen", segments = listOf("book-123", "file-456"))
|
||||
val result = unapply(uri)
|
||||
val result = parseLissenUri(uri)
|
||||
assertNotNull(result)
|
||||
assertEquals("book-123" to "file-456", result)
|
||||
}
|
||||
@@ -71,7 +71,7 @@ class LissenMediaSchemeConverterTest {
|
||||
@Test
|
||||
fun `parses lissen URI with underscore ids`() {
|
||||
val uri = stubUri(scheme = "lissen", segments = listOf("li_abc123", "ino_xyz789"))
|
||||
val result = unapply(uri)
|
||||
val result = parseLissenUri(uri)
|
||||
assertNotNull(result)
|
||||
assertEquals("li_abc123", result!!.first)
|
||||
assertEquals("ino_xyz789", result.second)
|
||||
@@ -80,7 +80,7 @@ class LissenMediaSchemeConverterTest {
|
||||
@Test
|
||||
fun `parses lissen URI with numeric ids`() {
|
||||
val uri = stubUri(scheme = "lissen", segments = listOf("123456", "789012"))
|
||||
val result = unapply(uri)
|
||||
val result = parseLissenUri(uri)
|
||||
assertNotNull(result)
|
||||
assertEquals("123456", result!!.first)
|
||||
assertEquals("789012", result.second)
|
||||
|
||||
@@ -208,7 +208,7 @@ class LibraryViewModelTest {
|
||||
|
||||
viewModel.toggleGroup(series())
|
||||
|
||||
assertEquals(listOf("1", "2", "22"), viewModel.groupBooks.value["ser-1"]?.map { it.id })
|
||||
assertEquals(listOf("1", "2", "22"), viewModel.groupBooks["ser-1"]?.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -220,7 +220,7 @@ class LibraryViewModelTest {
|
||||
viewModel.toggleGroup(author())
|
||||
|
||||
assertTrue("aut-1" in viewModel.expandedGroups.value)
|
||||
assertEquals(listOf("b1"), viewModel.groupBooks.value["aut-1"]?.map { it.id })
|
||||
assertEquals(listOf("b1"), viewModel.groupBooks["aut-1"]?.map { it.id })
|
||||
coVerify(exactly = 1) { mediaChannel.fetchAuthorBooks("lib-1", "aut-1") }
|
||||
}
|
||||
|
||||
@@ -233,8 +233,8 @@ class LibraryViewModelTest {
|
||||
viewModel.toggleGroup(series())
|
||||
|
||||
assertTrue("ser-1" in viewModel.expandedGroups.value)
|
||||
assertEquals(listOf("b1", "b2"), viewModel.groupBooks.value["ser-1"]?.map { it.id })
|
||||
assertTrue(viewModel.groupLoading.value.isEmpty())
|
||||
assertEquals(listOf("b1", "b2"), viewModel.groupBooks["ser-1"]?.map { it.id })
|
||||
assertTrue(viewModel.groupLoading.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -280,7 +280,7 @@ class LibraryViewModelTest {
|
||||
|
||||
viewModel.prefetchGroup(series())
|
||||
|
||||
assertEquals(listOf("b1", "b2"), viewModel.groupBooks.value["ser-1"]?.map { it.id })
|
||||
assertEquals(listOf("b1", "b2"), viewModel.groupBooks["ser-1"]?.map { it.id })
|
||||
assertFalse("ser-1" in viewModel.expandedGroups.value)
|
||||
}
|
||||
|
||||
@@ -316,8 +316,8 @@ class LibraryViewModelTest {
|
||||
viewModel.resetGroupExpansion()
|
||||
|
||||
assertTrue(viewModel.expandedGroups.value.isEmpty())
|
||||
assertTrue(viewModel.groupBooks.value.isEmpty())
|
||||
assertTrue(viewModel.groupLoading.value.isEmpty())
|
||||
assertTrue(viewModel.groupBooks.isEmpty())
|
||||
assertTrue(viewModel.groupLoading.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -307,26 +307,26 @@ class SettingsViewModelTest {
|
||||
@Nested
|
||||
inner class SeekTimePreference {
|
||||
@Test
|
||||
fun `preferForwardRewind updates seek forward`() {
|
||||
viewModel.preferForwardRewind(60)
|
||||
fun `preferForward updates seek forward`() {
|
||||
viewModel.preferForward(60)
|
||||
assertEquals(60, viewModel.seekTime.value.forward)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preferRewindRewind updates seek rewind`() {
|
||||
viewModel.preferRewindRewind(30)
|
||||
fun `preferRewind updates seek rewind`() {
|
||||
viewModel.preferRewind(30)
|
||||
assertEquals(30, viewModel.seekTime.value.rewind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preferForwardRewind preserves rewind value`() {
|
||||
viewModel.preferForwardRewind(60)
|
||||
fun `preferForward preserves rewind value`() {
|
||||
viewModel.preferForward(60)
|
||||
assertEquals(SeekTime.Default.rewind, viewModel.seekTime.value.rewind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preferRewindRewind preserves forward value`() {
|
||||
viewModel.preferRewindRewind(10)
|
||||
fun `preferRewind preserves forward value`() {
|
||||
viewModel.preferRewind(10)
|
||||
assertEquals(SeekTime.Default.forward, viewModel.seekTime.value.forward)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user