feat(audiobooks): audiobook media model, scanner, library + parts player (D4)

- Model: Audiobook/AudiobookPart with a pure progress codec and speed ladder
  (0.75/1.0/1.25/1.5/2.0).
- Scanner: a second MediaStore pass detects books by genre signal or
  numbered-chapter sibling directories; grouping heuristics are pure and
  unit-tested; the music scan is unchanged.
- Room v7 (MIGRATION_6_7): audiobooks + audiobook_parts tables, DAO,
  AudiobookRepository with group-keyed rebuild that preserves resume and one
  bookmark per book.
- UI: 'audiobooks' as the sixth music crossbar pivot (canon §3.1 home menu
  untouched; GemLibraryAudiobookPartScene parity), book list, book detail with
  resume CTA, parts list, bookmark jump, compact player with speed steps.
- PlaybackController: PlaybackSource.AUDIOBOOK, playAudiobook(startPositionMs),
  setSpeed, speed/currentAudiobookId flows, reset on non-book queues; resume
  persisted from controller state.
- Canon §3.3/§10 and roadmap D4 updated. 30 new tests; suite 1,551/0.
This commit is contained in:
John Smith
2026-09-12 18:01:31 -04:00
parent 4aae2ea9dc
commit 35eb3aacc8
21 changed files with 1787 additions and 12 deletions
@@ -52,6 +52,7 @@ class DoradoGraph(
val cloudUpdates: com.heretek.dorado_hd.cloud.CloudUpdateService,
val playCounts: com.heretek.dorado_hd.analysis.PlayCountStore,
val appState: com.heretek.dorado_hd.data.repo.AppStateRepository,
val audiobooks: com.heretek.dorado_hd.data.repo.AudiobookRepository,
) {
/**
* True while a full-bleed presentation (picture viewer) owns the screen
@@ -142,11 +143,12 @@ open class DoradoApp : Application() {
val cloudUpdates = com.heretek.dorado_hd.cloud.CloudUpdateService(settings = { latestSettings.get() })
val equalizer = com.heretek.dorado_hd.media.EqualizerController()
val appState = com.heretek.dorado_hd.data.repo.AppStateRepository(db)
val audiobooks = com.heretek.dorado_hd.data.repo.AudiobookRepository(db)
graph = DoradoGraph(
library, quickplay, settings, settings.settings, controller, nav,
artistImages, artistBios, notes, calendar, alarms, radio, podcasts, games, deviceLink, analysis, mixes, scrobble, lyrics,
cloudSignIn, cloudSignInCallback, cloudUpdates, playCountStore, appState,
cloudSignIn, cloudSignInCallback, cloudUpdates, playCountStore, appState, audiobooks,
)
if (autoConnectPlayback) {
@@ -197,6 +199,28 @@ open class DoradoApp : Application() {
}
}
// D4: persist the audiobook resume (last part + position). Throttled to
// one write per 5 s, plus an immediate write on a part transition, so
// the position ticker doesn't hammer the database.
appScope.launch {
var lastWriteAt = 0L
var lastPart = -1
combine(
controller.currentAudiobookId,
controller.currentIndex,
controller.positionMs,
) { bookId, part, position -> Triple(bookId, part, position) }
.collect { (bookId, part, position) ->
if (bookId == null || part < 0) return@collect
val now = android.os.SystemClock.elapsedRealtime()
val partChanged = part != lastPart
if (!partChanged && now - lastWriteAt < 5_000L) return@collect
lastPart = part
lastWriteAt = now
runCatching { audiobooks.saveResume(bookId, part, position) }
}
}
// Opt-in MediaStore watcher (Settings > collection > watch media store).
// Debounced 2 s to coalesce bursts (e.g. mass-transfer).
var watcher: ContentObserver? = null
@@ -5,6 +5,7 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import com.heretek.dorado_hd.data.model.Album
import com.heretek.dorado_hd.data.model.Artist
import com.heretek.dorado_hd.data.model.Genre
@@ -391,3 +392,53 @@ interface PlayCountDao {
upsert(PlayCountEntity(mediaId = mediaId, count = next, lastPlayedAt = now))
}
}
/**
* Audiobook store (D4). Books are keyed by the scanner's stable `groupKey`
* (unique index) so `rebuild` updates metadata and parts while preserving the
* resume/bookmark columns.
*/
@Dao
interface AudiobookDao {
@Query("SELECT * FROM audiobooks ORDER BY title COLLATE NOCASE")
fun books(): Flow<List<AudiobookEntity>>
@Query("SELECT * FROM audiobooks WHERE id = :id")
fun book(id: Long): Flow<AudiobookEntity?>
@Query("SELECT * FROM audiobooks WHERE id = :id")
suspend fun bookNow(id: Long): AudiobookEntity?
@Query("SELECT * FROM audiobooks")
suspend fun allBooks(): List<AudiobookEntity>
@Query("SELECT * FROM audiobook_parts WHERE bookId = :bookId ORDER BY position")
fun parts(bookId: Long): Flow<List<AudiobookPartEntity>>
@Query("SELECT * FROM audiobook_parts WHERE bookId = :bookId ORDER BY position")
suspend fun partsNow(bookId: Long): List<AudiobookPartEntity>
@Insert
suspend fun insertBook(book: AudiobookEntity): Long
@Update
suspend fun updateBook(book: AudiobookEntity)
@Insert
suspend fun insertParts(parts: List<AudiobookPartEntity>)
@Query("DELETE FROM audiobook_parts WHERE bookId = :bookId")
suspend fun deleteParts(bookId: Long)
@Query("DELETE FROM audiobook_parts")
suspend fun deleteAllParts()
@Query("DELETE FROM audiobooks WHERE id IN (:ids)")
suspend fun deleteBooks(ids: List<Long>)
@Query("UPDATE audiobooks SET resume = :resume, updatedAt = :updatedAt WHERE id = :id")
suspend fun setResume(id: Long, resume: String, updatedAt: Long)
@Query("UPDATE audiobooks SET bookmark = :bookmark, updatedAt = :updatedAt WHERE id = :id")
suspend fun setBookmark(id: Long, bookmark: String, updatedAt: Long)
}
@@ -25,8 +25,10 @@ import androidx.room.RoomDatabase
ScrobbleEntity::class,
PlayCountEntity::class,
AppStateEntity::class,
AudiobookEntity::class,
AudiobookPartEntity::class,
],
version = 6,
version = 7,
exportSchema = false,
)
abstract class DoradoDatabase : RoomDatabase() {
@@ -46,6 +48,7 @@ abstract class DoradoDatabase : RoomDatabase() {
abstract fun scrobbleDao(): ScrobbleDao
abstract fun playCountDao(): PlayCountDao
abstract fun appStateDao(): AppStateDao
abstract fun audiobookDao(): AudiobookDao
companion object {
@Volatile
@@ -54,7 +57,7 @@ abstract class DoradoDatabase : RoomDatabase() {
fun get(context: Context): DoradoDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(context, DoradoDatabase::class.java, "dorado_hd.db")
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7)
.build()
.also { instance = it }
}
@@ -208,3 +208,37 @@ data class PlayCountEntity(
val count: Int,
val lastPlayedAt: Long,
)
/**
* Audiobook grouping (D4): one row per book. `groupKey` is the scanner's
* stable identity (`album:<id>` or `dir:<path>`) so reseeds preserve the
* resume/bookmark positions. Positions are [AudiobookProgress] codecs.
*/
@Entity(tableName = "audiobooks", indices = [Index(value = ["groupKey"], unique = true)])
data class AudiobookEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val groupKey: String,
val title: String,
val author: String,
val albumId: Long,
val partCount: Int,
val totalDurationMs: Long,
val resume: String,
val bookmark: String,
val updatedAt: Long,
)
/** One ordered, playable part belonging to an [AudiobookEntity]. */
@Entity(
tableName = "audiobook_parts",
indices = [Index("bookId"), Index(value = ["mediaId"], unique = true)],
)
data class AudiobookPartEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val bookId: Long,
val mediaId: Long,
val position: Int,
val title: String,
val durationMs: Long,
val uri: String,
)
@@ -170,3 +170,43 @@ val MIGRATION_5_6 = object : Migration(5, 6) {
)
}
}
/**
* Room v6 → v7: audiobooks (roadmap D4). `audiobooks` groups ordered parts
* (`audiobook_parts`) by the scanner's stable `groupKey`; the resume/bookmark
* positions persist there as `AudiobookProgress` codecs. Existing data
* untouched. The SQL mirrors Room's expected v7 schema exactly.
*/
val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""CREATE TABLE IF NOT EXISTS `audiobooks` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`groupKey` TEXT NOT NULL,
`title` TEXT NOT NULL,
`author` TEXT NOT NULL,
`albumId` INTEGER NOT NULL,
`partCount` INTEGER NOT NULL,
`totalDurationMs` INTEGER NOT NULL,
`resume` TEXT NOT NULL,
`bookmark` TEXT NOT NULL,
`updatedAt` INTEGER NOT NULL
)""",
)
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_audiobooks_groupKey` ON `audiobooks` (`groupKey`)")
db.execSQL(
"""CREATE TABLE IF NOT EXISTS `audiobook_parts` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`bookId` INTEGER NOT NULL,
`mediaId` INTEGER NOT NULL,
`position` INTEGER NOT NULL,
`title` TEXT NOT NULL,
`durationMs` INTEGER NOT NULL,
`uri` TEXT NOT NULL
)""",
)
db.execSQL("CREATE INDEX IF NOT EXISTS `index_audiobook_parts_bookId` ON `audiobook_parts` (`bookId`)")
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_audiobook_parts_mediaId` ON `audiobook_parts` (`mediaId`)")
}
}
@@ -0,0 +1,99 @@
package com.heretek.dorado_hd.data.model
import android.net.Uri
/**
* One ordered part (file) of an audiobook. Snapshots the metadata needed to
* play the part so the audiobook tables survive library rescans independent of
* the music `tracks` table.
*/
data class AudiobookPart(
val id: Long,
val bookId: Long,
val mediaId: Long,
val index: Int,
val title: String,
val durationMs: Long,
val uri: Uri,
) {
/** Maps the part onto the shared queue/transport model. */
fun toTrack(book: Audiobook): Track = Track(
mediaId = mediaId,
title = title,
artist = book.author,
artistId = 0,
album = book.title,
albumId = book.albumId,
genre = AUDIOBOOK_GENRE,
durationMs = durationMs,
dateAdded = 0,
trackNumber = index + 1,
year = "",
uri = uri,
)
companion object {
/** Genre stamped on audiobook queue items (see PlaybackSource.AUDIOBOOK). */
const val AUDIOBOOK_GENRE = "audiobook"
}
}
/** A book: ordered parts plus the persisted resume/bookmark positions. */
data class Audiobook(
val id: Long,
val title: String,
val author: String,
val albumId: Long,
val partCount: Int,
val totalDurationMs: Long,
val resume: AudiobookProgress.Position?,
val bookmark: AudiobookProgress.Position?,
) {
/** "not started", "part 3 of 12" — the book-list progress line. */
val progressLabel: String
get() = resume?.let { "part ${it.partIndex + 1} of $partCount" } ?: "not started"
}
/**
* Pure codec for the resume / bookmark positions persisted on `audiobooks`.
* A position is "part index + millisecond offset"; the stored form is
* `"<partIndex>:<positionMs>"` and [NONE] means "no saved position".
*/
object AudiobookProgress {
data class Position(val partIndex: Int, val positionMs: Long)
const val NONE = "-1:0"
fun encode(position: Position?): String {
if (position == null || position.partIndex < 0) return NONE
return "${position.partIndex}:${position.positionMs.coerceAtLeast(0L)}"
}
fun decode(raw: String?): Position? {
val text = raw?.trim().orEmpty()
if (text.isEmpty() || text == NONE) return null
val pieces = text.split(':', limit = 2)
if (pieces.size != 2) return null
val partIndex = pieces[0].toIntOrNull() ?: return null
val positionMs = pieces[1].toLongOrNull() ?: return null
if (partIndex < 0 || positionMs < 0L) return null
return Position(partIndex, positionMs)
}
/** Time left in the current part, never negative. */
fun remainingMs(positionMs: Long, durationMs: Long): Long =
(durationMs - positionMs).coerceAtLeast(0L)
/** "12:34" / "1:02:03" — the compact player's time-remaining label. */
fun format(ms: Long): String {
val totalSeconds = ms.coerceAtLeast(0L) / 1000
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
return if (hours > 0) {
"%d:%02d:%02d".format(hours, minutes, seconds)
} else {
"%d:%02d".format(minutes, seconds)
}
}
}
@@ -0,0 +1,137 @@
package com.heretek.dorado_hd.data.repo
import android.net.Uri
import androidx.room.withTransaction
import com.heretek.dorado_hd.data.db.AudiobookEntity
import com.heretek.dorado_hd.data.db.AudiobookPartEntity
import com.heretek.dorado_hd.data.db.DoradoDatabase
import com.heretek.dorado_hd.data.model.Audiobook
import com.heretek.dorado_hd.data.model.AudiobookPart
import com.heretek.dorado_hd.data.model.AudiobookProgress
import com.heretek.dorado_hd.data.scan.AudiobookGrouping
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Room-backed audiobook store (roadmap D4). The scanner calls [rebuild] after
* each library pass; books are matched by their stable `groupKey`, so a rescan
* refreshes titles/parts without losing resume or bookmark positions.
*/
class AudiobookRepository(private val db: DoradoDatabase) {
fun books(): Flow<List<Audiobook>> =
db.audiobookDao().books().map { rows -> rows.map { it.toModel() } }
fun book(id: Long): Flow<Audiobook?> =
db.audiobookDao().book(id).map { it?.toModel() }
fun parts(bookId: Long): Flow<List<AudiobookPart>> =
db.audiobookDao().parts(bookId).map { rows -> rows.map { it.toModel() } }
suspend fun bookNow(id: Long): Audiobook? = db.audiobookDao().bookNow(id)?.toModel()
suspend fun partsNow(bookId: Long): List<AudiobookPart> =
db.audiobookDao().partsNow(bookId).map { it.toModel() }
suspend fun saveResume(bookId: Long, partIndex: Int, positionMs: Long) {
db.audiobookDao().setResume(
bookId,
AudiobookProgress.encode(AudiobookProgress.Position(partIndex, positionMs)),
System.currentTimeMillis(),
)
}
suspend fun saveBookmark(bookId: Long, partIndex: Int, positionMs: Long) {
db.audiobookDao().setBookmark(
bookId,
AudiobookProgress.encode(AudiobookProgress.Position(partIndex, positionMs)),
System.currentTimeMillis(),
)
}
suspend fun clearBookmark(bookId: Long) {
db.audiobookDao().setBookmark(bookId, AudiobookProgress.NONE, System.currentTimeMillis())
}
/**
* Replaces the audiobook catalog with a fresh scan. Kept books retain their
* ids, resume and bookmark; vanished books are dropped with their parts.
*/
suspend fun rebuild(groups: List<AudiobookGrouping.Book>) = db.withTransaction {
val dao = db.audiobookDao()
val existing = dao.allBooks().associateBy { it.groupKey }
val keepKeys = groups.map { it.key }.toSet()
val staleIds = existing.values.filter { it.groupKey !in keepKeys }.map { it.id }
if (staleIds.isNotEmpty()) dao.deleteBooks(staleIds)
// Parts are rebuilt wholesale inside the transaction: a media file that
// moved between books (its old book still exists) would otherwise trip
// the unique `mediaId` index.
dao.deleteAllParts()
val now = System.currentTimeMillis()
for (group in groups) {
val prior = existing[group.key]
val bookId = if (prior == null) {
dao.insertBook(
AudiobookEntity(
groupKey = group.key,
title = group.title,
author = group.author,
albumId = group.albumId,
partCount = group.parts.size,
totalDurationMs = group.totalDurationMs,
resume = AudiobookProgress.NONE,
bookmark = AudiobookProgress.NONE,
updatedAt = now,
),
)
} else {
dao.updateBook(
prior.copy(
title = group.title,
author = group.author,
albumId = group.albumId,
partCount = group.parts.size,
totalDurationMs = group.totalDurationMs,
updatedAt = now,
),
)
prior.id
}
dao.insertParts(
group.parts.mapIndexed { index, file ->
AudiobookPartEntity(
bookId = bookId,
mediaId = file.mediaId,
position = index,
title = file.title,
durationMs = file.durationMs,
uri = file.uri,
)
},
)
}
}
private fun AudiobookEntity.toModel() = Audiobook(
id = id,
title = title,
author = author,
albumId = albumId,
partCount = partCount,
totalDurationMs = totalDurationMs,
resume = AudiobookProgress.decode(resume),
bookmark = AudiobookProgress.decode(bookmark),
)
private fun AudiobookPartEntity.toModel() = AudiobookPart(
id = id,
bookId = bookId,
mediaId = mediaId,
index = position,
title = title,
durationMs = durationMs,
uri = Uri.parse(uri),
)
}
@@ -0,0 +1,198 @@
package com.heretek.dorado_hd.data.scan
/**
* Pure audiobook grouping heuristics (roadmap D4). The scanner feeds in every
* long-enough audio file it can see; this object decides which of them form a
* book and in what order the parts play.
*
* Two independent signals are honored:
* 1. **Genre** — a MediaStore album whose genre contains `audiobook` or
* `spoken word` groups by `albumId` (explicit, so a single-file book is
* allowed).
* 2. **Directory** — sibling files in one folder whose names look like
* chapters, e.g. `Book Name/01 - Chapter.mp3`. The folder must hold at
* least two chapter-like files; the part order is the chapter number.
*
* Everything here is Android-free so the heuristics are unit-testable.
*/
object AudiobookGrouping {
/** The subset of a scanned audio file the heuristic needs. */
data class File(
val mediaId: Long,
val title: String,
val artist: String,
val album: String,
val albumId: Long,
val genre: String,
val durationMs: Long,
val dateAdded: Long,
val path: String,
val uri: String,
) {
/** File name without its directory or extension. */
val fileName: String
get() = path.replace('\\', '/').substringAfterLast('/').substringBeforeLast('.')
}
enum class Source { ALBUM_GENRE, DIRECTORY }
data class Book(
/** Stable identity across rescans (`album:<id>` or `dir:<path>`). */
val key: String,
val title: String,
val author: String,
val albumId: Long,
val source: Source,
val parts: List<File>,
) {
val totalDurationMs: Long get() = parts.sumOf { it.durationMs }
}
// A leading part number: "01 - Chapter", "1. Introduction", "002".
private val LEADING_NUMBER = Regex("""^0*(\d{1,3})(?!\d)""")
// "Chapter 4", "Part 12", "Book 2", "Chapter One".
private val NUMBER_WORD = Regex(
"""(?:chapter|part|book)\s*[-–—.:#]*\s*(\d{1,3}|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty)""",
RegexOption.IGNORE_CASE,
)
private val CHAPTER_WORD = Regex("""(?:^|[^a-z])(chapter|part|book)(?:[^a-z]|$)""", RegexOption.IGNORE_CASE)
private val BOOKISH_DIR = Regex(
"""(?:^|[^a-z])(audiobook|audio book|spoken word|unabridged|novel|book)(?:[^a-z]|$)""",
RegexOption.IGNORE_CASE,
)
private val UNKNOWN_ARTISTS = setOf(
"", "unknown artist", "unknown", "<unknown>", "various artists", "various artist",
)
private val WORD_NUMBERS = mapOf(
"one" to 1, "two" to 2, "three" to 3, "four" to 4, "five" to 5,
"six" to 6, "seven" to 7, "eight" to 8, "nine" to 9, "ten" to 10,
"eleven" to 11, "twelve" to 12, "thirteen" to 13, "fourteen" to 14,
"fifteen" to 15, "sixteen" to 16, "seventeen" to 17, "eighteen" to 18,
"nineteen" to 19, "twenty" to 20,
)
/** True when a genre tag flags spoken-word/audiobook content. */
fun isAudiobookGenre(genre: String): Boolean {
val g = genre.lowercase()
return "audiobook" in g || "audio book" in g || "spoken word" in g || "spoken-word" in g
}
/**
* Leading chapter/part number of a title or file name, or null when the
* name carries none. Used for ordering and for chapter-likeness.
*/
fun chapterNumber(text: String): Int? {
val trimmed = text.trim()
LEADING_NUMBER.find(trimmed)?.let { return it.groupValues[1].toIntOrNull() }
val word = NUMBER_WORD.find(trimmed) ?: return null
val token = word.groupValues[1].lowercase()
return token.toIntOrNull() ?: WORD_NUMBERS[token]
}
/** "01 - Chapter" / "Chapter One" / "part 3" — a book part, not a song. */
fun isChapterLike(text: String): Boolean =
chapterNumber(text) != null || CHAPTER_WORD.containsMatchIn(text)
/** Directory portion of a path (null for a bare file name / root file). */
fun parentDirectory(path: String): String? {
val normalized = path.replace('\\', '/')
val slash = normalized.lastIndexOf('/')
return if (slash > 0) normalized.substring(0, slash) else null
}
fun directoryName(path: String): String? = parentDirectory(path)?.substringAfterLast('/')
/**
* Groups [files] into books. Genre-flagged albums win over directory
* grouping; a file never lands in two books.
*/
fun group(files: List<File>): List<Book> {
val books = mutableListOf<Book>()
val claimed = mutableSetOf<Long>()
files.filter { isAudiobookGenre(it.genre) }
.groupBy { it.albumId }
.toSortedMap()
.forEach { (albumId, members) ->
val ordered = orderParts(members)
books += Book(
key = "album:$albumId",
title = titleOf(ordered),
author = authorOf(ordered),
albumId = albumId,
source = Source.ALBUM_GENRE,
parts = ordered,
)
claimed += members.map { it.mediaId }
}
// A directory that already contributed to a genre-flagged book must not
// spawn a second phantom book from its untagged siblings (track-level
// genre tags are often missing on some files of the same book).
val claimedDirectories = files
.filter { it.mediaId in claimed }
.mapNotNull { parentDirectory(it.path) }
.toSet()
files.asSequence()
.filter { it.mediaId !in claimed && parentDirectory(it.path) !in claimedDirectories }
.groupBy { parentDirectory(it.path) }
.toSortedMap(compareBy { it ?: "" })
.forEach { (directory, members0) ->
if (directory == null || members0.size < 2) return@forEach
val members = members0.sortedBy { it.path }
val chapterLike = members.count { isChapterLike(it.title) || isChapterLike(it.fileName) }
val bookishDirectory = BOOKISH_DIR.containsMatchIn(directoryName(members.first().path) ?: "")
val allChapters = chapterLike == members.size
if (chapterLike >= 2 && (allChapters || bookishDirectory)) {
val ordered = orderParts(members)
books += Book(
key = "dir:$directory",
title = titleOf(ordered),
author = authorOf(ordered),
albumId = ordered.first().albumId,
source = Source.DIRECTORY,
parts = ordered,
)
}
}
return books.sortedBy { it.title.lowercase() }
}
/** Chapter number first, then title — stable and deterministic. */
private fun orderParts(members: List<File>): List<File> =
members.sortedWith(
compareBy(
{ chapterNumber(it.title) ?: chapterNumber(it.fileName) ?: Int.MAX_VALUE },
{ it.title.lowercase() },
),
)
private fun titleOf(parts: List<File>): String {
val album = parts.first().album.trim()
if (album.isNotEmpty() && !album.equals("unknown album", ignoreCase = true)) return album
val directory = directoryName(parts.first().path)
if (!directory.isNullOrBlank()) {
val tail = directory.substringAfterLast(" - ", directory).trim()
return tail.ifEmpty { directory }
}
return parts.first().title
}
private fun authorOf(parts: List<File>): String {
val named = parts.map { it.artist.trim() }.filter { it.lowercase() !in UNKNOWN_ARTISTS }
val mostCommon = named.groupingBy { it }.eachCount().entries
.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key.lowercase() })
.firstOrNull()?.key
if (mostCommon != null && named.count { it == mostCommon } * 2 >= parts.size) return mostCommon
val directory = directoryName(parts.first().path)
if (directory != null && " - " in directory) {
val prefix = directory.substringBefore(" - ").trim()
if (prefix.isNotEmpty()) return prefix
}
return mostCommon ?: "unknown author"
}
}
@@ -103,10 +103,75 @@ class MediaLibraryScanner(
}
db.trackDao().upsertAll(found)
ScanResult(found.size, removed)
// Audiobooks (D4) ride a second, wider pass: the music projection above
// stays byte-for-byte compatible, while this one also carries the file
// path (for directory grouping) and includes non-`IS_MUSIC` spoken-word
// files. The pure heuristic decides what actually becomes a book.
val audiobookFiles = audiobookCandidates()
val books = AudiobookGrouping.group(audiobookFiles)
com.heretek.dorado_hd.data.repo.AudiobookRepository(db).rebuild(books)
ScanResult(found.size, removed, books.size)
}
data class ScanResult(val scanned: Int, val removed: Int)
/** Long-enough audio files with the metadata the grouping heuristic needs. */
private fun audiobookCandidates(): List<AudiobookGrouping.File> {
val sdk = android.os.Build.VERSION.SDK_INT
val projection = mutableListOf(
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.ARTIST,
MediaStore.Audio.Media.ALBUM,
MediaStore.Audio.Media.ALBUM_ID,
MediaStore.Audio.Media.DURATION,
MediaStore.Audio.Media.DATE_ADDED,
MediaStore.Audio.Media.DATA,
)
if (sdk >= 30) projection += MediaStore.Audio.Media.GENRE
val out = mutableListOf<AudiobookGrouping.File>()
context.contentResolver.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
projection.toTypedArray(),
"${MediaStore.Audio.Media.DURATION} >= 30000",
null,
null,
)?.use { c ->
val iId = c.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
val iTitle = c.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)
val iArtist = c.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)
val iAlbum = c.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM)
val iAlbumId = c.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID)
val iGenre = if (sdk >= 30) c.getColumnIndex(MediaStore.Audio.Media.GENRE) else -1
val iDur = c.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
val iDate = c.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_ADDED)
val iData = c.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA)
while (c.moveToNext()) {
val mediaId = c.getLong(iId)
out += AudiobookGrouping.File(
mediaId = mediaId,
title = c.getString(iTitle)?.trim().takeUnless { it.isNullOrEmpty() } ?: "unknown title",
artist = c.getString(iArtist)?.trim().takeUnless { it.isNullOrEmpty() } ?: "unknown artist",
album = c.getString(iAlbum)?.trim().takeUnless { it.isNullOrEmpty() } ?: "unknown album",
albumId = c.getLong(iAlbumId),
genre = if (iGenre >= 0) {
c.getString(iGenre)?.trim().takeUnless { it.isNullOrEmpty() } ?: "unknown"
} else {
"unknown"
},
durationMs = c.getLong(iDur),
dateAdded = c.getLong(iDate),
path = c.getString(iData)?.trim().orEmpty(),
uri = ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, mediaId).toString(),
)
}
}
return out
}
data class ScanResult(val scanned: Int, val removed: Int, val books: Int = 0)
}
fun TrackEntity.toModel(): com.heretek.dorado_hd.data.model.Track =
@@ -0,0 +1,28 @@
package com.heretek.dorado_hd.media
import kotlin.math.abs
/**
* Audiobook playback-speed steps (roadmap D4). Kept Android-free so the
* selector mapping and labels are unit-testable; [PlaybackController.setSpeed]
* feeds the chosen value straight into Media3's `player.setPlaybackSpeed`.
*/
object AudiobookSpeeds {
val STEPS: List<Float> = listOf(0.75f, 1.0f, 1.25f, 1.5f, 2.0f)
/** Closest offered step to [speed] (ties resolve to the lower step). */
fun nearest(speed: Float): Float =
STEPS.minByOrNull { abs(it - speed) } ?: 1.0f
/** True when [speed] is exactly one of the offered steps. */
fun isStep(speed: Float): Boolean = STEPS.any { abs(it - speed) < 0.0001f }
/** "0.75×", "1×" — the selector's label. */
fun label(speed: Float): String {
val value = nearest(speed)
return if (value == value.toInt().toFloat()) "${value.toInt()}×" else "$value×"
}
/** Keeps arbitrary input in a sane range while preserving the step value. */
fun clamp(speed: Float): Float = speed.coerceIn(0.25f, 3.0f)
}
@@ -30,9 +30,11 @@ import kotlinx.coroutines.launch
* Which presentation the current queue belongs to. `Radio.kt` marks its
* streams [RADIO] when it starts them; Now Playing uses that explicit signal
* to show the station identity and a live tag instead of the scrubber
* (device `GemNowPlayingRadioScene`). Every other surface stays [LIBRARY].
* (device `GemNowPlayingRadioScene`). [AUDIOBOOK] marks a book's part queue so
* the audiobook speed selector stays in effect across parts and is reset when
* any other queue starts.
*/
enum class PlaybackSource { LIBRARY, RADIO }
enum class PlaybackSource { LIBRARY, RADIO, AUDIOBOOK }
/**
* App-side playback coordinator. Bridges the Compose UI to the Media3 session
@@ -86,6 +88,17 @@ class PlaybackController(
private val _source = MutableStateFlow(PlaybackSource.LIBRARY)
val source: StateFlow<PlaybackSource> = _source.asStateFlow()
/** Book currently being read, or null when the queue is not an audiobook. */
private val _audiobookId = MutableStateFlow<Long?>(null)
val currentAudiobookId: StateFlow<Long?> = _audiobookId.asStateFlow()
/** Audiobook playback speed (1.0 unless the user picked a step). */
private val _speed = MutableStateFlow(1.0f)
val speed: StateFlow<Float> = _speed.asStateFlow()
/** Media ids of the parts of the audiobook queue, for the reset guard. */
private var audiobookPartIds: Set<Long> = emptySet()
/** Sleep-timer countdown in ms; 0 when no timer is armed. */
private val _sleepRemainingMs = MutableStateFlow(0L)
val sleepRemainingMs: StateFlow<Long> = _sleepRemainingMs.asStateFlow()
@@ -100,6 +113,7 @@ class PlaybackController(
val token = SessionToken(context, ComponentName(context, DoradoPlaybackService::class.java))
controller = MediaController.Builder(context, token).buildAsync().await()
controller?.addListener(listener)
controller?.setPlaybackSpeed(_speed.value)
positionTicker()
}
@@ -127,6 +141,12 @@ class PlaybackController(
_nowPlaying.value = track
_durationMs.value = controller?.duration ?: 0L
candidateReachedThreshold = false
// Parts of the audiobook keep the chosen reading speed; anything
// else (a hand-off to a music track, an external queue) resets it.
if (track == null || track.mediaId !in audiobookPartIds) {
if (_audiobookId.value != null) _audiobookId.value = null
if (_speed.value != 1.0f) setSpeed(1.0f)
}
if (previous != null && previousReached && previous.mediaId != track?.mediaId) {
val finished = previous
scope.launch {
@@ -290,6 +310,11 @@ class PlaybackController(
fun play(tracks: List<Track>, startIndex: Int = 0, source: PlaybackSource = PlaybackSource.LIBRARY) {
if (tracks.isEmpty()) return
if (source != PlaybackSource.AUDIOBOOK) {
audiobookPartIds = emptySet()
_audiobookId.value = null
if (_speed.value != 1.0f) setSpeed(1.0f)
}
baseOrder = tracks
_queue.value = tracks
_source.value = source
@@ -303,6 +328,34 @@ class PlaybackController(
if (FadeRamp.effectiveMs(crossfadeMs()) > 0L) fadeTo(1f, FadeRamp.effectiveMs(crossfadeMs()))
}
/**
* Starts a book's part queue (D4). [startPositionMs] seeds playback from
* the persisted resume; the source marker keeps the speed selector in
* effect for the remaining parts.
*/
fun playAudiobook(
bookId: Long,
tracks: List<Track>,
startIndex: Int = 0,
startPositionMs: Long = 0L,
) {
if (tracks.isEmpty()) return
audiobookPartIds = tracks.map { it.mediaId }.toSet()
_audiobookId.value = bookId
play(tracks, startIndex.coerceIn(0, tracks.lastIndex), PlaybackSource.AUDIOBOOK)
if (startPositionMs > 0L) seekTo(startPositionMs)
}
/**
* Applies an audiobook reading speed (Media3 `player.setPlaybackSpeed`).
* Non-audiobook queues reset this to 1.0 when they start.
*/
fun setSpeed(speed: Float) {
val value = AudiobookSpeeds.clamp(speed)
_speed.value = value
controller?.setPlaybackSpeed(value)
}
fun enqueue(track: Track) {
val player = controller ?: return
player.addMediaItem(track.toMediaItem())
@@ -0,0 +1,309 @@
package com.heretek.dorado_hd.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.heretek.dorado_hd.data.model.AudiobookPart
import com.heretek.dorado_hd.data.model.AudiobookProgress
import com.heretek.dorado_hd.design.DoradoTokens
import com.heretek.dorado_hd.design.LocalDoradoColors
import com.heretek.dorado_hd.design.components.EdgeCropText
import com.heretek.dorado_hd.design.components.KineticList
import com.heretek.dorado_hd.design.components.firstLetterOf
import com.heretek.dorado_hd.media.AudiobookSpeeds
import com.heretek.dorado_hd.media.PlaybackSource
import com.heretek.dorado_hd.ui.LocalDoradoGraph
import com.heretek.dorado_hd.ui.components.DetailScaffold
import kotlinx.coroutines.launch
/**
* The music crossbar's `audiobooks` pivot (post-device extension; the device
* shipped `GemLibraryAudiobookPartScene`, parity audit §2). Books identified by
* the scanner are listed with author and resume progress.
*/
@Composable
fun AudiobooksTab(onOpenBook: (Long) -> Unit) {
val graph = LocalDoradoGraph.current
val books by graph.audiobooks.books().collectAsState(initial = emptyList())
if (books.isEmpty()) {
EdgeCropText(
text = "no audiobooks — import books in settings",
fontSize = DoradoTokens.TYPE_LIST.dp,
alpha = 0.5f,
modifier = Modifier.padding(horizontal = DoradoTokens.EDGE.dp, vertical = 12.dp),
)
return
}
KineticList(
items = books,
key = { it.id },
letter = { firstLetterOf(it.title) },
bottomPadding = 44.dp,
rowContent = { book, _ ->
Row(
Modifier
.fillMaxWidth()
.height(DoradoTokens.ROW_HEIGHT.dp)
.clickable { onOpenBook(book.id) }
.padding(horizontal = DoradoTokens.EDGE.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
EdgeCropText(text = book.title, fontSize = DoradoTokens.TYPE_LIST.dp)
EdgeCropText(
text = "${book.author} · ${book.progressLabel}",
fontSize = DoradoTokens.TYPE_LIST_SECONDARY.dp,
color = LocalDoradoColors.current.textSecondary,
)
}
}
},
)
}
/**
* Book detail: the resume CTA, the ordered parts list with played ticks, and
* the compact audiobook player (current part + time left, bookmark, speed).
*/
@Composable
fun AudiobookBookScreen(bookId: Long, onBack: () -> Unit) {
val graph = LocalDoradoGraph.current
val colors = LocalDoradoColors.current
val scope = rememberCoroutineScope()
val book by graph.audiobooks.book(bookId).collectAsState(initial = null)
val parts by graph.audiobooks.parts(bookId).collectAsState(initial = emptyList())
val nowPlaying by graph.controller.nowPlaying.collectAsState()
val positionMs by graph.controller.positionMs.collectAsState()
val durationMs by graph.controller.durationMs.collectAsState()
val currentIndex by graph.controller.currentIndex.collectAsState()
val playingBookId by graph.controller.currentAudiobookId.collectAsState()
val source by graph.controller.source.collectAsState()
val speed by graph.controller.speed.collectAsState()
val current = book
if (current == null) {
// First frame (flow not emitted yet) or the book vanished in a rescan:
// keep the cropped header/back contract rather than a blank screen.
DetailScaffold(title = "audiobooks", onBack = onBack) {}
return
}
val audiobookPlaying = source == PlaybackSource.AUDIOBOOK
val thisBookPlaying = audiobookPlaying && playingBookId == current.id
fun start(index: Int, position: Long) {
if (parts.isEmpty()) return
val tracks = parts.map { it.toTrack(current) }
graph.controller.playAudiobook(
bookId = current.id,
tracks = tracks,
startIndex = index.coerceIn(0, tracks.lastIndex),
startPositionMs = position.coerceAtLeast(0L),
)
}
DetailScaffold(title = current.title, onBack = onBack) {
Column(Modifier.fillMaxSize()) {
EdgeCropText(
text = "${current.author} · ${current.partCount} parts · ${AudiobookProgress.format(current.totalDurationMs)}",
fontSize = DoradoTokens.TYPE_LIST_SECONDARY.dp,
color = colors.textSecondary,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = DoradoTokens.EDGE.dp, vertical = 4.dp),
)
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = DoradoTokens.EDGE.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val resume = current.resume
if (resume != null && resume.partIndex < parts.size) {
CtaText(
text = "resume part ${resume.partIndex + 1}",
onClick = { start(resume.partIndex, resume.positionMs) },
)
Spacer(Modifier.width(DoradoTokens.EDGE.dp))
}
CtaText(text = "play", onClick = { start(0, 0L) })
val bookmark = current.bookmark
if (bookmark != null && bookmark.partIndex < parts.size) {
Spacer(Modifier.width(DoradoTokens.EDGE.dp))
CtaText(
text = "bookmark · part ${bookmark.partIndex + 1} · ${AudiobookProgress.format(bookmark.positionMs)}",
onClick = { start(bookmark.partIndex, bookmark.positionMs) },
muted = true,
)
}
}
Box(Modifier.weight(1f)) {
KineticList(
items = parts,
key = { it.id },
letter = { null },
bottomPadding = 44.dp,
showAlphabet = false,
rowContent = { part, _ ->
PartRow(
part = part,
playing = thisBookPlaying && nowPlaying?.mediaId == part.mediaId,
played = current.resume?.let { part.index < it.partIndex } == true,
onClick = {
val resume = current.resume
if (thisBookPlaying && nowPlaying?.mediaId == part.mediaId) {
graph.controller.toggle()
} else if (resume != null && resume.partIndex == part.index) {
start(part.index, resume.positionMs)
} else {
start(part.index, 0L)
}
},
)
},
)
}
CompactPlayer(
playing = audiobookPlaying,
thisBook = thisBookPlaying,
partIndex = currentIndex,
partCount = current.partCount,
remainingMs = AudiobookProgress.remainingMs(positionMs, durationMs),
speed = speed,
onBookmark = {
val part = currentIndex
if (thisBookPlaying && part >= 0) {
scope.launch { graph.audiobooks.saveBookmark(current.id, part, positionMs) }
}
},
onSpeed = { graph.controller.setSpeed(it) },
)
// Clear the shell's MiniPlayer, which is drawn over the canvas bottom.
Spacer(Modifier.height(DoradoTokens.MINI_PLAYER_HEIGHT.dp))
}
}
}
@Composable
private fun PartRow(
part: AudiobookPart,
playing: Boolean,
played: Boolean,
onClick: () -> Unit,
) {
val colors = LocalDoradoColors.current
Row(
Modifier
.fillMaxWidth()
.height(DoradoTokens.ROW_HEIGHT.dp)
.clickable(onClick = onClick)
.padding(horizontal = DoradoTokens.EDGE.dp),
verticalAlignment = Alignment.CenterVertically,
) {
EdgeCropText(
text = "%02d".format(part.index + 1),
fontSize = DoradoTokens.TYPE_LIST_SECONDARY.dp,
color = colors.textSecondary,
modifier = Modifier.width(28.dp),
)
Column(Modifier.weight(1f)) {
EdgeCropText(
text = part.title,
fontSize = DoradoTokens.TYPE_LIST.dp,
color = if (playing) colors.accent else colors.textPrimary,
fontWeight = if (playing) FontWeight.SemiBold else FontWeight.Light,
)
EdgeCropText(
text = AudiobookProgress.format(part.durationMs),
fontSize = DoradoTokens.TYPE_CAPTION.dp,
color = colors.textSecondary,
)
}
if (playing) {
EdgeCropText(text = "playing", fontSize = DoradoTokens.TYPE_CAPTION.dp, color = colors.accent)
} else if (played) {
EdgeCropText(text = "played", fontSize = DoradoTokens.TYPE_CAPTION.dp, color = colors.textSecondary)
}
}
}
/**
* Compact player affordance: what part is being read, time remaining, a
* bookmark action and the reading-speed steps.
*/
@Composable
private fun CompactPlayer(
playing: Boolean,
thisBook: Boolean,
partIndex: Int,
partCount: Int,
remainingMs: Long,
speed: Float,
onBookmark: () -> Unit,
onSpeed: (Float) -> Unit,
) {
val colors = LocalDoradoColors.current
Row(
Modifier
.fillMaxWidth()
.height(36.dp)
.padding(horizontal = DoradoTokens.EDGE.dp),
verticalAlignment = Alignment.CenterVertically,
) {
EdgeCropText(
text = if (playing) {
"part ${(partIndex + 1).coerceIn(1, partCount.coerceAtLeast(1))} · ${AudiobookProgress.format(remainingMs)} left"
} else {
"no book playing"
},
fontSize = DoradoTokens.TYPE_LIST_SECONDARY.dp,
color = colors.textSecondary,
modifier = Modifier.weight(1f),
)
if (playing && thisBook) {
CtaText(text = "bookmark", onClick = onBookmark)
Spacer(Modifier.width(DoradoTokens.EDGE.dp))
}
AudiobookSpeeds.STEPS.forEach { step ->
EdgeCropText(
text = AudiobookSpeeds.label(step),
fontSize = DoradoTokens.TYPE_LIST_SECONDARY.dp,
color = if (AudiobookSpeeds.isStep(speed) && speed == step) colors.accent else colors.textSecondary,
fontWeight = if (speed == step) FontWeight.SemiBold else FontWeight.Light,
modifier = Modifier
.clickable { onSpeed(step) }
.padding(horizontal = 4.dp),
)
}
}
}
@Composable
private fun CtaText(text: String, onClick: () -> Unit, muted: Boolean = false) {
val colors = LocalDoradoColors.current
EdgeCropText(
text = text,
fontSize = DoradoTokens.TYPE_LIST.dp,
color = if (muted) colors.textSecondary else colors.accent,
modifier = Modifier.clickable(onClick = onClick),
)
}
@@ -57,8 +57,13 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/** Real Zune HD music crossbar order. */
private val PIVOTS = listOf("albums", "artists", "playlists", "songs", "genres")
/**
* Real Zune HD music crossbar order (`albums · artists · playlists · songs ·
* genres`), plus Dorado-HD's post-device `audiobooks` pivot (canon §10): the
* device shipped an audiobook library scene, and a pivot keeps the nine-entry
* home menu untouched (canon §3.1).
*/
private val PIVOTS = listOf("albums", "artists", "playlists", "songs", "genres", "audiobooks")
@Composable
fun MusicScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
@@ -73,6 +78,15 @@ fun MusicScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
var djBusy by remember { mutableStateOf(false) }
var query by remember { mutableStateOf("") }
var results by remember { mutableStateOf<List<Track>>(emptyList()) }
// Audiobook detail is an in-pivot surface (the Music route owns it), so the
// crossbar stays put and the cropped header performs back.
var openBookId by remember { mutableStateOf<Long?>(null) }
val openBook = openBookId
if (openBook != null) {
AudiobookBookScreen(bookId = openBook, onBack = { openBookId = null })
return
}
// Debounced indexed search (device zcontent_serv content service).
LaunchedEffect(query) {
@@ -230,7 +244,8 @@ fun MusicScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
1 -> ArtistsTab()
2 -> PlaylistsTab()
3 -> SongsTab()
else -> GenresTab()
4 -> GenresTab()
else -> AudiobooksTab(onOpenBook = { openBookId = it })
}
}
}
@@ -0,0 +1,174 @@
package com.heretek.dorado_hd
import com.heretek.dorado_hd.data.scan.AudiobookGrouping
import com.heretek.dorado_hd.data.scan.AudiobookGrouping.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pure audiobook grouping heuristics (roadmap D4): genre-flagged albums,
* directory chapter detection, part ordering and title/author resolution.
*/
class AudiobookGroupingTest {
private fun file(
id: Long,
title: String,
path: String,
artist: String = "Jane Author",
album: String = "Book Name",
albumId: Long = 7L,
genre: String = "unknown",
durationMs: Long = 600_000L,
) = File(
mediaId = id,
title = title,
artist = artist,
album = album,
albumId = albumId,
genre = genre,
durationMs = durationMs,
dateAdded = 0L,
path = path,
uri = "content://media/external/audio/media/$id",
)
@Test
fun `genre flagged albums group by album id`() {
val files = listOf(
file(1, "Opening", "/Audio/story/a.mp3", genre = "Audiobook", albumId = 9),
file(2, "The End", "/Audio/story/b.mp3", genre = "Audiobook", albumId = 9),
file(3, "Single Track", "/Audio/song.mp3", genre = "Rock", albumId = 10),
)
val books = AudiobookGrouping.group(files)
assertEquals(1, books.size)
assertEquals("album:9", books.single().key)
assertEquals(AudiobookGrouping.Source.ALBUM_GENRE, books.single().source)
assertEquals(2, books.single().parts.size)
}
@Test
fun `spoken word genre is recognized`() {
val files = listOf(
file(1, "one", "/Audio/b/1.mp3", genre = "Spoken Word", albumId = 3),
file(2, "two", "/Audio/b/2.mp3", genre = "spoken-word", albumId = 3),
file(3, "three", "/Audio/b/3.mp3", genre = "AUDIOBOOKS", albumId = 3),
)
assertEquals(1, AudiobookGrouping.group(files).size)
}
@Test
fun `directory of numbered chapters groups and orders numerically`() {
val files = listOf(
file(10, "10 - The End", "/Books/Book Name/10 - The End.mp3"),
file(2, "02 - Chapter Two", "/Books/Book Name/02 - Chapter Two.mp3"),
file(1, "01 - Chapter One", "/Books/Book Name/01 - Chapter One.mp3"),
)
val book = AudiobookGrouping.group(files).single()
assertEquals("dir:/Books/Book Name", book.key)
assertEquals(AudiobookGrouping.Source.DIRECTORY, book.source)
assertEquals(
listOf("01 - Chapter One", "02 - Chapter Two", "10 - The End"),
book.parts.map { it.title },
)
assertEquals(1_800_000L, book.totalDurationMs)
}
@Test
fun `a lone chapter file does not become a book`() {
val books = AudiobookGrouping.group(
listOf(file(1, "01 - Chapter One", "/Books/Lonely/01 - Chapter One.mp3")),
)
assertTrue(books.isEmpty())
}
@Test
fun `unnumbered files outside a bookish directory stay music`() {
val files = listOf(
file(1, "intro", "/Music/Some Album/intro.mp3"),
file(2, "outro", "/Music/Some Album/outro.mp3"),
)
assertTrue(AudiobookGrouping.group(files).isEmpty())
}
@Test
fun `bookish directory groups word numbered parts`() {
val files = listOf(
file(1, "Part One", "/Books/My Novel/Part One.mp3", album = "unknown album"),
file(2, "Part Two", "/Books/My Novel/Part Two.mp3", album = "unknown album"),
)
val book = AudiobookGrouping.group(files).single()
assertEquals("My Novel", book.title)
assertEquals(listOf("Part One", "Part Two"), book.parts.map { it.title })
}
@Test
fun `genre grouping wins over directory grouping`() {
val files = listOf(
file(1, "Chapter One", "/Books/Mixed/01.mp3", genre = "audiobook", albumId = 42),
file(2, "Chapter Two", "/Books/Mixed/02.mp3"),
file(3, "Chapter Three", "/Books/Mixed/03.mp3"),
)
val books = AudiobookGrouping.group(files)
assertEquals(1, books.size)
assertEquals("album:42", books.single().key)
}
@Test
fun `chapter number understands words and rejects years`() {
assertEquals(3, AudiobookGrouping.chapterNumber("Chapter Three"))
assertEquals(12, AudiobookGrouping.chapterNumber("part 12 - the dig"))
assertEquals(2, AudiobookGrouping.chapterNumber("02 - Chapter Two"))
assertNull(AudiobookGrouping.chapterNumber("2001 - A Space Odyssey"))
assertNull(AudiobookGrouping.chapterNumber("Untitled"))
}
@Test
fun `author falls back to the directory prefix`() {
val files = listOf(
file(1, "Chapter One", "/Books/Robert Louis Stevenson - Treasure Island/01.mp3", artist = "unknown artist", album = "unknown album"),
file(2, "Chapter Two", "/Books/Robert Louis Stevenson - Treasure Island/02.mp3", artist = "<unknown>", album = "unknown album"),
)
val book = AudiobookGrouping.group(files).single()
assertEquals("Robert Louis Stevenson", book.author)
assertEquals("Treasure Island", book.title)
}
@Test
fun `title falls back to the directory name when album is unknown`() {
val files = listOf(
file(1, "Chapter One", "/Books/Sherlock Holmes/01.mp3", album = "unknown album"),
file(2, "Chapter Two", "/Books/Sherlock Holmes/02.mp3", album = "unknown album"),
)
assertEquals("Sherlock Holmes", AudiobookGrouping.group(files).single().title)
}
@Test
fun `books sort by title`() {
val files = listOf(
file(1, "Chapter One", "/Books/Zebra/01.mp3", album = "Zebra Book"),
file(2, "Chapter Two", "/Books/Zebra/02.mp3", album = "Zebra Book"),
file(3, "Chapter One", "/Books/Apple/01.mp3", album = "Apple Book"),
file(4, "Chapter Two", "/Books/Apple/02.mp3", album = "Apple Book"),
)
assertEquals(
listOf("Apple Book", "Zebra Book"),
AudiobookGrouping.group(files).map { it.title },
)
}
}
@@ -0,0 +1,143 @@
package com.heretek.dorado_hd
import android.content.Context
import androidx.room.Room
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.heretek.dorado_hd.data.db.AudiobookEntity
import com.heretek.dorado_hd.data.db.DoradoDatabase
import com.heretek.dorado_hd.data.db.MIGRATION_6_7
import com.heretek.dorado_hd.data.model.AudiobookProgress
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import java.io.File
import java.lang.reflect.Proxy
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
/**
* Guards the v6→v7 audiobook migration (roadmap D4):
* 1. the SQL emitted by [MIGRATION_6_7] is byte-for-byte (modulo whitespace)
* what Room generated for the two new entities, and
* 2. a real v6 database upgrades and serves audiobook queries.
*/
@RunWith(RobolectricTestRunner::class)
class AudiobookMigrationTest {
@Test
fun `migration mirrors the generated room schema`() {
val generated = Regex("""execSQL\("((?:[^"\\]|\\.)*)"\)""")
.findAll(generatedImpl().readText())
.map { it.groupValues[1] }
.filter { it.startsWith("CREATE") }
.filter { "`audiobooks`" in it || "`audiobook_parts`" in it }
.map(::normalize)
.toSet()
assertEquals("expected five generated statements", 5, generated.size)
val recorded = mutableListOf<String>()
val recorder = Proxy.newProxyInstance(
SupportSQLiteDatabase::class.java.classLoader,
arrayOf(SupportSQLiteDatabase::class.java),
) { _, method, args ->
if (method.name == "execSQL" && args?.size == 1 && args[0] is String) {
recorded += args[0] as String
}
null
} as SupportSQLiteDatabase
MIGRATION_6_7.migrate(recorder)
assertEquals(generated, recorded.map(::normalize).toSet())
}
@Test
fun `a v6 database upgrades and serves audiobook queries`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val name = "audiobook-migration-test.db"
context.deleteDatabase(name)
context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null).use { raw ->
V6_SCHEMA.forEach { raw.execSQL(it) }
raw.version = 6
}
val db = Room.databaseBuilder(context, DoradoDatabase::class.java, name)
.addMigrations(MIGRATION_6_7)
.allowMainThreadQueries()
.build()
try {
runBlocking {
val id = db.audiobookDao().insertBook(
AudiobookEntity(
groupKey = "dir:/Books/Alpha",
title = "Alpha",
author = "Jane Author",
albumId = 7L,
partCount = 1,
totalDurationMs = 60_000L,
resume = AudiobookProgress.NONE,
bookmark = AudiobookProgress.NONE,
updatedAt = 1L,
),
)
assertNotNull(id)
assertEquals(1, db.audiobookDao().books().first().size)
}
} finally {
db.close()
context.deleteDatabase(name)
}
}
private fun generatedImpl(): File {
val candidates = listOf(
File("build/generated/ksp/debug/kotlin/com/heretek/dorado_hd/data/db/DoradoDatabase_Impl.kt"),
File("app/build/generated/ksp/debug/kotlin/com/heretek/dorado_hd/data/db/DoradoDatabase_Impl.kt"),
)
return candidates.firstOrNull { it.isFile }
?: error("generated DoradoDatabase_Impl.kt not found; run compileDebugKotlin first")
}
private fun normalize(sql: String): String =
sql.replace(Regex("\\s+"), " ").replace("( ", "(").replace(" )", ")").trim()
private companion object {
/**
* The full v6 schema, copied from the v6 Room-generated `createAllTables`
* (the v7 migration only adds the two audiobook tables).
*/
val V6_SCHEMA = listOf(
"CREATE TABLE IF NOT EXISTS `tracks` (`mediaId` INTEGER NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `artistId` INTEGER NOT NULL, `album` TEXT NOT NULL, `albumId` INTEGER NOT NULL, `genre` TEXT NOT NULL, `durationMs` INTEGER NOT NULL, `dateAdded` INTEGER NOT NULL, `trackNumber` INTEGER NOT NULL, `year` TEXT NOT NULL, `uri` TEXT NOT NULL, PRIMARY KEY(`mediaId`))",
"CREATE INDEX IF NOT EXISTS `index_tracks_albumId` ON `tracks` (`albumId`)",
"CREATE INDEX IF NOT EXISTS `index_tracks_artistId` ON `tracks` (`artistId`)",
"CREATE INDEX IF NOT EXISTS `index_tracks_title` ON `tracks` (`title`)",
"CREATE TABLE IF NOT EXISTS `playlists` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `dateCreated` INTEGER NOT NULL)",
"CREATE TABLE IF NOT EXISTS `playlist_tracks` (`playlistId` INTEGER NOT NULL, `mediaId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`playlistId`, `mediaId`))",
"CREATE TABLE IF NOT EXISTS `ratings` (`mediaId` INTEGER NOT NULL, `rating` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))",
"CREATE TABLE IF NOT EXISTS `pins` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `kind` TEXT NOT NULL, `refId` INTEGER NOT NULL, `label` TEXT NOT NULL, `subLabel` TEXT NOT NULL, `artAlbumId` INTEGER NOT NULL, `pinnedAt` INTEGER NOT NULL)",
"CREATE TABLE IF NOT EXISTS `history` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `kind` TEXT NOT NULL, `refId` INTEGER NOT NULL, `label` TEXT NOT NULL, `subLabel` TEXT NOT NULL, `artAlbumId` INTEGER NOT NULL, `playedAt` INTEGER NOT NULL)",
"CREATE TABLE IF NOT EXISTS `artist_images` (`artistKey` TEXT NOT NULL, `imagePath` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`artistKey`))",
"CREATE TABLE IF NOT EXISTS `notes` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `body` TEXT NOT NULL, `modifiedAt` INTEGER NOT NULL)",
"CREATE INDEX IF NOT EXISTS `index_notes_modifiedAt` ON `notes` (`modifiedAt`)",
"CREATE TABLE IF NOT EXISTS `appointments` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `notes` TEXT, `startAt` INTEGER NOT NULL, `endAt` INTEGER)",
"CREATE INDEX IF NOT EXISTS `index_appointments_startAt` ON `appointments` (`startAt`)",
"CREATE TABLE IF NOT EXISTS `alarms` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `label` TEXT NOT NULL, `alarmKind` INTEGER NOT NULL, `refId` INTEGER NOT NULL, `daysOfWeek` INTEGER NOT NULL)",
"CREATE TABLE IF NOT EXISTS `radio_stations` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `frequencyKhz` INTEGER NOT NULL, `streamUrl` TEXT NOT NULL, `isPreset` INTEGER NOT NULL, `lastPlayedAt` INTEGER NOT NULL)",
"CREATE TABLE IF NOT EXISTS `podcast_feeds` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `feedUrl` TEXT NOT NULL, `artworkUrl` TEXT NOT NULL, `description` TEXT NOT NULL, `subscribedAt` INTEGER NOT NULL)",
"CREATE UNIQUE INDEX IF NOT EXISTS `index_podcast_feeds_feedUrl` ON `podcast_feeds` (`feedUrl`)",
"CREATE TABLE IF NOT EXISTS `podcast_episodes` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `feedId` INTEGER NOT NULL, `title` TEXT NOT NULL, `pubAt` INTEGER NOT NULL, `durationMs` INTEGER NOT NULL, `enclosureUrl` TEXT NOT NULL, `played` INTEGER NOT NULL, `positionMs` INTEGER NOT NULL)",
"CREATE INDEX IF NOT EXISTS `index_podcast_episodes_feedId` ON `podcast_episodes` (`feedId`)",
"CREATE INDEX IF NOT EXISTS `index_podcast_episodes_pubAt` ON `podcast_episodes` (`pubAt`)",
"CREATE TABLE IF NOT EXISTS `game_scores` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `game` TEXT NOT NULL, `score` INTEGER NOT NULL, `meta` TEXT, `playedAt` INTEGER NOT NULL)",
"CREATE INDEX IF NOT EXISTS `index_game_scores_game` ON `game_scores` (`game`)",
"CREATE INDEX IF NOT EXISTS `index_game_scores_playedAt` ON `game_scores` (`playedAt`)",
"CREATE TABLE IF NOT EXISTS `track_features` (`mediaId` INTEGER NOT NULL, `bpm` REAL NOT NULL, `energy` REAL NOT NULL, `valence` REAL NOT NULL, `acousticness` REAL NOT NULL, `danceability` REAL NOT NULL, `spectralCentroid` REAL NOT NULL, `analyzedAt` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))",
"CREATE TABLE IF NOT EXISTS `scrobble_queue` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `artist` TEXT NOT NULL, `title` TEXT NOT NULL, `album` TEXT NOT NULL, `durationSeconds` INTEGER NOT NULL, `timestampSec` INTEGER NOT NULL)",
"CREATE TABLE IF NOT EXISTS `play_counts` (`mediaId` INTEGER NOT NULL, `count` INTEGER NOT NULL, `lastPlayedAt` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))",
"CREATE TABLE IF NOT EXISTS `app_state` (`app` TEXT NOT NULL, `value` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`app`))",
)
}
}
@@ -0,0 +1,58 @@
package com.heretek.dorado_hd
import com.heretek.dorado_hd.data.model.AudiobookProgress
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** Progress/bookmark codec and time formatting (roadmap D4). */
class AudiobookProgressTest {
@Test
fun `encode writes part and position`() {
assertEquals("2:12000", AudiobookProgress.encode(AudiobookProgress.Position(2, 12_000L)))
assertEquals("0:0", AudiobookProgress.encode(AudiobookProgress.Position(0, 0L)))
}
@Test
fun `encode maps missing or negative parts to none`() {
assertEquals(AudiobookProgress.NONE, AudiobookProgress.encode(null))
assertEquals(AudiobookProgress.NONE, AudiobookProgress.encode(AudiobookProgress.Position(-1, 500L)))
// A negative offset is clamped, a negative part is "not started".
assertEquals("1:0", AudiobookProgress.encode(AudiobookProgress.Position(1, -50L)))
}
@Test
fun `decode round trips valid positions`() {
val position = AudiobookProgress.Position(7, 3_600_000L)
assertEquals(position, AudiobookProgress.decode(AudiobookProgress.encode(position)))
assertEquals(AudiobookProgress.Position(0, 0L), AudiobookProgress.decode("0:0"))
}
@Test
fun `decode rejects none and malformed input`() {
assertNull(AudiobookProgress.decode(null))
assertNull(AudiobookProgress.decode(""))
assertNull(AudiobookProgress.decode(AudiobookProgress.NONE))
assertNull(AudiobookProgress.decode("-1:0"))
assertNull(AudiobookProgress.decode("garbage"))
assertNull(AudiobookProgress.decode("2"))
assertNull(AudiobookProgress.decode("2:"))
assertNull(AudiobookProgress.decode("x:10"))
assertNull(AudiobookProgress.decode("2:-1"))
}
@Test
fun `remaining never goes negative`() {
assertEquals(150_000L, AudiobookProgress.remainingMs(90_000L, 240_000L))
assertEquals(0L, AudiobookProgress.remainingMs(300_000L, 240_000L))
}
@Test
fun `format renders minutes or hours`() {
assertEquals("0:00", AudiobookProgress.format(0L))
assertEquals("1:15", AudiobookProgress.format(75_400L))
assertEquals("1:02:03", AudiobookProgress.format(3_723_000L))
assertEquals("0:00", AudiobookProgress.format(-10L))
}
}
@@ -0,0 +1,51 @@
package com.heretek.dorado_hd
import com.heretek.dorado_hd.media.AudiobookSpeeds
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/** Speed-step mapping for the audiobook selector (roadmap D4). */
class AudiobookSpeedsTest {
@Test
fun `offers the five selector steps`() {
assertEquals(listOf(0.75f, 1.0f, 1.25f, 1.5f, 2.0f), AudiobookSpeeds.STEPS)
}
@Test
fun `nearest snaps any value onto a step`() {
assertEquals(0.75f, AudiobookSpeeds.nearest(0.6f))
assertEquals(1.0f, AudiobookSpeeds.nearest(1.1f))
assertEquals(1.25f, AudiobookSpeeds.nearest(1.3f))
assertEquals(1.5f, AudiobookSpeeds.nearest(1.6f))
assertEquals(2.0f, AudiobookSpeeds.nearest(3.0f))
// Ties resolve to the lower step (deterministic).
assertEquals(1.0f, AudiobookSpeeds.nearest(1.125f))
}
@Test
fun `labels drop trailing zeros`() {
assertEquals("1×", AudiobookSpeeds.label(1.0f))
assertEquals("0.75×", AudiobookSpeeds.label(0.75f))
assertEquals("1.25×", AudiobookSpeeds.label(1.25f))
assertEquals("2×", AudiobookSpeeds.label(2.0f))
// A non-step label still reports the nearest offered step.
assertEquals("1.5×", AudiobookSpeeds.label(1.4f))
}
@Test
fun `is step recognises exact steps only`() {
assertTrue(AudiobookSpeeds.isStep(0.75f))
assertTrue(AudiobookSpeeds.isStep(2.0f))
assertFalse(AudiobookSpeeds.isStep(1.1f))
}
@Test
fun `clamp keeps playback speeds sane`() {
assertEquals(0.25f, AudiobookSpeeds.clamp(0.0f))
assertEquals(3.0f, AudiobookSpeeds.clamp(9.0f))
assertEquals(1.5f, AudiobookSpeeds.clamp(1.5f))
}
}
@@ -0,0 +1,146 @@
package com.heretek.dorado_hd
import android.content.Context
import android.net.Uri
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.heretek.dorado_hd.data.db.DoradoDatabase
import com.heretek.dorado_hd.data.model.AudiobookProgress
import com.heretek.dorado_hd.data.repo.AudiobookRepository
import com.heretek.dorado_hd.data.scan.AudiobookGrouping
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
/** Room round-trip for [AudiobookRepository] (mirrors AudioFeatureStoreTest). */
@RunWith(RobolectricTestRunner::class)
class AudiobookStoreTest {
private lateinit var db: DoradoDatabase
private lateinit var repo: AudiobookRepository
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(context, DoradoDatabase::class.java)
.allowMainThreadQueries()
.build()
repo = AudiobookRepository(db)
}
@After
fun tearDown() {
db.close()
}
private fun book(
key: String,
title: String,
author: String = "Jane Author",
mediaIds: List<Long> = listOf(1L, 2L, 3L),
) = AudiobookGrouping.Book(
key = key,
title = title,
author = author,
albumId = 7L,
source = AudiobookGrouping.Source.DIRECTORY,
parts = mediaIds.mapIndexed { index, mediaId ->
AudiobookGrouping.File(
mediaId = mediaId,
title = "Part ${index + 1}",
artist = author,
album = title,
albumId = 7L,
genre = "unknown",
durationMs = 60_000L,
dateAdded = 0L,
path = "/Books/$title/${index + 1}.mp3",
uri = "content://media/external/audio/media/$mediaId",
)
},
)
@Test
fun `rebuild persists books and ordered parts`() = runBlocking {
repo.rebuild(listOf(book("dir:/a", "Alpha", mediaIds = listOf(11, 12, 13))))
val books = repo.books().first()
assertEquals(1, books.size)
assertEquals("Alpha", books.single().title)
assertEquals("Jane Author", books.single().author)
assertEquals(3, books.single().partCount)
assertEquals(180_000L, books.single().totalDurationMs)
val parts = repo.parts(books.single().id).first()
assertEquals(listOf("Part 1", "Part 2", "Part 3"), parts.map { it.title })
assertEquals(listOf(0, 1, 2), parts.map { it.index })
assertEquals(listOf(11L, 12L, 13L), parts.map { it.mediaId })
assertEquals(Uri.parse("content://media/external/audio/media/11"), parts.first().uri)
}
@Test
fun `resume and bookmark round trip through the codec`() = runBlocking {
repo.rebuild(listOf(book("dir:/a", "Alpha")))
val id = repo.books().first().single().id
repo.saveResume(id, 2, 12_000L)
repo.saveBookmark(id, 1, 500L)
val stored = repo.book(id).first()!!
assertEquals(AudiobookProgress.Position(2, 12_000L), stored.resume)
assertEquals(AudiobookProgress.Position(1, 500L), stored.bookmark)
assertEquals("part 3 of 3", stored.progressLabel)
repo.clearBookmark(id)
assertNull(repo.book(id).first()!!.bookmark)
assertEquals(AudiobookProgress.Position(2, 12_000L), repo.book(id).first()!!.resume)
}
@Test
fun `rescan preserves resume and replaces parts for the same group key`() = runBlocking {
repo.rebuild(listOf(book("dir:/a", "Alpha", mediaIds = listOf(1, 2, 3))))
val alphaBefore = repo.books().first().single()
repo.saveResume(alphaBefore.id, 1, 9_000L)
repo.rebuild(
listOf(
book("dir:/a", "Alpha Revised", mediaIds = listOf(21, 22)),
book("dir:/b", "Beta", mediaIds = listOf(31)),
),
)
val books = repo.books().first()
assertEquals(listOf("Alpha Revised", "Beta"), books.map { it.title })
val alpha = books.first { it.title == "Alpha Revised" }
assertEquals(alphaBefore.id, alpha.id)
assertEquals(2, alpha.partCount)
assertEquals(AudiobookProgress.Position(1, 9_000L), alpha.resume)
assertEquals(listOf(21L, 22L), repo.parts(alpha.id).first().map { it.mediaId })
assertNull(alpha.bookmark)
}
@Test
fun `rebuild drops vanished books and their parts`() = runBlocking {
repo.rebuild(
listOf(
book("dir:/a", "Alpha", mediaIds = listOf(1, 2)),
book("dir:/b", "Beta", mediaIds = listOf(3, 4)),
),
)
val betaId = repo.books().first().first { it.title == "Beta" }.id
repo.rebuild(listOf(book("dir:/a", "Alpha", mediaIds = listOf(1, 2))))
val books = repo.books().first()
assertEquals(listOf("Alpha"), books.map { it.title })
assertTrue(repo.parts(betaId).first().isEmpty())
assertNull(repo.bookNow(betaId))
}
}
@@ -0,0 +1,137 @@
package com.heretek.dorado_hd.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.dp
import androidx.test.core.app.ApplicationProvider
import com.heretek.dorado_hd.DoradoApp
import com.heretek.dorado_hd.DoradoGraph
import com.heretek.dorado_hd.TestDoradoApp
import com.heretek.dorado_hd.data.scan.AudiobookGrouping
import com.heretek.dorado_hd.design.DoradoTheme
import com.heretek.dorado_hd.ui.components.LocalContextMenu
import com.heretek.dorado_hd.ui.components.MenuController
import com.heretek.dorado_hd.ui.screens.AudiobookBookScreen
import com.heretek.dorado_hd.ui.screens.AudiobooksTab
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* Compose smoke gate for the D4 audiobook surfaces: the book list and the
* book detail (parts + resume CTA) render through the real graph without
* throwing and expose their text.
*/
@RunWith(RobolectricTestRunner::class)
@Config(application = TestDoradoApp::class)
class AudiobookScreenTest {
@get:Rule
val compose = createComposeRule()
private fun book() = AudiobookGrouping.Book(
key = "dir:/Books/Smoke Alpha",
title = "Smoke Alpha",
author = "Jane Author",
albumId = 7L,
source = AudiobookGrouping.Source.DIRECTORY,
parts = listOf(1L, 2L).mapIndexed { index, mediaId ->
AudiobookGrouping.File(
mediaId = mediaId,
title = "Part ${index + 1}",
artist = "Jane Author",
album = "Smoke Alpha",
albumId = 7L,
genre = "unknown",
durationMs = 60_000L,
dateAdded = 0L,
path = "/Books/Smoke Alpha/${index + 1}.mp3",
uri = "content://media/external/audio/media/$mediaId",
)
},
)
/**
* The app's first-launch MediaStore scan asynchronously rebuilds (and so
* wipes) the audiobook catalog; wait until it has completed before seeding
* a fixture, otherwise the scan deletes it mid-test.
*/
private suspend fun seed(graph: DoradoGraph) {
withTimeout(15_000) {
while (!graph.settingsFlow.first().libraryScanned) delay(25)
}
graph.audiobooks.rebuild(listOf(book()))
}
@Test
fun `book list renders the title and progress`() {
val graph = ApplicationProvider.getApplicationContext<DoradoApp>().graph
runBlocking { seed(graph) }
compose.setContent {
DoradoTheme {
val menus = remember { MenuController() }
CompositionLocalProvider(
LocalDoradoGraph provides graph,
LocalContextMenu provides menus,
) {
Box(Modifier.requiredSize(480.dp, 272.dp)) {
AudiobooksTab(onOpenBook = {})
}
}
}
}
compose.waitUntil(5_000) {
compose.onAllNodesWithText("Smoke Alpha", substring = true)
.fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithText("Smoke Alpha", substring = true).assertExists()
compose.onNodeWithText("Jane Author", substring = true).assertExists()
compose.onNodeWithText("not started", substring = true).assertExists()
}
@Test
fun `book detail renders parts and the resume affordances`() {
val graph = ApplicationProvider.getApplicationContext<DoradoApp>().graph
val bookId = runBlocking {
seed(graph)
graph.audiobooks.books().first().first { it.title == "Smoke Alpha" }.id
}
compose.setContent {
DoradoTheme {
val menus = remember { MenuController() }
CompositionLocalProvider(
LocalDoradoGraph provides graph,
LocalContextMenu provides menus,
) {
Box(Modifier.requiredSize(480.dp, 272.dp)) {
AudiobookBookScreen(bookId = bookId, onBack = {})
}
}
}
}
compose.waitUntil(5_000) {
compose.onAllNodesWithText("Part 1", substring = true)
.fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithText("Part 1", substring = true).assertExists()
compose.onNodeWithText("Part 2", substring = true).assertExists()
compose.onNodeWithText("play", substring = false).assertExists()
compose.onNodeWithText("bookmark", substring = false).assertDoesNotExist()
}
}
+4 -1
View File
@@ -259,7 +259,10 @@ explicitly documented (long-term / N-A) — no gap unplanned.
- ✅ **D1** Share (`ACTION_SEND`) from Now Playing.
- ✅ **D3** Marketplace discovery: catalog search + entry details over the frozen catalog.
- 🟡 **D2** Social user card: a local **Zune Card** (tracks/hearts/plays) on the Social screen; the inbox still needs a backend (mock shell only).
- ⏳ **D4** Audiobooks (new media model — the largest remaining gap).
- ✅ **D4** Audiobooks: `AudiobookGrouping` (genre/directory heuristics) →
Room v7 `audiobooks`/`audiobook_parts` + `AudiobookRepository` → music
crossbar `audiobooks` pivot (book list, parts, resume/bookmark, 0.75–2.0×
speed). Entry-point as post-device extension; canon §3.3/§10 updated.
### Long-term backlog (documented, not scheduled)
+8 -1
View File
@@ -56,6 +56,11 @@ placeholder.
### 3.3 Crossbar ("sub-menus arrayed left to right across the top")
- Inside `music`: top crossbar row is `albums · artists · playlists · songs ·
genres` (real Zune order).
- **Extension (D4):** Dorado-HD appends a sixth `audiobooks` pivot. The device
shipped an audiobook library (`GemLibraryAudiobookPartScene`, parity audit
§2), but its crossbar position is not recoverable from the sources; keeping
books inside the music pivot leaves the nine-entry home menu untouched
(§3.1). Labelled a post-device extension per §10.
- Flick horizontally on the crossbar (or content) to switch pivots; content
slides horizontally beneath the fixed crossbar.
- Lists scroll vertically: textual (artists, genres) or grid (albums).
@@ -235,7 +240,9 @@ fade-in on the next item — Media3 has no overlapping crossfade; labelled
procedural animation remains the fallback), and the **richer lock-screen
surface** (`DoradoMediaNotificationProvider`: dedicated public low-importance
channel, compact transport actions from the session layout, album art via the
session bitmap loader).
session bitmap loader), and the **audiobook library** (grouped parts, resume
and bookmark positions, and a 0.75–2.0× reading-speed selector; anchored to the
device's `GemLibraryAudiobookPartScene`).
**Pending:** live-radio pause-and-cache — tracked in
`docs/parity-roadmap.md` (M5/M6/M10).