diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt index e84a8263..8e2d4d83 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt @@ -7,6 +7,7 @@ import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao +import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao @@ -20,6 +21,7 @@ import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity import com.fabledsword.minstrel.cache.db.entities.CachedAlbumEntity import com.fabledsword.minstrel.cache.db.entities.CachedArtistEntity +import com.fabledsword.minstrel.cache.db.entities.CachedHistorySnapshotEntity import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity @@ -57,9 +59,10 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity CachedMutationEntity::class, CachedResumeStateEntity::class, CachedHomeIndexEntity::class, + CachedHistorySnapshotEntity::class, AuthSessionEntity::class, ], - version = 5, + version = 6, exportSchema = true, ) @TypeConverters(MinstrelTypeConverters::class) @@ -76,5 +79,6 @@ abstract class AppDatabase : RoomDatabase() { abstract fun cachedMutationDao(): CachedMutationDao abstract fun cachedResumeStateDao(): CachedResumeStateDao abstract fun cachedHomeIndexDao(): CachedHomeIndexDao + abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao abstract fun authSessionDao(): AuthSessionDao } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt index 1c0d73aa..9a5287ed 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt @@ -6,6 +6,7 @@ import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao +import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao @@ -65,6 +66,11 @@ object DatabaseModule { fun provideCachedHomeIndexDao(db: AppDatabase): CachedHomeIndexDao = db.cachedHomeIndexDao() + @Provides + @Singleton + fun provideCachedHistorySnapshotDao(db: AppDatabase): CachedHistorySnapshotDao = + db.cachedHistorySnapshotDao() + @Provides @Singleton fun provideCachedPlaylistDao(db: AppDatabase): CachedPlaylistDao = diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedHistorySnapshotDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedHistorySnapshotDao.kt new file mode 100644 index 00000000..737a2729 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedHistorySnapshotDao.kt @@ -0,0 +1,18 @@ +package com.fabledsword.minstrel.cache.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.fabledsword.minstrel.cache.db.entities.CachedHistorySnapshotEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface CachedHistorySnapshotDao { + /** Observes the single snapshot row (null until the first fetch lands). */ + @Query("SELECT * FROM cached_history_snapshot WHERE id = 1") + fun observe(): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(row: CachedHistorySnapshotEntity) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedHistorySnapshotEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedHistorySnapshotEntity.kt new file mode 100644 index 00000000..f6d68747 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedHistorySnapshotEntity.kt @@ -0,0 +1,26 @@ +package com.fabledsword.minstrel.cache.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +/** + * Single-row snapshot of the `/api/me/history` response, stored as the + * raw wire JSON so the History tab paints instantly from disk on cold + * open and survives connectivity loss. Mirrors the Flutter Drift + * `CachedHistorySnapshot` table (id defaults to 1; one row total). + * + * Cache-first + SWR: the tab observes this row and a background refresh + * overwrites it, so the freshest plays land without a blocking spinner. + */ +@Entity(tableName = "cached_history_snapshot") +data class CachedHistorySnapshotEntity( + @PrimaryKey val id: Int = SINGLETON_ID, + val json: String, + val updatedAt: Instant = Clock.System.now(), +) { + companion object { + const val SINGLETON_ID = 1 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/history/data/HistoryRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/history/data/HistoryRepository.kt index 804fc6ad..3414993d 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/history/data/HistoryRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/history/data/HistoryRepository.kt @@ -1,8 +1,14 @@ package com.fabledsword.minstrel.history.data import com.fabledsword.minstrel.api.endpoints.HistoryApi +import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao +import com.fabledsword.minstrel.cache.db.entities.CachedHistorySnapshotEntity import com.fabledsword.minstrel.library.data.toDomain import com.fabledsword.minstrel.models.TrackRef +import com.fabledsword.minstrel.models.wire.HistoryPageWire +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json import retrofit2.Retrofit import retrofit2.create import javax.inject.Inject @@ -21,36 +27,53 @@ data class HistoryEntry( val track: TrackRef, ) -/** - * Read-through accessor for the caller's listening history. - * - * v1 is fetch-on-visit only — no Room caching. The Phase-13 offline - * pass adds a `cached_history_snapshot` JSON-blob mirror so the tab - * paints from disk on cold open and survives connectivity loss - * (matching `flutter_client/lib/library/library_screen.dart`'s - * SWR-style `historyProvider`). - */ -@Singleton -class HistoryRepository @Inject constructor( - retrofit: Retrofit, -) { - private val api: HistoryApi = retrofit.create() - - suspend fun fetchPage( - limit: Int = HistoryApi.DEFAULT_LIMIT, - offset: Int = 0, - ): HistoryPage { - val wire = api.getHistory(limit, offset) - return HistoryPage( - entries = wire.events.map { - HistoryEntry(id = it.id, playedAt = it.playedAt, track = it.track.toDomain()) - }, - hasMore = wire.hasMore, - ) - } -} - data class HistoryPage( val entries: List, val hasMore: Boolean, ) + +/** + * Cache-first listening history. Mirrors Flutter's `_historyProvider` + * (`cacheFirst` over the `cached_history_snapshot` drift row with + * `alwaysRefresh`) using the native Room + Flow idiom: + * + * - [observeHistory] emits the cached snapshot instantly (null until + * the first fetch lands), so the History tab paints from disk on + * cold open with no blocking spinner. + * - [refresh] pulls `/api/me/history`, stores the raw wire JSON as + * the single snapshot row, and the Flow re-emits — SWR. Errors + * propagate to the caller (the VM surfaces them only when there's + * no cache to show). + * + * The whole page is stored as one JSON blob (single-row table) rather + * than per-row Room entities — history is a read-only timestamp-keyed + * snapshot, so a blob is the simplest faithful mirror of the Drift + * snapshot and avoids ordering/paging bookkeeping for no UX gain. + */ +@Singleton +class HistoryRepository @Inject constructor( + private val snapshotDao: CachedHistorySnapshotDao, + private val json: Json, + retrofit: Retrofit, +) { + private val api: HistoryApi = retrofit.create() + + fun observeHistory(): Flow = + snapshotDao.observe().map { row -> + row?.let { json.decodeFromString(it.json).toDomain() } + } + + /** Fetch the latest history and overwrite the cached snapshot. */ + suspend fun refresh() { + val wire = api.getHistory() + val blob = json.encodeToString(HistoryPageWire.serializer(), wire) + snapshotDao.upsert(CachedHistorySnapshotEntity(json = blob)) + } +} + +private fun HistoryPageWire.toDomain(): HistoryPage = HistoryPage( + entries = events.map { + HistoryEntry(id = it.id, playedAt = it.playedAt, track = it.track.toDomain()) + }, + hasMore = hasMore, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt b/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt index 69efca0b..38eef426 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt @@ -35,8 +35,10 @@ import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.time.OffsetDateTime import java.time.ZoneId @@ -48,6 +50,8 @@ import javax.inject.Inject // ─── State ─────────────────────────────────────────────────────────── +private const val SHARE_STOP_TIMEOUT_MS = 5_000L + sealed interface HistoryUiState { data object Loading : HistoryUiState data object Empty : HistoryUiState @@ -63,27 +67,36 @@ class HistoryTabViewModel @Inject constructor( private val player: PlayerController, ) : ViewModel() { - private val internal = MutableStateFlow(HistoryUiState.Loading) - val uiState: StateFlow = internal.asStateFlow() + private val refreshError = MutableStateFlow(null) + + /** + * Cache-first: emits the cached snapshot instantly (paints from + * disk on cold open), then SWR-refreshes underneath. Shows Error + * only when there's nothing cached AND the refresh failed — a + * failed refresh over a populated cache stays silent. + */ + val uiState: StateFlow = + combine(repository.observeHistory(), refreshError) { page, err -> + when { + page != null && page.entries.isNotEmpty() -> HistoryUiState.Success(page.entries) + page != null -> HistoryUiState.Empty + err != null -> HistoryUiState.Error(err) + else -> HistoryUiState.Loading + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), + initialValue = HistoryUiState.Loading, + ) init { refresh() } fun refresh(): Job = viewModelScope.launch { - internal.value = HistoryUiState.Loading - try { - val page = repository.fetchPage() - internal.value = if (page.entries.isEmpty()) { - HistoryUiState.Empty - } else { - HistoryUiState.Success(page.entries) - } - } catch ( - @Suppress("TooGenericExceptionCaught") e: Throwable, - ) { - internal.value = HistoryUiState.Error(ErrorCopy.fromThrowable(e)) - } + refreshError.value = null + runCatching { repository.refresh() } + .onFailure { refreshError.value = ErrorCopy.fromThrowable(it) } } /** Single-track play, matching the Flutter history-row behavior. */