mirror of
https://github.com/project-dorado/dorado-hd.git
synced 2026-10-11 21:10:01 +00:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user