diff --git a/android/.idea/caches/deviceStreaming.xml b/android/.idea/caches/deviceStreaming.xml
index d469d46c..5dc2fb2b 100644
--- a/android/.idea/caches/deviceStreaming.xml
+++ b/android/.idea/caches/deviceStreaming.xml
@@ -1754,6 +1754,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/.idea/misc.xml b/android/.idea/misc.xml
index 74dd639e..b2c751a3 100644
--- a/android/.idea/misc.xml
+++ b/android/.idea/misc.xml
@@ -1,4 +1,3 @@
-
diff --git a/android/app/schemas/com.fabledsword.minstrel.cache.db.AppDatabase/6.json b/android/app/schemas/com.fabledsword.minstrel.cache.db.AppDatabase/6.json
new file mode 100644
index 00000000..f0864eb0
--- /dev/null
+++ b/android/app/schemas/com.fabledsword.minstrel.cache.db.AppDatabase/6.json
@@ -0,0 +1,690 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 6,
+ "identityHash": "fb73ed8674efb1d82a586551baba5ef0",
+ "entities": [
+ {
+ "tableName": "sync_metadata",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cursor` INTEGER NOT NULL, `lastSyncAt` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "cursor",
+ "columnName": "cursor",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastSyncAt",
+ "columnName": "lastSyncAt",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "cached_artists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `sortName` TEXT NOT NULL, `mbid` TEXT, `artistThumbPath` TEXT, `artistFanartPath` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sortName",
+ "columnName": "sortName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mbid",
+ "columnName": "mbid",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "artistThumbPath",
+ "columnName": "artistThumbPath",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "artistFanartPath",
+ "columnName": "artistFanartPath",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "fetchedAt",
+ "columnName": "fetchedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "cached_albums",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `sortTitle` TEXT NOT NULL, `releaseDate` TEXT, `coverPath` TEXT, `mbid` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artistId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sortTitle",
+ "columnName": "sortTitle",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseDate",
+ "columnName": "releaseDate",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "coverPath",
+ "columnName": "coverPath",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mbid",
+ "columnName": "mbid",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "fetchedAt",
+ "columnName": "fetchedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "cached_tracks",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `durationMs` INTEGER NOT NULL, `trackNumber` INTEGER, `discNumber` INTEGER, `filePath` TEXT, `fileFormat` TEXT, `genre` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "albumId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artistId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "durationMs",
+ "columnName": "durationMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "trackNumber",
+ "columnName": "trackNumber",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "discNumber",
+ "columnName": "discNumber",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "filePath",
+ "columnName": "filePath",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "fileFormat",
+ "columnName": "fileFormat",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "genre",
+ "columnName": "genre",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "fetchedAt",
+ "columnName": "fetchedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "cached_likes",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `likedAt` INTEGER NOT NULL, PRIMARY KEY(`userId`, `entityType`, `entityId`))",
+ "fields": [
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "entityType",
+ "columnName": "entityType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "entityId",
+ "columnName": "entityId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "likedAt",
+ "columnName": "likedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "userId",
+ "entityType",
+ "entityId"
+ ]
+ }
+ },
+ {
+ "tableName": "cached_playlists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userId` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `isPublic` INTEGER NOT NULL, `coverPath` TEXT, `trackCount` INTEGER NOT NULL, `durationSec` INTEGER NOT NULL, `systemVariant` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "userId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isPublic",
+ "columnName": "isPublic",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "coverPath",
+ "columnName": "coverPath",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "trackCount",
+ "columnName": "trackCount",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "durationSec",
+ "columnName": "durationSec",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "systemVariant",
+ "columnName": "systemVariant",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "fetchedAt",
+ "columnName": "fetchedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "cached_playlist_tracks",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `trackId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`playlistId`, `trackId`))",
+ "fields": [
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlistId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "trackId",
+ "columnName": "trackId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "playlistId",
+ "trackId"
+ ]
+ }
+ },
+ {
+ "tableName": "cached_quarantine_mine",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `reason` TEXT NOT NULL, `notes` TEXT, `createdAt` TEXT NOT NULL, `trackTitle` TEXT NOT NULL, `trackDurationMs` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `albumTitle` TEXT NOT NULL, `albumCoverArtPath` TEXT, `artistId` TEXT NOT NULL, `artistName` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`trackId`))",
+ "fields": [
+ {
+ "fieldPath": "trackId",
+ "columnName": "trackId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "reason",
+ "columnName": "reason",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "notes",
+ "columnName": "notes",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "trackTitle",
+ "columnName": "trackTitle",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "trackDurationMs",
+ "columnName": "trackDurationMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "albumId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumTitle",
+ "columnName": "albumTitle",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumCoverArtPath",
+ "columnName": "albumCoverArtPath",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artistId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artistName",
+ "columnName": "artistName",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fetchedAt",
+ "columnName": "fetchedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "trackId"
+ ]
+ }
+ },
+ {
+ "tableName": "audio_cache_index",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `path` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `cachedAt` INTEGER NOT NULL, `lastPlayedAt` INTEGER, `source` TEXT NOT NULL, PRIMARY KEY(`trackId`))",
+ "fields": [
+ {
+ "fieldPath": "trackId",
+ "columnName": "trackId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "path",
+ "columnName": "path",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sizeBytes",
+ "columnName": "sizeBytes",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "cachedAt",
+ "columnName": "cachedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastPlayedAt",
+ "columnName": "lastPlayedAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "trackId"
+ ]
+ }
+ },
+ {
+ "tableName": "cached_mutations",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `kind` TEXT NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAttemptAt` INTEGER, `attempts` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "kind",
+ "columnName": "kind",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "payload",
+ "columnName": "payload",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastAttemptAt",
+ "columnName": "lastAttemptAt",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "attempts",
+ "columnName": "attempts",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "cached_resume_state",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "json",
+ "columnName": "json",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "cached_home_index",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`section` TEXT NOT NULL, `position` INTEGER NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`section`, `position`))",
+ "fields": [
+ {
+ "fieldPath": "section",
+ "columnName": "section",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "entityType",
+ "columnName": "entityType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "entityId",
+ "columnName": "entityId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fetchedAt",
+ "columnName": "fetchedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "section",
+ "position"
+ ]
+ }
+ },
+ {
+ "tableName": "cached_history_snapshot",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "json",
+ "columnName": "json",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "auth_session",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `sessionCookie` TEXT, `baseUrl` TEXT NOT NULL, `userJson` TEXT, `themeMode` TEXT, `clientId` TEXT, `cacheSettingsJson` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sessionCookie",
+ "columnName": "sessionCookie",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "baseUrl",
+ "columnName": "baseUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userJson",
+ "columnName": "userJson",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "themeMode",
+ "columnName": "themeMode",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "clientId",
+ "columnName": "clientId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "cacheSettingsJson",
+ "columnName": "cacheSettingsJson",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fb73ed8674efb1d82a586551baba5ef0')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt
index cfbe9396..c92f4dbd 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/MainActivity.kt
@@ -23,7 +23,7 @@ import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
import com.fabledsword.minstrel.cache.CachedTrackIds
import com.fabledsword.minstrel.connectivity.LocalServerHealth
import com.fabledsword.minstrel.connectivity.ServerHealth
-import com.fabledsword.minstrel.connectivity.ServerHealthController
+import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.nav.DetailSeedCache
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.nav.MinstrelNavGraph
@@ -41,7 +41,7 @@ import javax.inject.Inject
class MainActivity : ComponentActivity() {
@Inject lateinit var seedCache: DetailSeedCache
@Inject lateinit var cachedTrackIds: CachedTrackIds
- @Inject lateinit var serverHealth: ServerHealthController
+ @Inject lateinit var serverHealth: NetworkStatusController
// Flipped to true when the user taps the media notification (or
// any other entry point that asks for the full player). The App
@@ -91,7 +91,7 @@ class MainActivity : ComponentActivity() {
private fun App(
seedCache: DetailSeedCache,
cachedTrackIds: CachedTrackIds,
- serverHealth: ServerHealthController,
+ serverHealth: NetworkStatusController,
pendingOpenNowPlaying: StateFlow,
onOpenedNowPlaying: () -> Unit,
themeVm: ThemePreferenceViewModel = hiltViewModel(),
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt
index 1b6f8e91..18db4513 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt
@@ -19,8 +19,7 @@ import com.fabledsword.minstrel.player.PlayEventsReporter
import com.fabledsword.minstrel.player.PlaybackErrorReporter
import com.fabledsword.minstrel.player.ResumeController
import com.fabledsword.minstrel.update.data.UpdateBannerController
-import com.fabledsword.minstrel.connectivity.ServerHealthController
-import com.fabledsword.minstrel.update.data.VersionCheckController
+import com.fabledsword.minstrel.connectivity.NetworkStatusController
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -115,21 +114,13 @@ class MinstrelApplication :
@Suppress("unused") @Inject lateinit var audioPrefetcher: AudioPrefetcher
/**
- * Same construct-the-singleton trick — VersionCheckController's
- * init block starts a 5-min poll loop against /healthz so the
- * shell-level VersionTooOldBanner can surface min_client_version
- * mismatches without waiting for the next user-driven request.
+ * Same construct-the-singleton trick — NetworkStatusController owns the
+ * /healthz poll loop + the device-link collector + the reachability state
+ * machine, and is the single authority on the tri-state ServerHealth
+ * signal (plus the VersionTooOld byproduct). It must exist from launch so
+ * the poll loop runs and the StateFlow stays warm for every consumer.
*/
- @Suppress("unused") @Inject lateinit var versionCheckController: VersionCheckController
-
- /**
- * Same construct-the-singleton trick — ServerHealthController combines
- * ConnectivityObserver + VersionCheckController.reachable into the
- * tri-state ServerHealth signal. Its stateIn is `SharingStarted.Eagerly`
- * so the StateFlow needs an active subscriber from launch onward; the
- * @Inject keeps the singleton alive and the flow collecting.
- */
- @Suppress("unused") @Inject lateinit var serverHealthController: ServerHealthController
+ @Suppress("unused") @Inject lateinit var networkStatusController: NetworkStatusController
/**
* Same construct-the-singleton trick — UpdateBannerController polls
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/NetworkModule.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/NetworkModule.kt
index 040b203b..91233f84 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/api/NetworkModule.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/api/NetworkModule.kt
@@ -1,6 +1,7 @@
package com.fabledsword.minstrel.api
import com.fabledsword.minstrel.BuildConfig
+import com.fabledsword.minstrel.connectivity.ReachabilityReportingInterceptor
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import dagger.Module
import dagger.Provides
@@ -45,9 +46,15 @@ object NetworkModule {
fun provideOkHttp(
baseUrl: BaseUrlInterceptor,
auth: AuthCookieInterceptor,
+ reachability: ReachabilityReportingInterceptor,
logging: HttpLoggingInterceptor,
): OkHttpClient =
OkHttpClient.Builder()
+ // ReachabilityReportingInterceptor MUST run first: it identifies
+ // Minstrel-bound requests by the still-unrewritten PLACEHOLDER_HOST
+ // (so external artwork fetches don't read as server reachability)
+ // and observes the final transport outcome by wrapping the chain.
+ .addInterceptor(reachability)
// AuthCookieInterceptor MUST run before BaseUrlInterceptor.
// Both scope on `host == PLACEHOLDER_HOST` to distinguish
// Minstrel-server requests from external image fetches
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistDao.kt
index bb3276da..1fc818b8 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistDao.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistDao.kt
@@ -31,6 +31,20 @@ interface CachedPlaylistDao {
@Query("SELECT * FROM cached_playlists WHERE id = :id")
suspend fun getById(id: String): CachedPlaylistEntity?
+ /**
+ * Per-playlist count of member tracks resident in the audio cache index.
+ * LEFT JOINs so playlists with zero cached tracks still appear
+ * (cachedCount = 0). Drives the offline "fully cached" greying.
+ */
+ @Query(
+ "SELECT p.id AS playlistId, COUNT(a.trackId) AS cachedCount " +
+ "FROM cached_playlists p " +
+ "LEFT JOIN cached_playlist_tracks t ON t.playlistId = p.id " +
+ "LEFT JOIN audio_cache_index a ON a.trackId = t.trackId " +
+ "GROUP BY p.id",
+ )
+ fun observeCachedCounts(): Flow>
+
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List)
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/PlaylistCachedCount.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/PlaylistCachedCount.kt
new file mode 100644
index 00000000..3aea0e79
--- /dev/null
+++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/PlaylistCachedCount.kt
@@ -0,0 +1,11 @@
+package com.fabledsword.minstrel.cache.db.dao
+
+/**
+ * Projection: how many of a playlist's member tracks are resident in the audio
+ * cache index. Backs the offline "fully cached" greying — a playlist is fully
+ * available offline when [cachedCount] reaches its `trackCount`.
+ */
+data class PlaylistCachedCount(
+ val playlistId: String,
+ val cachedCount: Int,
+)
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ConnectivityObserver.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ConnectivityObserver.kt
index 09db0a07..ee4ccc4e 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ConnectivityObserver.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ConnectivityObserver.kt
@@ -29,7 +29,7 @@ import javax.inject.Singleton
* the app to Offline with no debounce and fast-failed in-flight playback
* via [com.fabledsword.minstrel.player.OfflineGatedDataSource]. The
* authority on whether *Minstrel* is reachable is the `/healthz` poll
- * ([com.fabledsword.minstrel.update.data.VersionCheckController], which
+ * ([com.fabledsword.minstrel.connectivity.NetworkStatusController], which
* has its own failure hysteresis), not this coarse device-link signal.
*
* Used by the shell-level ConnectionErrorBanner; downstream
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/NetworkStatusController.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/NetworkStatusController.kt
new file mode 100644
index 00000000..e59d8b1a
--- /dev/null
+++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/NetworkStatusController.kt
@@ -0,0 +1,176 @@
+package com.fabledsword.minstrel.connectivity
+
+import androidx.compose.runtime.staticCompositionLocalOf
+import com.fabledsword.minstrel.BuildConfig
+import com.fabledsword.minstrel.auth.AuthStore
+import com.fabledsword.minstrel.di.ApplicationScope
+import com.fabledsword.minstrel.update.api.HealthzApi
+import com.fabledsword.minstrel.update.api.HealthzResponse
+import com.fabledsword.minstrel.update.data.VersionResult
+import com.fabledsword.minstrel.update.data.isVersionNewer
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import retrofit2.Retrofit
+import timber.log.Timber
+import java.util.concurrent.atomic.AtomicLong
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val POLL_HEALTHY_MS = 5 * 60 * 1000L
+private const val POLL_DEGRADED_MS = 20_000L
+private const val ARBITRATE_MIN_GAP_MS = 2_000L
+
+/**
+ * THE single authority on network/server reachability. Absorbs the former
+ * VersionCheckController (the /healthz poll + version parsing) and
+ * ServerHealthController (the tri-state derive) into one signal-driven unit so
+ * the app has one place that answers "can we reach Minstrel?" — not three
+ * half-systems reporting differently.
+ *
+ * Inputs (all funnel through a single-consumer intent channel for thread
+ * safety — [reportSuccess]/[reportFailure] are called from the audio read path
+ * and OkHttp threads):
+ * - device link transitions ([ConnectivityObserver]); link-return triggers an
+ * immediate probe so recovery is near-instant (fixes the sticky banner).
+ * - periodic /healthz probe, adaptive cadence (calm when Healthy, fast when down).
+ * - reportSuccess / reportFailure from the API interceptor, the audio data
+ * source, and the playback-error reporter.
+ * - recheck() from pull-to-refresh and the banner.
+ *
+ * Version compatibility is a byproduct of the same /healthz response.
+ *
+ * Constructed at launch via the construct-the-singleton trick in
+ * [com.fabledsword.minstrel.MinstrelApplication].
+ */
+@Singleton
+class NetworkStatusController @Inject constructor(
+ @ApplicationScope private val scope: CoroutineScope,
+ connectivity: ConnectivityObserver,
+ private val authStore: AuthStore,
+ retrofit: Retrofit,
+) {
+ private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
+ private val machine = ReachabilityMachine()
+ private val lastProbeAtMs = AtomicLong(0)
+
+ private val stateInternal = MutableStateFlow(ServerHealth.Healthy)
+ val state: StateFlow = stateInternal.asStateFlow()
+
+ private val versionInternal = MutableStateFlow(VersionResult.SKIPPED)
+ val versionResult: StateFlow = versionInternal.asStateFlow()
+
+ private sealed interface Intent {
+ data class Link(val up: Boolean) : Intent
+ data class Probe(val resp: HealthzResponse?) : Intent
+ object OpSuccess : Intent
+ object OpFailure : Intent
+ }
+
+ private val intents = Channel(Channel.UNLIMITED)
+
+ init {
+ scope.launch { reduceLoop() }
+ scope.launch {
+ connectivity.online.collect { up ->
+ intents.trySend(Intent.Link(up))
+ if (up) probeOnce()
+ }
+ }
+ scope.launch { pollLoop() }
+ }
+
+ /** A real server op verifiably succeeded — self-proving recovery. Cheap; safe on hot paths. */
+ fun reportSuccess() {
+ if (stateInternal.value != ServerHealth.Healthy) intents.trySend(Intent.OpSuccess)
+ }
+
+ /** A real network op failed — triggers /healthz arbitration. No-op when already Offline. */
+ fun reportFailure() {
+ if (stateInternal.value == ServerHealth.Offline) return
+ intents.trySend(Intent.OpFailure)
+ }
+
+ /** One-shot recheck for pull-to-refresh and the banner. */
+ fun recheck() {
+ scope.launch { probeOnce(force = true) }
+ }
+
+ private suspend fun reduceLoop() {
+ for (intent in intents) {
+ val now = System.currentTimeMillis()
+ when (intent) {
+ is Intent.Link -> machine.onLinkChange(intent.up)
+ is Intent.Probe -> applyProbe(intent.resp, now)
+ Intent.OpSuccess -> machine.onSuccess()
+ Intent.OpFailure -> {
+ machine.onOpFailure(now)
+ // Arbitrate off the reducer thread: awaiting a stalled
+ // /healthz here would block a concurrent self-proving
+ // success from snapping us straight back to Healthy.
+ scope.launch { probeOnce() }
+ }
+ }
+ emit(machine.health())
+ }
+ }
+
+ private fun applyProbe(resp: HealthzResponse?, now: Long) {
+ if (resp == null) {
+ machine.onProbeFailure(now)
+ } else {
+ machine.onSuccess()
+ versionInternal.value = versionResultFor(resp)
+ }
+ }
+
+ private fun emit(next: ServerHealth) {
+ if (stateInternal.value != next) {
+ Timber.w("NetworkStatus -> %s", next)
+ stateInternal.value = next
+ }
+ }
+
+ private suspend fun pollLoop() {
+ probeOnce()
+ while (true) {
+ val interval =
+ if (stateInternal.value == ServerHealth.Healthy) POLL_HEALTHY_MS
+ else POLL_DEGRADED_MS
+ delay(interval)
+ probeOnce()
+ }
+ }
+
+ private suspend fun probeOnce(force: Boolean = false) {
+ // Startup guard: don't poll the localhost placeholder before AuthStore
+ // hydrates the real base URL — that false failure used to flash the banner.
+ if (authStore.baseUrl.value == AuthStore.DEFAULT_BASE_URL) return
+ val now = System.currentTimeMillis()
+ if (!force && now - lastProbeAtMs.get() < ARBITRATE_MIN_GAP_MS) return
+ lastProbeAtMs.set(now)
+ val resp = runCatching { api.check() }.getOrNull()
+ intents.trySend(Intent.Probe(resp))
+ }
+
+ private fun versionResultFor(resp: HealthzResponse): VersionResult {
+ val min = resp.minClientVersion
+ return when {
+ min.isEmpty() -> VersionResult.SKIPPED
+ isVersionNewer(min, BuildConfig.VERSION_NAME) -> VersionResult.TOO_OLD
+ else -> VersionResult.OK
+ }
+ }
+}
+
+/**
+ * Reactive [ServerHealth] snapshot provided once at the app root. Lets leaf
+ * composables (TrackRow gating, write-affordance disabling) branch on health
+ * without re-injecting the controller. Defaults to Healthy so previews/tests
+ * don't crash.
+ */
+val LocalServerHealth = staticCompositionLocalOf { ServerHealth.Healthy }
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ReachabilityMachine.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ReachabilityMachine.kt
new file mode 100644
index 00000000..11d54f15
--- /dev/null
+++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ReachabilityMachine.kt
@@ -0,0 +1,88 @@
+package com.fabledsword.minstrel.connectivity
+
+internal const val ESCALATE_AFTER_MS = 120_000L
+internal const val CORROBORATION_WINDOW_MS = 30_000L
+internal const val CORROBORATION_OP_THRESHOLD = 2
+
+/**
+ * Pure reachability state machine. No Android, no coroutines, no real clock —
+ * every entry point takes `nowMs`, so it is fully deterministic and unit-
+ * testable. [NetworkStatusController] wires real time + signals around it.
+ *
+ * Reachability (independent of the device link):
+ * - Reachable last evidence says the server answered.
+ * - Unstable a probe failed; arbitration/escalation pending.
+ * - Unreachable corroborated or sustained failure.
+ *
+ * [health] folds the device link over that: no link → Offline; otherwise the
+ * reachability maps Reachable→Healthy, Unstable→Unstable, Unreachable→ServerDown.
+ *
+ * Principle: **success is self-proving, failure is ambiguous.** [onSuccess]
+ * (a real server byte-read or API 2xx, or a successful /healthz) snaps straight
+ * back to Reachable. A failure only escalates when a /healthz probe corroborates
+ * it ([onProbeFailure]) — either via fresh op-failure corroboration or the
+ * sustained-time backstop.
+ */
+class ReachabilityMachine {
+
+ private enum class Reachability { Reachable, Unstable, Unreachable }
+
+ private var linkUp = true
+ private var reachability = Reachability.Reachable
+ private var failureStreakStartMs: Long? = null
+ private val recentOpFailures = ArrayDeque()
+
+ fun onLinkChange(up: Boolean) {
+ linkUp = up
+ // Link transitions don't reset reachability — a restored link keeps the
+ // last-known server reachability until a fresh probe/op result arrives.
+ if (!up) recentOpFailures.clear()
+ }
+
+ /** A real successful server op (stream read, API 2xx) or a successful /healthz. */
+ fun onSuccess() {
+ reachability = Reachability.Reachable
+ failureStreakStartMs = null
+ recentOpFailures.clear()
+ }
+
+ /** A real network op failed. Ambiguous on its own — records corroboration. */
+ fun onOpFailure(nowMs: Long) {
+ pruneOpFailures(nowMs)
+ recentOpFailures.addLast(nowMs)
+ }
+
+ /** A /healthz probe failed — the arbiter. Escalates per corroboration/backstop. */
+ fun onProbeFailure(nowMs: Long) {
+ pruneOpFailures(nowMs)
+ if (reachability == Reachability.Reachable) {
+ reachability = Reachability.Unstable
+ failureStreakStartMs = nowMs
+ }
+ if (reachability == Reachability.Unstable && shouldEscalate(nowMs)) {
+ reachability = Reachability.Unreachable
+ }
+ }
+
+ fun health(): ServerHealth = when {
+ !linkUp -> ServerHealth.Offline
+ reachability == Reachability.Reachable -> ServerHealth.Healthy
+ reachability == Reachability.Unstable -> ServerHealth.Unstable
+ else -> ServerHealth.ServerDown
+ }
+
+ private fun shouldEscalate(nowMs: Long): Boolean {
+ val corroborated = recentOpFailures.size >= CORROBORATION_OP_THRESHOLD
+ val sustained =
+ failureStreakStartMs?.let { nowMs - it >= ESCALATE_AFTER_MS } ?: false
+ return corroborated || sustained
+ }
+
+ private fun pruneOpFailures(nowMs: Long) {
+ while (recentOpFailures.isNotEmpty() &&
+ nowMs - recentOpFailures.first() > CORROBORATION_WINDOW_MS
+ ) {
+ recentOpFailures.removeFirst()
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ReachabilityReportingInterceptor.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ReachabilityReportingInterceptor.kt
new file mode 100644
index 00000000..c7d98c8b
--- /dev/null
+++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ReachabilityReportingInterceptor.kt
@@ -0,0 +1,49 @@
+package com.fabledsword.minstrel.connectivity
+
+import com.fabledsword.minstrel.api.BaseUrlInterceptor.Companion.PLACEHOLDER_HOST
+import dagger.Lazy
+import okhttp3.Interceptor
+import okhttp3.Response
+import java.io.IOException
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val HEALTHZ_PATH = "/healthz"
+
+/**
+ * Feeds real Minstrel API outcomes into [NetworkStatusController]. A 2xx is
+ * self-proving proof the server is reachable → reportSuccess(); a transport
+ * [IOException] (no response at all) → reportFailure(), which triggers /healthz
+ * arbitration.
+ *
+ * MUST run first in the OkHttp chain (before [BaseUrlInterceptor]) so the host
+ * is still the [PLACEHOLDER_HOST] sentinel: this shared client also fetches
+ * EXTERNAL artwork (musicbrainz / coverartarchive), and an external image
+ * loading must NOT be read as "our server is reachable" — only sentinel-host
+ * requests are Minstrel-bound. 5xx is deliberately NOT a failure (the server
+ * answered), and /healthz is skipped to avoid a feedback loop with the poll.
+ *
+ * [NetworkStatusController] is injected as a [Lazy] to break the Hilt cycle:
+ * the controller needs `Retrofit`, which needs `OkHttpClient`, which needs this
+ * interceptor. By the time a request flows through, the controller singleton is
+ * already constructed (construct-the-singleton trick in MinstrelApplication).
+ */
+@Singleton
+class ReachabilityReportingInterceptor @Inject constructor(
+ private val networkStatus: Lazy,
+) : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val request = chain.request()
+ val isMinstrel = request.url.host == PLACEHOLDER_HOST
+ val isHealthz = request.url.encodedPath.endsWith(HEALTHZ_PATH)
+ if (!isMinstrel || isHealthz) return chain.proceed(request)
+ return try {
+ val response = chain.proceed(request)
+ if (response.isSuccessful) networkStatus.get().reportSuccess()
+ response
+ } catch (e: IOException) {
+ networkStatus.get().reportFailure()
+ throw e
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealth.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealth.kt
new file mode 100644
index 00000000..23e750b0
--- /dev/null
+++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealth.kt
@@ -0,0 +1,15 @@
+package com.fabledsword.minstrel.connectivity
+
+/**
+ * The single reachability signal every consumer branches on.
+ *
+ * - [Healthy] link up, /healthz ok — normal network behavior.
+ * - [Unstable] link up, a recent failure with arbitration pending —
+ * INFORMATIONAL ONLY. Does NOT gate playback; preserves the
+ * anti-flicker intent of commit 5c0db429 while still warning
+ * the user that something is flaky.
+ * - [ServerDown] link up but /healthz failing, corroborated or sustained —
+ * gate to cache-only.
+ * - [Offline] no device link at all — gate to cache-only.
+ */
+enum class ServerHealth { Healthy, Unstable, ServerDown, Offline }
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt
deleted file mode 100644
index a2b83f8d..00000000
--- a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ServerHealthController.kt
+++ /dev/null
@@ -1,75 +0,0 @@
-package com.fabledsword.minstrel.connectivity
-
-import androidx.compose.runtime.staticCompositionLocalOf
-import com.fabledsword.minstrel.di.ApplicationScope
-import com.fabledsword.minstrel.update.data.VersionCheckController
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.stateIn
-import timber.log.Timber
-import javax.inject.Inject
-import javax.inject.Singleton
-
-/**
- * Tri-state server-reachability signal that downstream consumers can branch
- * on to decide whether to hit the network, gate writes, or fall back to
- * cache-only behavior.
- *
- * Composed from two existing signals -- this controller doesn't poll its own
- * endpoint:
- *
- * - [ConnectivityObserver.online] -- system-level NetworkCallback that
- * answers only "is there an active INTERNET-capable network link" (NOT
- * VALIDATED -- a WAN-validation flicker must not read as offline when
- * the LAN server is reachable).
- * - [VersionCheckController.reachable] -- did the last `/healthz` poll
- * succeed. This is the authority on whether *Minstrel* is reachable;
- * it has its own failure hysteresis. Distinguishes "device has a link
- * but our server is down" from "no network at all."
- *
- * `version too old` is intentionally *not* folded in here -- it's a separate
- * UX (the VersionTooOldBanner) and conflating it with offline would mask the
- * real cause.
- */
-enum class ServerHealth { Healthy, Offline, ServerDown }
-
-@Singleton
-class ServerHealthController @Inject constructor(
- @ApplicationScope scope: CoroutineScope,
- connectivity: ConnectivityObserver,
- versionCheck: VersionCheckController,
-) {
- val state: StateFlow = combine(
- connectivity.online,
- versionCheck.reachable,
- ) { online, serverReachable ->
- when {
- !online -> ServerHealth.Offline
- !serverReachable -> ServerHealth.ServerDown
- else -> ServerHealth.Healthy
- }
- }
- // Transition log -- the signal had no instrumentation, which is why a
- // false-offline (WAN flicker flipping playback to "Source error") was
- // hard to diagnose from logcat. WARN-tier so ReleaseTree surfaces it.
- .distinctUntilChanged()
- .onEach { Timber.w("ServerHealth -> %s", it) }
- .stateIn(
- scope = scope,
- started = SharingStarted.Eagerly,
- initialValue = ServerHealth.Healthy,
- )
-}
-
-/**
- * Reactive [ServerHealth] snapshot provided once at the app root from the
- * controller's StateFlow. Lets leaf composables (TrackRow gating, write-
- * affordance disabling) branch on health without each ViewModel re-
- * injecting the controller. Defaults to Healthy so unwrapped previews
- * and tests don't crash.
- */
-val LocalServerHealth = staticCompositionLocalOf { ServerHealth.Healthy }
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt
index c736610e..2d1eef2f 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/connectivity/ui/ConnectionErrorBanner.kt
@@ -14,83 +14,121 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import androidx.lifecycle.viewModelScope
import com.composables.icons.lucide.CloudOff
import com.composables.icons.lucide.Lucide
+import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
-import com.fabledsword.minstrel.connectivity.ServerHealthController
import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
-private const val HEALTH_SHARE_STOP_TIMEOUT_MS = 5_000L
+private const val BACK_ONLINE_FLASH_MS = 2_000L
/**
- * Lifts [ServerHealthController]'s tri-state into a StateFlow for the banner
- * composable. Keeps the banner pure-presentation.
+ * Exposes [NetworkStatusController]'s tri-state directly to the banner. No
+ * re-wrapping StateFlow — the controller's is already app-scoped and warm.
*/
@HiltViewModel
class ConnectivityBannerViewModel @Inject constructor(
- health: ServerHealthController,
+ networkStatus: NetworkStatusController,
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
) : ViewModel() {
- val health: StateFlow = health.state.stateIn(
- scope = viewModelScope,
- started = SharingStarted.WhileSubscribed(HEALTH_SHARE_STOP_TIMEOUT_MS),
- initialValue = ServerHealth.Healthy,
- )
+ val health: StateFlow = networkStatus.state
}
/**
- * Banner shown at the top of the shell when the user can't reach the server.
- * Tri-state so we tell the user *why*: no device network vs server-down.
- * Copy choices match the Flutter analogues. Auto-hides via slide+fade when
- * health returns to [ServerHealth.Healthy].
+ * Shell banner. Tells the user *why* they're degraded — no link vs server-down
+ * — plus a non-alarming "Reconnecting…" for the transient [ServerHealth.Unstable]
+ * window, and a brief "Back online" confirmation when health recovers so
+ * recovery is unmistakable. Sentence case, understated voice (design system).
*/
@Composable
fun ConnectionErrorBanner(
viewModel: ConnectivityBannerViewModel = hiltViewModel(),
) {
val health by viewModel.health.collectAsStateWithLifecycle()
+ var showBackOnline by remember { mutableStateOf(false) }
+ var wasDown by remember { mutableStateOf(false) }
+
+ LaunchedEffect(health) {
+ val down = health == ServerHealth.Offline || health == ServerHealth.ServerDown
+ if (health == ServerHealth.Healthy && wasDown) {
+ // try/finally so a mid-delay cancellation (health flips again) can't
+ // orphan the flag and leave "Back online" stuck on screen.
+ try {
+ showBackOnline = true
+ delay(BACK_ONLINE_FLASH_MS)
+ } finally {
+ showBackOnline = false
+ }
+ }
+ wasDown = down
+ }
+
AnimatedVisibility(
- visible = health != ServerHealth.Healthy,
+ visible = health != ServerHealth.Healthy || showBackOnline,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .background(MaterialTheme.colorScheme.errorContainer)
- .padding(horizontal = 16.dp, vertical = 10.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- Icon(
- imageVector = Lucide.CloudOff,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onErrorContainer,
- )
- Text(
- text = when (health) {
- ServerHealth.Offline ->
- "No connection — check Wi-Fi or mobile data."
- ServerHealth.ServerDown ->
- "Server unreachable — your cached content is still available."
- ServerHealth.Healthy -> ""
- },
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onErrorContainer,
- )
- }
+ BannerContent(health = health, backOnline = showBackOnline)
}
}
+
+@Composable
+private fun BannerContent(health: ServerHealth, backOnline: Boolean) {
+ val scheme = MaterialTheme.colorScheme
+ val background: Color
+ val foreground: Color
+ when {
+ backOnline -> {
+ background = scheme.secondaryContainer
+ foreground = scheme.onSecondaryContainer
+ }
+ health == ServerHealth.Unstable -> {
+ background = scheme.surfaceVariant
+ foreground = scheme.onSurfaceVariant
+ }
+ else -> {
+ background = scheme.errorContainer
+ foreground = scheme.onErrorContainer
+ }
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(background)
+ .padding(horizontal = 16.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Icon(imageVector = Lucide.CloudOff, contentDescription = null, tint = foreground)
+ Text(
+ text = bannerText(health, backOnline),
+ style = MaterialTheme.typography.bodyMedium,
+ color = foreground,
+ )
+ }
+}
+
+private fun bannerText(health: ServerHealth, backOnline: Boolean): String = when {
+ backOnline -> "Back online."
+ health == ServerHealth.Offline -> "No connection — check Wi-Fi or mobile data."
+ health == ServerHealth.ServerDown ->
+ "Server unreachable — your cached content is still available."
+ health == ServerHealth.Unstable -> "Reconnecting…"
+ else -> ""
+}
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt
index eb913061..e013a149 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt
@@ -54,6 +54,7 @@ import com.composables.icons.lucide.History
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.fabledsword.minstrel.api.ErrorCopy
+import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.home.data.HomeRepository
import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.library.widgets.AlbumCard
@@ -134,7 +135,7 @@ class HomeViewModel @Inject constructor(
private val libraryRepository: LibraryRepository,
private val player: com.fabledsword.minstrel.player.PlayerController,
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
- connectivity: com.fabledsword.minstrel.connectivity.ConnectivityObserver,
+ networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
) : ViewModel() {
private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus())
@@ -142,9 +143,13 @@ class HomeViewModel @Inject constructor(
/** System-playlist build status for the Home placeholder cards. */
val systemStatus: StateFlow = systemStatusInternal.asStateFlow()
- /** True when the device has no usable internet — gates the offline-pool cards. */
- val offline: StateFlow = connectivity.online
- .map { !it }
+ /**
+ * Cache-only when there's no link OR the server is unreachable. Reads the
+ * unified [NetworkStatusController] (not the raw device link) so Home reacts
+ * to ServerDown too; the transient Unstable state stays calm (not offline).
+ */
+ val offline: StateFlow = networkStatus.state
+ .map { it == ServerHealth.Offline || it == ServerHealth.ServerDown }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
@@ -701,18 +706,19 @@ private fun PlaylistsRow(
icon = iconForPool(item.kind),
onClick = { onPlayPool(item.kind) },
)
- is PlaylistRowItem.Real -> PlaylistCard(
- playlist = item.playlist,
- onClick = { onPlaylistClick(item.playlist.id) },
- onPlay = { onPlayPlaylist(item.playlist) },
- // Match Flutter: refreshable system playlists need
- // the live server endpoints, so disable their play
- // overlay when offline (user can still tap into the
- // detail and shuffle all from cache). User playlists
- // play from cache + survive offline.
- playEnabled = item.playlist.trackCount > 0 &&
- !(offline && item.playlist.refreshable),
- )
+ is PlaylistRowItem.Real -> {
+ // Greyed offline when the tile needs the live server or
+ // isn't fully cached — dimmed but still tappable into the
+ // detail to shuffle whatever subset is cached.
+ val greyed = offline && item.playlist.unavailableOffline
+ PlaylistCard(
+ playlist = item.playlist,
+ onClick = { onPlaylistClick(item.playlist.id) },
+ onPlay = { onPlayPlaylist(item.playlist) },
+ playEnabled = item.playlist.trackCount > 0 && !greyed,
+ greyed = greyed,
+ )
+ }
is PlaylistRowItem.Placeholder -> PlaylistPlaceholderCard(
label = item.label,
variant = item.variant,
@@ -728,7 +734,7 @@ private fun iconForPool(kind: OfflinePoolKind) = when (kind) {
}
/** A cache-backed offline pool, a real playlist tile, or a not-yet-generated slot. */
-private sealed interface PlaylistRowItem {
+internal sealed interface PlaylistRowItem {
data class OfflinePool(val kind: OfflinePoolKind) : PlaylistRowItem
data class Real(val playlist: PlaylistRef) : PlaylistRowItem
data class Placeholder(val label: String, val variant: String) : PlaylistRowItem
@@ -741,55 +747,81 @@ enum class OfflinePoolKind(val label: String) {
}
/**
- * Builds the Home Playlists row. When [offline], the two cache-backed
- * pool cards (Recently played, Liked) lead the row. Then For You +
- * Discover + 3× Songs-like fixed slots (real card when generated,
- * placeholder otherwise), then the secondary system kinds (deep cuts /
- * rediscover / new for you / on this day / first listens) when they
- * exist — no placeholders for these since they're conditional on
- * library shape, not guaranteed singletons. Finally user-owned
- * playlists.
+ * Builds the Home Playlists row.
+ *
+ * Online: For You + Discover + 3× Songs-like fixed slots (real card when
+ * generated, placeholder otherwise), then the secondary system kinds (deep cuts
+ * / rediscover / new for you / on this day / first listens) when they exist —
+ * no placeholders for these since they're conditional on library shape — then
+ * user-owned playlists.
+ *
+ * Offline: the two cache-backed pools (Recently played, Liked) lead, then the
+ * same real playlists in curated order but stably partitioned fully-cached
+ * first / greyed after, and the "building/pending" placeholders are dropped
+ * (they need the server to generate, so they're meaningless offline).
*
* Diverges from Flutter (`flutter_client/lib/library/home_screen.dart`
* `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
* surfaces the secondary kinds on Home. Operator authorized the
* divergence on 2026-06-01; web UI catch-up tracked as task #53.
*/
-private fun buildPlaylistsRow(
+internal fun buildPlaylistsRow(
owned: List,
status: SystemPlaylistsStatus,
offline: Boolean,
+): List {
+ if (!offline) return buildOnlineRow(owned, status)
+ val out = mutableListOf(
+ PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED),
+ PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED),
+ )
+ val (available, greyed) = orderedRealPlaylists(owned).partition { !it.unavailableOffline }
+ (available + greyed).forEach { out += PlaylistRowItem.Real(it) }
+ return out
+}
+
+/** The online layout: fixed system slots (with placeholders), secondary, user. */
+private fun buildOnlineRow(
+ owned: List,
+ status: SystemPlaylistsStatus,
): List {
val out = mutableListOf()
- if (offline) {
- out += PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED)
- out += PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED)
- }
out += owned.firstOrNull { it.systemVariant == "for_you" }
?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("For You", variantFor("for-you", status))
out += owned.firstOrNull { it.systemVariant == "discover" }
?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
- val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(3)
+ val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
for (i in 0 until SONGS_LIKE_SLOTS) {
out += songsLike.getOrNull(i)
?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
}
- // Secondary system kinds in server-registry order. Only included
- // when actually generated — these depend on library shape (Deep
- // cuts needs deep albums, On this day needs prior history, etc.)
- // so a missing one means "not enough data" rather than "still
- // building".
for (variant in SECONDARY_SYSTEM_VARIANTS) {
- owned.firstOrNull { it.systemVariant == variant }
- ?.let { out += PlaylistRowItem.Real(it) }
+ owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
}
owned.filter { it.systemVariant == null }.forEach { out += PlaylistRowItem.Real(it) }
return out
}
+/**
+ * Curated real-playlist order (system primaries, then secondary, then user).
+ * Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
+ * only differs by dropping placeholders + partitioning available-first.
+ */
+private fun orderedRealPlaylists(owned: List): List {
+ val out = mutableListOf()
+ owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
+ owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
+ out += owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
+ for (variant in SECONDARY_SYSTEM_VARIANTS) {
+ owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
+ }
+ owned.filter { it.systemVariant == null }.forEach { out += it }
+ return out
+}
+
private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
s.inFlight -> "building"
s.lastError != null -> "failed"
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt
index a4610c05..b690f911 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt
@@ -20,6 +20,8 @@ data class PlaylistRef(
val trackCount: Int = 0,
val coverUrl: String = "",
val ownerUsername: String = "",
+ /** All member tracks resident in the audio cache — playable fully offline. */
+ val fullyCached: Boolean = false,
) {
val isSystem: Boolean get() = systemVariant != null
@@ -30,6 +32,13 @@ data class PlaylistRef(
* expose a refresh trigger; rebuilds are scheduler-driven).
*/
val refreshable: Boolean get() = isSystem && systemVariant != "songs_like_artist"
+
+ /**
+ * Can't be relied on while offline — either it needs the live server to
+ * (re)generate (a refreshable system mix) or not all its tracks are cached.
+ * Callers combine this with the current offline state to grey the tile.
+ */
+ val unavailableOffline: Boolean get() = refreshable || !fullyCached
}
/**
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/OfflineGatedDataSource.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/OfflineGatedDataSource.kt
index 677a9033..314597f0 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/player/OfflineGatedDataSource.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/player/OfflineGatedDataSource.kt
@@ -3,14 +3,14 @@ package com.fabledsword.minstrel.player
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.TransferListener
+import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
-import com.fabledsword.minstrel.connectivity.ServerHealthController
import java.io.IOException
import java.io.InterruptedIOException
/**
* DataSource wrapper that fails the network read immediately when
- * [ServerHealthController] reports a non-Healthy state. CacheDataSource only
+ * [NetworkStatusController] reports a gating state. CacheDataSource only
* calls this upstream factory on cache misses, so playback of cached audio is
* unaffected -- only "tap a non-cached track while offline" hits this branch
* and gets a fast, meaningful error instead of a multi-second network timeout
@@ -22,10 +22,23 @@ import java.io.InterruptedIOException
*/
class OfflineGatedDataSource(
private val delegate: DataSource,
- private val health: ServerHealthController,
+ private val health: NetworkStatusController,
) : DataSource {
override fun open(dataSpec: DataSpec): Long {
+ gateOnHealth()
+ return try {
+ val opened = delegate.open(dataSpec)
+ health.reportSuccess() // bytes flowing from the server == reachable
+ opened
+ } catch (e: IOException) {
+ health.reportFailure() // real network read failed → arbitrate via /healthz
+ throw e
+ }
+ }
+
+ /** Fast-fail before touching the network when the server can't be reached. */
+ private fun gateOnHealth() {
when (health.state.value) {
ServerHealth.Offline -> throw OfflineException(
"Track not in the on-device cache and the device is offline.",
@@ -33,9 +46,9 @@ class OfflineGatedDataSource(
ServerHealth.ServerDown -> throw OfflineException(
"Track not in the on-device cache and the Minstrel server is unreachable.",
)
- ServerHealth.Healthy -> Unit
+ // Unstable is non-gating: still try the network. Healthy too.
+ ServerHealth.Unstable, ServerHealth.Healthy -> Unit
}
- return delegate.open(dataSpec)
}
override fun close() = delegate.close()
@@ -49,7 +62,7 @@ class OfflineGatedDataSource(
class OfflineGatedDataSourceFactory(
private val upstream: DataSource.Factory,
- private val health: ServerHealthController,
+ private val health: NetworkStatusController,
) : DataSource.Factory {
override fun createDataSource(): DataSource =
OfflineGatedDataSource(upstream.createDataSource(), health)
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlaybackErrorReporter.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlaybackErrorReporter.kt
index f423cc5f..24e6458d 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlaybackErrorReporter.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlaybackErrorReporter.kt
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.player
+import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
@@ -40,6 +41,7 @@ private const val DEBOUNCE_MS = 2_000L
class PlaybackErrorReporter @Inject constructor(
private val playerController: PlayerController,
private val repository: PlaybackErrorRepository,
+ private val networkStatus: NetworkStatusController,
@ApplicationScope private val scope: CoroutineScope,
) {
private val outChannel = Channel(Channel.BUFFERED)
@@ -52,6 +54,10 @@ class PlaybackErrorReporter @Inject constructor(
val buffer = mutableListOf()
var debounceJob: kotlinx.coroutines.Job? = null
playerController.playbackErrorEvents.collect { event ->
+ // A track failing to play is ambiguous (dead server vs. one bad
+ // file) — let the controller arbitrate via /healthz. No-op when
+ // already Offline; cheap otherwise.
+ networkStatus.reportFailure()
// Fire-and-forget the server report — repository handles
// success/queue branching so callers don't see throws.
scope.launch { repository.report(event) }
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt
index f3af0185..3420c498 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt
@@ -55,7 +55,7 @@ class PlayerFactory @Inject constructor(
private val cacheConfig: CacheConfig,
private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
- private val serverHealth: com.fabledsword.minstrel.connectivity.ServerHealthController,
+ private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
) {
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt
index 02bf1c26..b68db292 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt
@@ -7,6 +7,7 @@ import retrofit2.HttpException
import java.net.HttpURLConnection
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
+import com.fabledsword.minstrel.cache.db.dao.PlaylistCachedCount
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
import com.fabledsword.minstrel.cache.mutations.MutationQueue
@@ -18,6 +19,7 @@ import com.fabledsword.minstrel.models.wire.PlaylistTrackWire
import com.fabledsword.minstrel.models.wire.PlaylistWire
import com.fabledsword.minstrel.shared.resolveServerUrl
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import retrofit2.Retrofit
import retrofit2.create
@@ -51,9 +53,16 @@ class PlaylistsRepository @Inject constructor(
// ── Reads (Flow, cache-first; the cache is the source of truth) ──
- /** Owned + public; UI splits by isSystem / userId comparison. */
+ /**
+ * Owned + public; UI splits by isSystem / userId comparison. Joined with
+ * per-playlist cache counts so [PlaylistRef.fullyCached] drives the offline
+ * greying without each consumer re-querying the cache index.
+ */
fun observeAll(): Flow> =
- playlistDao.observeAll().map { rows -> rows.map { it.toDomain() } }
+ combine(
+ playlistDao.observeAll(),
+ playlistDao.observeCachedCounts(),
+ ) { rows, counts -> mergePlaylistsWithCache(rows, counts) }
/** User-owned playlists only (systemVariant IS NULL). */
fun observeUserPlaylists(): Flow> =
@@ -233,6 +242,23 @@ fun List.toPlayableTrackRefs(): List =
// ── Mappers (internal — keep Room + wire types out of the UI layer) ──
+/**
+ * Maps cached playlist rows to domain refs, stamping [PlaylistRef.fullyCached]
+ * from the cache-index counts. A playlist is fully cached when it has tracks
+ * and every member track is resident (`cachedCount >= trackCount`). Pulled out
+ * of the Flow so the predicate is unit-testable without a Room database.
+ */
+internal fun mergePlaylistsWithCache(
+ rows: List,
+ counts: List,
+): List {
+ val cachedById = counts.associate { it.playlistId to it.cachedCount }
+ return rows.map { row ->
+ val cached = cachedById[row.id] ?: 0
+ row.toDomain().copy(fullyCached = row.trackCount > 0 && cached >= row.trackCount)
+ }
+}
+
private fun CachedPlaylistEntity.toDomain(): PlaylistRef =
PlaylistRef(
id = id,
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt
index b5302718..14caa57b 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt
@@ -27,7 +27,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
-import com.fabledsword.minstrel.connectivity.ConnectivityObserver
+import com.fabledsword.minstrel.connectivity.NetworkStatusController
+import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.nav.PlaylistDetail
@@ -66,7 +67,7 @@ class PlaylistsListViewModel @Inject constructor(
private val repository: PlaylistsRepository,
private val player: PlayerController,
private val eventsStream: EventsStream,
- connectivity: ConnectivityObserver,
+ networkStatus: NetworkStatusController,
) : ViewModel() {
private val poolMessages = Channel(Channel.BUFFERED)
@@ -74,9 +75,9 @@ class PlaylistsListViewModel @Inject constructor(
/** Transient snackbar messages from playlist tile play taps. */
val transientMessages: Flow = poolMessages.receiveAsFlow()
- /** True when the device has no usable internet -- gates refreshable system tiles. */
- val offline: StateFlow = connectivity.online
- .map { !it }
+ /** Cache-only: no link OR server unreachable (Unstable stays calm). Greys tiles. */
+ val offline: StateFlow = networkStatus.state
+ .map { it == ServerHealth.Offline || it == ServerHealth.ServerDown }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
@@ -191,14 +192,15 @@ private fun PlaylistsGrid(
SectionHeader("System playlists")
}
items(items = systemPlaylists, key = { it.id }) { playlist ->
+ // Greyed offline when it needs the live server or isn't fully
+ // cached — dimmed but still tappable into the detail.
+ val greyed = offline && playlist.unavailableOffline
PlaylistCard(
playlist = playlist,
onClick = { onPlaylistClick(playlist.id) },
onPlay = { onPlay(playlist) },
- // Refreshable system tiles need server endpoints (systemShuffle);
- // disable when offline. Empty mixes show a snackbar after tap.
- playEnabled = playlist.trackCount > 0 &&
- !(offline && playlist.refreshable),
+ playEnabled = playlist.trackCount > 0 && !greyed,
+ greyed = greyed,
)
}
}
@@ -207,11 +209,13 @@ private fun PlaylistsGrid(
SectionHeader("Your playlists")
}
items(items = userPlaylists, key = { it.id }) { playlist ->
+ val greyed = offline && playlist.unavailableOffline
PlaylistCard(
playlist = playlist,
onClick = { onPlaylistClick(playlist.id) },
onPlay = { onPlay(playlist) },
- playEnabled = playlist.trackCount > 0,
+ playEnabled = playlist.trackCount > 0 && !greyed,
+ greyed = greyed,
)
}
}
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt
index da12d8e4..55ef6cfb 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt
@@ -15,6 +15,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -40,6 +41,10 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
* [playEnabled] disables the overlay (50% alpha + ignore taps) — Home
* uses it for system playlists in offline mode (their server-side
* shuffle endpoint is unreachable) and for empty playlists.
+ *
+ * [greyed] dims the whole tile (it can't be reliably played offline) while
+ * keeping it tappable — the detail screen is the escape hatch for shuffling
+ * whatever subset is cached. Greyed always disables the play overlay too.
*/
@Composable
fun PlaylistCard(
@@ -48,11 +53,13 @@ fun PlaylistCard(
modifier: Modifier = Modifier,
onPlay: (suspend () -> Unit)? = null,
playEnabled: Boolean = true,
+ greyed: Boolean = false,
) {
val seedCache = LocalDetailSeedCache.current
Surface(
modifier = modifier
.width(176.dp)
+ .alpha(if (greyed) GREYED_ALPHA else 1f) // dimmed, still clickable
.clickable {
seedCache.stashPlaylist(playlist)
onClick()
@@ -63,7 +70,7 @@ fun PlaylistCard(
PlaylistCardCover(
playlist = playlist,
onPlay = onPlay,
- playEnabled = playEnabled,
+ playEnabled = playEnabled && !greyed,
)
Spacer(Modifier.height(8.dp))
Text(
@@ -148,6 +155,7 @@ private fun VariantPill(label: String, modifier: Modifier = Modifier) {
}
private const val PILL_BG_ALPHA = 0.85f
+private const val GREYED_ALPHA = 0.45f
private fun subtitleFor(playlist: PlaylistRef): String = when {
playlist.trackCount > 0 -> "${playlist.trackCount} tracks"
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/search/data/SearchRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/search/data/SearchRepository.kt
index 5c6b5527..7785bbb3 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/search/data/SearchRepository.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/search/data/SearchRepository.kt
@@ -4,8 +4,8 @@ import com.fabledsword.minstrel.api.endpoints.SearchApi
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
+import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
-import com.fabledsword.minstrel.connectivity.ServerHealthController
import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.models.SearchResponseRef
import retrofit2.Retrofit
@@ -16,8 +16,8 @@ import javax.inject.Singleton
private const val LOCAL_SEARCH_LIMIT = 20
/**
- * Cache-first when offline. When [ServerHealthController] is Healthy the
- * repository hits `/api/search` and returns the server's three-facet
+ * Cache-first when offline. When [NetworkStatusController] reports Healthy or
+ * Unstable the repository hits `/api/search` and returns the server's three-facet
* paged response. When health is Offline or ServerDown it falls back to
* Room LIKE queries against `cached_*` so the user can still find
* something to play from what's already on the device; the outcome's
@@ -27,7 +27,7 @@ private const val LOCAL_SEARCH_LIMIT = 20
@Singleton
class SearchRepository @Inject constructor(
retrofit: Retrofit,
- private val serverHealth: ServerHealthController,
+ private val serverHealth: NetworkStatusController,
private val trackDao: CachedTrackDao,
private val albumDao: CachedAlbumDao,
private val artistDao: CachedArtistDao,
@@ -35,7 +35,8 @@ class SearchRepository @Inject constructor(
private val api: SearchApi = retrofit.create()
suspend fun search(query: String): SearchOutcome = when (serverHealth.state.value) {
- ServerHealth.Healthy -> SearchOutcome(remoteSearch(query), localOnly = false)
+ ServerHealth.Healthy, ServerHealth.Unstable ->
+ SearchOutcome(remoteSearch(query), localOnly = false)
ServerHealth.Offline, ServerHealth.ServerDown ->
SearchOutcome(localSearch(query), localOnly = true)
}
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/PullRefreshNetworkViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/PullRefreshNetworkViewModel.kt
new file mode 100644
index 00000000..af28b155
--- /dev/null
+++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/PullRefreshNetworkViewModel.kt
@@ -0,0 +1,21 @@
+package com.fabledsword.minstrel.shared.widgets
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import com.fabledsword.minstrel.connectivity.NetworkStatusController
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+
+/**
+ * Routes a deliberate pull-to-refresh into a /healthz recheck so the connection
+ * banner clears within seconds rather than waiting for the next poll — even on
+ * cache-only screens whose own refresh never touches the network. Backs
+ * [PullToRefreshScaffold].
+ */
+@HiltViewModel
+class PullRefreshNetworkViewModel @Inject constructor(
+ private val networkStatus: NetworkStatusController,
+ @Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
+) : ViewModel() {
+ fun recheck() = networkStatus.recheck()
+}
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/PullToRefreshScaffold.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/PullToRefreshScaffold.kt
index 4a83d0ed..b3fdefa2 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/PullToRefreshScaffold.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/PullToRefreshScaffold.kt
@@ -10,12 +10,15 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.launch
/**
* Wraps [content] in a Material3 PullToRefreshBox so the user can
* swipe-down to trigger [onRefresh]. The wrapper manages the
- * `isRefreshing` indicator while [onRefresh] is in flight.
+ * `isRefreshing` indicator while [onRefresh] is in flight. Every pull also
+ * fires a /healthz recheck (via [PullRefreshNetworkViewModel]) so a stale
+ * connection banner clears promptly on a deliberate user refresh.
*
* [onRefresh] is suspend: pass `{ viewModel.refresh().join() }` so the
* indicator hides exactly when the underlying coroutine completes,
@@ -32,6 +35,7 @@ import kotlinx.coroutines.launch
fun PullToRefreshScaffold(
onRefresh: suspend () -> Unit,
modifier: Modifier = Modifier,
+ netVm: PullRefreshNetworkViewModel = hiltViewModel(),
content: @Composable () -> Unit,
) {
var isRefreshing by remember { mutableStateOf(false) }
@@ -42,6 +46,7 @@ fun PullToRefreshScaffold(
scope.launch {
isRefreshing = true
try {
+ netVm.recheck() // deliberate pull → re-probe the server now
onRefresh()
} finally {
isRefreshing = false
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackRow.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackRow.kt
index a80c343b..4c708811 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackRow.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/TrackRow.kt
@@ -61,8 +61,11 @@ fun TrackRow(
trailing: @Composable RowScope.() -> Unit = {},
) {
val context = LocalContext.current
- val offlineUnavailable = LocalServerHealth.current != ServerHealth.Healthy &&
- trackId !in LocalCachedTrackIds.current
+ val health = LocalServerHealth.current
+ // Unstable is non-gating — only a real gating state dims uncached rows.
+ val offlineUnavailable =
+ (health == ServerHealth.Offline || health == ServerHealth.ServerDown) &&
+ trackId !in LocalCachedTrackIds.current
val titleColor = if (nowPlaying) {
MaterialTheme.colorScheme.primary
} else {
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/api/HealthzApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/api/HealthzApi.kt
index 50e68480..52c0c482 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/update/api/HealthzApi.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/update/api/HealthzApi.kt
@@ -7,8 +7,8 @@ import retrofit2.http.GET
/**
* Retrofit interface for `GET /healthz` — unauthenticated health probe
* returning the server's running version + the minimum client version
- * it'll talk to. Used by [com.fabledsword.minstrel.update.data.VersionCheckController]
- * to surface the VersionTooOld banner.
+ * it'll talk to. Polled by [com.fabledsword.minstrel.connectivity.NetworkStatusController]
+ * for both reachability and the VersionTooOld banner.
*/
interface HealthzApi {
@GET("healthz")
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt
deleted file mode 100644
index c8749d95..00000000
--- a/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionCheckController.kt
+++ /dev/null
@@ -1,112 +0,0 @@
-package com.fabledsword.minstrel.update.data
-
-import com.fabledsword.minstrel.BuildConfig
-import com.fabledsword.minstrel.di.ApplicationScope
-import com.fabledsword.minstrel.update.api.HealthzApi
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.launch
-import retrofit2.Retrofit
-import timber.log.Timber
-import javax.inject.Inject
-import javax.inject.Singleton
-
-private const val POLL_INTERVAL_MS = 5 * 60 * 1000L
-
-// Hysteresis on the reachable signal. Flipping internalReachable to false on
-// the very first /healthz failure produced two false positives:
-// 1. App startup — AuthStore.baseUrl loads from Room asynchronously, so the
-// first runOnce() can fire against AuthStore.DEFAULT_BASE_URL
-// ("http://localhost:8080") before the real server URL has hydrated.
-// 2. Deployments whose reverse proxy routes only /api/* to the Go server —
-// /healthz never reaches the handler, so the user sees a permanent
-// "Server unreachable" banner even though all real /api/* calls succeed.
-// Requiring 3 consecutive failures (~15 min at the 5-min poll cadence) ensures
-// the banner only fires on sustained, real unreachability — and a single
-// success at any point resets the counter so transient hiccups self-clear.
-private const val REACHABILITY_FAILURE_THRESHOLD = 3
-
-/**
- * Result of the most recent /healthz version-compatibility check.
- * `Skipped` means the server didn't include `min_client_version`
- * (partial deploy or older server) and the UI should not gate.
- */
-enum class VersionResult { OK, TOO_OLD, SKIPPED }
-
-/**
- * Polls /healthz periodically and exposes the current
- * [VersionResult] for the shell's VersionTooOld banner. Soft-fails
- * on network errors — keeps the last-known result rather than
- * showing a misleading "too old" on a connectivity blip.
- *
- * Constructed at app launch via the construct-the-singleton trick
- * in [com.fabledsword.minstrel.MinstrelApplication].
- */
-@Singleton
-class VersionCheckController @Inject constructor(
- @ApplicationScope private val scope: CoroutineScope,
- retrofit: Retrofit,
-) {
- private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
-
- private val internal = MutableStateFlow(VersionResult.SKIPPED)
- val result: StateFlow = internal.asStateFlow()
-
- // Whether the most recent /healthz poll reached the server. Separate from
- // VersionResult because "unreachable" and "version mismatch" drive
- // different UX (offline banner vs version-too-old banner). Optimistic
- // initial value -- the first poll fires within seconds of app launch and
- // we don't want a "server down" flash before we've actually tried.
- // Consumed by ServerHealthController to compose with ConnectivityObserver
- // for the tri-state offline / server-down / healthy signal.
- private val internalReachable = MutableStateFlow(true)
- val reachable: StateFlow = internalReachable.asStateFlow()
-
- private var consecutiveFailures = 0
-
- init {
- scope.launch {
- while (true) {
- runOnce()
- delay(POLL_INTERVAL_MS)
- }
- }
- }
-
- /** One-shot recheck, bypassing the poll cadence. Used by the banner's "Check now" button. */
- fun recheck() {
- scope.launch { runOnce() }
- }
-
- private suspend fun runOnce() {
- val outcome = runCatching { api.check() }
- val response = outcome.getOrNull()
- if (response == null) {
- consecutiveFailures++
- if (consecutiveFailures >= REACHABILITY_FAILURE_THRESHOLD &&
- internalReachable.value
- ) {
- Timber.w(
- "/healthz unreachable for %d consecutive polls — flipping reachable=false",
- consecutiveFailures,
- )
- internalReachable.value = false
- }
- return
- }
- if (!internalReachable.value) {
- Timber.i("/healthz recovered after %d failures", consecutiveFailures)
- }
- consecutiveFailures = 0
- internalReachable.value = true
- val min = response.minClientVersion
- internal.value = when {
- min.isEmpty() -> VersionResult.SKIPPED
- isVersionNewer(min, BuildConfig.VERSION_NAME) -> VersionResult.TOO_OLD
- else -> VersionResult.OK
- }
- }
-}
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionResult.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionResult.kt
new file mode 100644
index 00000000..58118a3f
--- /dev/null
+++ b/android/app/src/main/java/com/fabledsword/minstrel/update/data/VersionResult.kt
@@ -0,0 +1,8 @@
+package com.fabledsword.minstrel.update.data
+
+/**
+ * Result of the most recent /healthz version-compatibility check.
+ * `SKIPPED` means the server didn't include `min_client_version`
+ * (partial deploy or older server) and the UI should not gate.
+ */
+enum class VersionResult { OK, TOO_OLD, SKIPPED }
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/ui/VersionTooOldViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/VersionTooOldViewModel.kt
index 41f842e0..52874561 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/update/ui/VersionTooOldViewModel.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/VersionTooOldViewModel.kt
@@ -2,21 +2,21 @@ package com.fabledsword.minstrel.update.ui
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
-import com.fabledsword.minstrel.update.data.VersionCheckController
+import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.update.data.VersionResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
/**
- * Tiny VM that exposes the [VersionCheckController]'s current
+ * Tiny VM that exposes the [NetworkStatusController]'s current
* [VersionResult] plus its recheck trigger for the banner button.
*/
@HiltViewModel
class VersionTooOldViewModel @Inject constructor(
- private val controller: VersionCheckController,
+ private val controller: NetworkStatusController,
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
) : ViewModel() {
- val result: StateFlow = controller.result
+ val result: StateFlow = controller.versionResult
fun recheck() = controller.recheck()
}
diff --git a/android/app/src/test/java/com/fabledsword/minstrel/connectivity/ReachabilityMachineTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/connectivity/ReachabilityMachineTest.kt
new file mode 100644
index 00000000..430fd08c
--- /dev/null
+++ b/android/app/src/test/java/com/fabledsword/minstrel/connectivity/ReachabilityMachineTest.kt
@@ -0,0 +1,116 @@
+package com.fabledsword.minstrel.connectivity
+
+import org.junit.jupiter.api.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class ReachabilityMachineTest {
+
+ private fun machine() = ReachabilityMachine()
+
+ @Test
+ fun `starts healthy`() {
+ val m = machine()
+ m.onLinkChange(up = true)
+ assertEquals(ServerHealth.Healthy, m.health())
+ }
+
+ @Test
+ fun `no link is offline regardless of probes`() {
+ val m = machine()
+ m.onLinkChange(up = false)
+ m.onProbeFailure(nowMs = 1)
+ assertEquals(ServerHealth.Offline, m.health())
+ }
+
+ @Test
+ fun `single probe failure is unstable not down`() {
+ val m = machine()
+ m.onLinkChange(up = true)
+ m.onProbeFailure(nowMs = 1_000)
+ assertEquals(ServerHealth.Unstable, m.health())
+ }
+
+ @Test
+ fun `probe success from unstable recovers to healthy`() {
+ val m = machine()
+ m.onLinkChange(up = true)
+ m.onProbeFailure(nowMs = 1_000)
+ m.onSuccess()
+ assertEquals(ServerHealth.Healthy, m.health())
+ }
+
+ @Test
+ fun `op success from unstable recovers to healthy`() {
+ val m = machine()
+ m.onLinkChange(up = true)
+ m.onProbeFailure(nowMs = 1_000)
+ m.onSuccess() // a successful stream read / API 2xx is self-proving
+ assertEquals(ServerHealth.Healthy, m.health())
+ }
+
+ @Test
+ fun `two op failures plus a failed probe escalate immediately`() {
+ val m = machine()
+ m.onLinkChange(up = true)
+ m.onOpFailure(nowMs = 1_000)
+ m.onOpFailure(nowMs = 1_500) // corroboration reached
+ m.onProbeFailure(nowMs = 2_000) // probe agrees → fast ServerDown
+ assertEquals(ServerHealth.ServerDown, m.health())
+ }
+
+ @Test
+ fun `op failures with a successful probe stay healthy (track-specific)`() {
+ val m = machine()
+ m.onLinkChange(up = true)
+ m.onOpFailure(nowMs = 1_000)
+ m.onOpFailure(nowMs = 1_500)
+ m.onSuccess() // arbiter says server is fine
+ assertEquals(ServerHealth.Healthy, m.health())
+ }
+
+ @Test
+ fun `stale op failures do not corroborate`() {
+ val m = machine()
+ m.onLinkChange(up = true)
+ m.onOpFailure(nowMs = 0)
+ m.onOpFailure(nowMs = 1_000)
+ // both op failures are now older than the corroboration window:
+ m.onProbeFailure(nowMs = 1_000 + CORROBORATION_WINDOW_MS + 1)
+ assertEquals(ServerHealth.Unstable, m.health()) // not enough fresh corroboration
+ }
+
+ @Test
+ fun `sustained failure backstop escalates after the window`() {
+ val m = machine()
+ m.onLinkChange(up = true)
+ m.onProbeFailure(nowMs = 1_000) // unstable, streak starts
+ m.onProbeFailure(nowMs = 1_000 + ESCALATE_AFTER_MS) // sustained ≥ backstop
+ assertEquals(ServerHealth.ServerDown, m.health())
+ }
+
+ @Test
+ fun `link restored keeps last-known down until a fresh result`() {
+ val m = machine()
+ m.onLinkChange(up = true)
+ m.onProbeFailure(nowMs = 1_000)
+ m.onProbeFailure(nowMs = 1_000 + ESCALATE_AFTER_MS) // ServerDown
+ m.onLinkChange(up = false)
+ assertEquals(ServerHealth.Offline, m.health())
+ m.onLinkChange(up = true)
+ // link back but no fresh probe result yet — last known reachability was down:
+ assertEquals(ServerHealth.ServerDown, m.health())
+ m.onSuccess()
+ assertEquals(ServerHealth.Healthy, m.health())
+ }
+
+ @Test
+ fun `unstable does not gate — it is not offline or serverdown`() {
+ val m = machine()
+ m.onLinkChange(up = true)
+ m.onProbeFailure(nowMs = 1_000)
+ val health = m.health()
+ assertTrue(health != ServerHealth.Offline && health != ServerHealth.ServerDown)
+ assertEquals(ServerHealth.Unstable, health)
+ }
+}
diff --git a/android/app/src/test/java/com/fabledsword/minstrel/home/ui/BuildPlaylistsRowTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/home/ui/BuildPlaylistsRowTest.kt
new file mode 100644
index 00000000..ed09e992
--- /dev/null
+++ b/android/app/src/test/java/com/fabledsword/minstrel/home/ui/BuildPlaylistsRowTest.kt
@@ -0,0 +1,59 @@
+package com.fabledsword.minstrel.home.ui
+
+import com.fabledsword.minstrel.models.PlaylistRef
+import com.fabledsword.minstrel.models.SystemPlaylistsStatus
+import org.junit.jupiter.api.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class BuildPlaylistsRowTest {
+
+ private fun user(id: String, cached: Boolean) =
+ PlaylistRef(id = id, userId = "u", name = id, trackCount = 1, fullyCached = cached)
+
+ @Test
+ fun `offline row leads with pools then available before greyed`() {
+ val owned = listOf(user("partial", cached = false), user("full", cached = true))
+ val row = buildPlaylistsRow(owned, SystemPlaylistsStatus(), offline = true)
+
+ assertTrue(row[0] is PlaylistRowItem.OfflinePool)
+ assertTrue(row[1] is PlaylistRowItem.OfflinePool)
+ // Fully-cached "full" comes before the greyed "partial".
+ val reals = row.filterIsInstance().map { it.playlist.id }
+ assertEquals(listOf("full", "partial"), reals)
+ }
+
+ @Test
+ fun `offline greys a refreshable system playlist even when fully cached`() {
+ val forYou = PlaylistRef(
+ id = "fy",
+ userId = "u",
+ name = "For You",
+ systemVariant = "for_you",
+ trackCount = 1,
+ fullyCached = true,
+ )
+ val row = buildPlaylistsRow(
+ listOf(forYou, user("u1", cached = true)),
+ SystemPlaylistsStatus(),
+ offline = true,
+ )
+ // The fully-cached user playlist is available; the refreshable system
+ // mix needs the server, so it greys out and sorts after.
+ val reals = row.filterIsInstance().map { it.playlist.id }
+ assertEquals(listOf("u1", "fy"), reals)
+ }
+
+ @Test
+ fun `offline row drops building-pending placeholders`() {
+ val row = buildPlaylistsRow(emptyList(), SystemPlaylistsStatus(), offline = true)
+ assertTrue(row.none { it is PlaylistRowItem.Placeholder })
+ }
+
+ @Test
+ fun `online row has no offline pools and keeps system-slot placeholders`() {
+ val row = buildPlaylistsRow(emptyList(), SystemPlaylistsStatus(), offline = false)
+ assertTrue(row.none { it is PlaylistRowItem.OfflinePool })
+ assertTrue(row.any { it is PlaylistRowItem.Placeholder })
+ }
+}
diff --git a/android/app/src/test/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepositoryCacheMergeTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepositoryCacheMergeTest.kt
new file mode 100644
index 00000000..b93d938e
--- /dev/null
+++ b/android/app/src/test/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepositoryCacheMergeTest.kt
@@ -0,0 +1,64 @@
+package com.fabledsword.minstrel.playlists.data
+
+import com.fabledsword.minstrel.cache.db.dao.PlaylistCachedCount
+import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
+import org.junit.jupiter.api.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class PlaylistsRepositoryCacheMergeTest {
+
+ private fun playlist(id: String, trackCount: Int) =
+ CachedPlaylistEntity(id = id, userId = "u", name = id, trackCount = trackCount)
+
+ @Test
+ fun `fully cached when every member track is resident`() {
+ val out = mergePlaylistsWithCache(
+ rows = listOf(playlist("p", trackCount = 3)),
+ counts = listOf(PlaylistCachedCount("p", cachedCount = 3)),
+ )
+ assertTrue(out.single().fullyCached)
+ }
+
+ @Test
+ fun `not fully cached when only some tracks are resident`() {
+ val out = mergePlaylistsWithCache(
+ rows = listOf(playlist("p", trackCount = 3)),
+ counts = listOf(PlaylistCachedCount("p", cachedCount = 2)),
+ )
+ assertFalse(out.single().fullyCached)
+ }
+
+ @Test
+ fun `not fully cached when no tracks are resident (missing count row)`() {
+ val out = mergePlaylistsWithCache(
+ rows = listOf(playlist("p", trackCount = 3)),
+ counts = emptyList(),
+ )
+ assertFalse(out.single().fullyCached)
+ }
+
+ @Test
+ fun `empty playlist is never fully cached`() {
+ val out = mergePlaylistsWithCache(
+ rows = listOf(playlist("p", trackCount = 0)),
+ counts = listOf(PlaylistCachedCount("p", cachedCount = 0)),
+ )
+ assertFalse(out.single().fullyCached)
+ }
+
+ @Test
+ fun `each playlist gets its own cache verdict`() {
+ val out = mergePlaylistsWithCache(
+ rows = listOf(playlist("full", 2), playlist("partial", 2)),
+ counts = listOf(
+ PlaylistCachedCount("full", cachedCount = 2),
+ PlaylistCachedCount("partial", cachedCount = 1),
+ ),
+ )
+ val byId = out.associateBy { it.id }
+ assertEquals(true, byId.getValue("full").fullyCached)
+ assertEquals(false, byId.getValue("partial").fullyCached)
+ }
+}