Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11466e1525 | |||
| e185b36138 | |||
| 31d8c30dfe | |||
| 69ccd7b25d | |||
| 8b77c6be97 | |||
| c33ef18a50 | |||
| 4c9450c117 | |||
| b467cb7532 | |||
| c78bbb7ba5 |
Generated
+13
@@ -1754,6 +1754,19 @@
|
|||||||
<option name="screenX" value="1600" />
|
<option name="screenX" value="1600" />
|
||||||
<option name="screenY" value="2560" />
|
<option name="screenY" value="2560" />
|
||||||
</PersistentDeviceSelectionData>
|
</PersistentDeviceSelectionData>
|
||||||
|
<PersistentDeviceSelectionData>
|
||||||
|
<option name="api" value="36" />
|
||||||
|
<option name="brand" value="google" />
|
||||||
|
<option name="codename" value="tangorpro" />
|
||||||
|
<option name="formFactor" value="Tablet" />
|
||||||
|
<option name="id" value="tangorpro" />
|
||||||
|
<option name="labId" value="google" />
|
||||||
|
<option name="manufacturer" value="Google" />
|
||||||
|
<option name="name" value="Pixel Tablet" />
|
||||||
|
<option name="screenDensity" value="320" />
|
||||||
|
<option name="screenX" value="1600" />
|
||||||
|
<option name="screenY" value="2560" />
|
||||||
|
</PersistentDeviceSelectionData>
|
||||||
<PersistentDeviceSelectionData>
|
<PersistentDeviceSelectionData>
|
||||||
<option name="api" value="35" />
|
<option name="api" value="35" />
|
||||||
<option name="brand" value="google" />
|
<option name="brand" value="google" />
|
||||||
|
|||||||
Generated
-1
@@ -1,4 +1,3 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||||
|
|||||||
@@ -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')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
|
|||||||
import com.fabledsword.minstrel.cache.CachedTrackIds
|
import com.fabledsword.minstrel.cache.CachedTrackIds
|
||||||
import com.fabledsword.minstrel.connectivity.LocalServerHealth
|
import com.fabledsword.minstrel.connectivity.LocalServerHealth
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
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.DetailSeedCache
|
||||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||||
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
||||||
@@ -41,7 +41,7 @@ import javax.inject.Inject
|
|||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@Inject lateinit var seedCache: DetailSeedCache
|
@Inject lateinit var seedCache: DetailSeedCache
|
||||||
@Inject lateinit var cachedTrackIds: CachedTrackIds
|
@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
|
// Flipped to true when the user taps the media notification (or
|
||||||
// any other entry point that asks for the full player). The App
|
// any other entry point that asks for the full player). The App
|
||||||
@@ -91,7 +91,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
private fun App(
|
private fun App(
|
||||||
seedCache: DetailSeedCache,
|
seedCache: DetailSeedCache,
|
||||||
cachedTrackIds: CachedTrackIds,
|
cachedTrackIds: CachedTrackIds,
|
||||||
serverHealth: ServerHealthController,
|
serverHealth: NetworkStatusController,
|
||||||
pendingOpenNowPlaying: StateFlow<Boolean>,
|
pendingOpenNowPlaying: StateFlow<Boolean>,
|
||||||
onOpenedNowPlaying: () -> Unit,
|
onOpenedNowPlaying: () -> Unit,
|
||||||
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ import com.fabledsword.minstrel.player.PlayEventsReporter
|
|||||||
import com.fabledsword.minstrel.player.PlaybackErrorReporter
|
import com.fabledsword.minstrel.player.PlaybackErrorReporter
|
||||||
import com.fabledsword.minstrel.player.ResumeController
|
import com.fabledsword.minstrel.player.ResumeController
|
||||||
import com.fabledsword.minstrel.update.data.UpdateBannerController
|
import com.fabledsword.minstrel.update.data.UpdateBannerController
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
import com.fabledsword.minstrel.update.data.VersionCheckController
|
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -115,21 +114,13 @@ class MinstrelApplication :
|
|||||||
@Suppress("unused") @Inject lateinit var audioPrefetcher: AudioPrefetcher
|
@Suppress("unused") @Inject lateinit var audioPrefetcher: AudioPrefetcher
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same construct-the-singleton trick — VersionCheckController's
|
* Same construct-the-singleton trick — NetworkStatusController owns the
|
||||||
* init block starts a 5-min poll loop against /healthz so the
|
* /healthz poll loop + the device-link collector + the reachability state
|
||||||
* shell-level VersionTooOldBanner can surface min_client_version
|
* machine, and is the single authority on the tri-state ServerHealth
|
||||||
* mismatches without waiting for the next user-driven request.
|
* 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
|
@Suppress("unused") @Inject lateinit var networkStatusController: NetworkStatusController
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same construct-the-singleton trick — UpdateBannerController polls
|
* Same construct-the-singleton trick — UpdateBannerController polls
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.fabledsword.minstrel.api
|
package com.fabledsword.minstrel.api
|
||||||
|
|
||||||
import com.fabledsword.minstrel.BuildConfig
|
import com.fabledsword.minstrel.BuildConfig
|
||||||
|
import com.fabledsword.minstrel.connectivity.ReachabilityReportingInterceptor
|
||||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
@@ -45,9 +46,15 @@ object NetworkModule {
|
|||||||
fun provideOkHttp(
|
fun provideOkHttp(
|
||||||
baseUrl: BaseUrlInterceptor,
|
baseUrl: BaseUrlInterceptor,
|
||||||
auth: AuthCookieInterceptor,
|
auth: AuthCookieInterceptor,
|
||||||
|
reachability: ReachabilityReportingInterceptor,
|
||||||
logging: HttpLoggingInterceptor,
|
logging: HttpLoggingInterceptor,
|
||||||
): OkHttpClient =
|
): OkHttpClient =
|
||||||
OkHttpClient.Builder()
|
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.
|
// AuthCookieInterceptor MUST run before BaseUrlInterceptor.
|
||||||
// Both scope on `host == PLACEHOLDER_HOST` to distinguish
|
// Both scope on `host == PLACEHOLDER_HOST` to distinguish
|
||||||
// Minstrel-server requests from external image fetches
|
// Minstrel-server requests from external image fetches
|
||||||
|
|||||||
+14
@@ -31,6 +31,20 @@ interface CachedPlaylistDao {
|
|||||||
@Query("SELECT * FROM cached_playlists WHERE id = :id")
|
@Query("SELECT * FROM cached_playlists WHERE id = :id")
|
||||||
suspend fun getById(id: String): CachedPlaylistEntity?
|
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<List<PlaylistCachedCount>>
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun upsertAll(rows: List<CachedPlaylistEntity>)
|
suspend fun upsertAll(rows: List<CachedPlaylistEntity>)
|
||||||
|
|
||||||
|
|||||||
+11
@@ -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,
|
||||||
|
)
|
||||||
+1
-1
@@ -29,7 +29,7 @@ import javax.inject.Singleton
|
|||||||
* the app to Offline with no debounce and fast-failed in-flight playback
|
* the app to Offline with no debounce and fast-failed in-flight playback
|
||||||
* via [com.fabledsword.minstrel.player.OfflineGatedDataSource]. The
|
* via [com.fabledsword.minstrel.player.OfflineGatedDataSource]. The
|
||||||
* authority on whether *Minstrel* is reachable is the `/healthz` poll
|
* 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.
|
* has its own failure hysteresis), not this coarse device-link signal.
|
||||||
*
|
*
|
||||||
* Used by the shell-level ConnectionErrorBanner; downstream
|
* Used by the shell-level ConnectionErrorBanner; downstream
|
||||||
|
|||||||
+176
@@ -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<ServerHealth> = stateInternal.asStateFlow()
|
||||||
|
|
||||||
|
private val versionInternal = MutableStateFlow(VersionResult.SKIPPED)
|
||||||
|
val versionResult: StateFlow<VersionResult> = 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<Intent>(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 }
|
||||||
+88
@@ -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<Long>()
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -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<NetworkStatusController>,
|
||||||
|
) : 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
-75
@@ -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<ServerHealth> = 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 }
|
|
||||||
+81
-43
@@ -14,83 +14,121 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.composables.icons.lucide.CloudOff
|
import com.composables.icons.lucide.CloudOff
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import javax.inject.Inject
|
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
|
* Exposes [NetworkStatusController]'s tri-state directly to the banner. No
|
||||||
* composable. Keeps the banner pure-presentation.
|
* re-wrapping StateFlow — the controller's is already app-scoped and warm.
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ConnectivityBannerViewModel @Inject constructor(
|
class ConnectivityBannerViewModel @Inject constructor(
|
||||||
health: ServerHealthController,
|
networkStatus: NetworkStatusController,
|
||||||
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
|
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val health: StateFlow<ServerHealth> = health.state.stateIn(
|
val health: StateFlow<ServerHealth> = networkStatus.state
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(HEALTH_SHARE_STOP_TIMEOUT_MS),
|
|
||||||
initialValue = ServerHealth.Healthy,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Banner shown at the top of the shell when the user can't reach the server.
|
* Shell banner. Tells the user *why* they're degraded — no link vs server-down
|
||||||
* Tri-state so we tell the user *why*: no device network vs server-down.
|
* — plus a non-alarming "Reconnecting…" for the transient [ServerHealth.Unstable]
|
||||||
* Copy choices match the Flutter analogues. Auto-hides via slide+fade when
|
* window, and a brief "Back online" confirmation when health recovers so
|
||||||
* health returns to [ServerHealth.Healthy].
|
* recovery is unmistakable. Sentence case, understated voice (design system).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ConnectionErrorBanner(
|
fun ConnectionErrorBanner(
|
||||||
viewModel: ConnectivityBannerViewModel = hiltViewModel(),
|
viewModel: ConnectivityBannerViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val health by viewModel.health.collectAsStateWithLifecycle()
|
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(
|
AnimatedVisibility(
|
||||||
visible = health != ServerHealth.Healthy,
|
visible = health != ServerHealth.Healthy || showBackOnline,
|
||||||
enter = expandVertically() + fadeIn(),
|
enter = expandVertically() + fadeIn(),
|
||||||
exit = shrinkVertically() + fadeOut(),
|
exit = shrinkVertically() + fadeOut(),
|
||||||
) {
|
) {
|
||||||
Row(
|
BannerContent(health = health, backOnline = showBackOnline)
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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 -> ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import com.composables.icons.lucide.History
|
|||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.Music
|
import com.composables.icons.lucide.Music
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
import com.fabledsword.minstrel.home.data.HomeRepository
|
import com.fabledsword.minstrel.home.data.HomeRepository
|
||||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||||
@@ -134,7 +135,7 @@ class HomeViewModel @Inject constructor(
|
|||||||
private val libraryRepository: LibraryRepository,
|
private val libraryRepository: LibraryRepository,
|
||||||
private val player: com.fabledsword.minstrel.player.PlayerController,
|
private val player: com.fabledsword.minstrel.player.PlayerController,
|
||||||
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
|
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
|
||||||
connectivity: com.fabledsword.minstrel.connectivity.ConnectivityObserver,
|
networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus())
|
private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus())
|
||||||
@@ -142,9 +143,13 @@ class HomeViewModel @Inject constructor(
|
|||||||
/** System-playlist build status for the Home placeholder cards. */
|
/** System-playlist build status for the Home placeholder cards. */
|
||||||
val systemStatus: StateFlow<SystemPlaylistsStatus> = systemStatusInternal.asStateFlow()
|
val systemStatus: StateFlow<SystemPlaylistsStatus> = systemStatusInternal.asStateFlow()
|
||||||
|
|
||||||
/** True when the device has no usable internet — gates the offline-pool cards. */
|
/**
|
||||||
val offline: StateFlow<Boolean> = connectivity.online
|
* Cache-only when there's no link OR the server is unreachable. Reads the
|
||||||
.map { !it }
|
* unified [NetworkStatusController] (not the raw device link) so Home reacts
|
||||||
|
* to ServerDown too; the transient Unstable state stays calm (not offline).
|
||||||
|
*/
|
||||||
|
val offline: StateFlow<Boolean> = networkStatus.state
|
||||||
|
.map { it == ServerHealth.Offline || it == ServerHealth.ServerDown }
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||||
@@ -701,18 +706,19 @@ private fun PlaylistsRow(
|
|||||||
icon = iconForPool(item.kind),
|
icon = iconForPool(item.kind),
|
||||||
onClick = { onPlayPool(item.kind) },
|
onClick = { onPlayPool(item.kind) },
|
||||||
)
|
)
|
||||||
is PlaylistRowItem.Real -> PlaylistCard(
|
is PlaylistRowItem.Real -> {
|
||||||
playlist = item.playlist,
|
// Greyed offline when the tile needs the live server or
|
||||||
onClick = { onPlaylistClick(item.playlist.id) },
|
// isn't fully cached — dimmed but still tappable into the
|
||||||
onPlay = { onPlayPlaylist(item.playlist) },
|
// detail to shuffle whatever subset is cached.
|
||||||
// Match Flutter: refreshable system playlists need
|
val greyed = offline && item.playlist.unavailableOffline
|
||||||
// the live server endpoints, so disable their play
|
PlaylistCard(
|
||||||
// overlay when offline (user can still tap into the
|
playlist = item.playlist,
|
||||||
// detail and shuffle all from cache). User playlists
|
onClick = { onPlaylistClick(item.playlist.id) },
|
||||||
// play from cache + survive offline.
|
onPlay = { onPlayPlaylist(item.playlist) },
|
||||||
playEnabled = item.playlist.trackCount > 0 &&
|
playEnabled = item.playlist.trackCount > 0 && !greyed,
|
||||||
!(offline && item.playlist.refreshable),
|
greyed = greyed,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
is PlaylistRowItem.Placeholder -> PlaylistPlaceholderCard(
|
is PlaylistRowItem.Placeholder -> PlaylistPlaceholderCard(
|
||||||
label = item.label,
|
label = item.label,
|
||||||
variant = item.variant,
|
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. */
|
/** 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 OfflinePool(val kind: OfflinePoolKind) : PlaylistRowItem
|
||||||
data class Real(val playlist: PlaylistRef) : PlaylistRowItem
|
data class Real(val playlist: PlaylistRef) : PlaylistRowItem
|
||||||
data class Placeholder(val label: String, val variant: String) : 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
|
* Builds the Home Playlists row.
|
||||||
* pool cards (Recently played, Liked) lead the row. Then For You +
|
*
|
||||||
* Discover + 3× Songs-like fixed slots (real card when generated,
|
* Online: For You + Discover + 3× Songs-like fixed slots (real card when
|
||||||
* placeholder otherwise), then the secondary system kinds (deep cuts /
|
* generated, placeholder otherwise), then the secondary system kinds (deep cuts
|
||||||
* rediscover / new for you / on this day / first listens) when they
|
* / rediscover / new for you / on this day / first listens) when they exist —
|
||||||
* exist — no placeholders for these since they're conditional on
|
* no placeholders for these since they're conditional on library shape — then
|
||||||
* library shape, not guaranteed singletons. Finally user-owned
|
* user-owned playlists.
|
||||||
* 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`
|
* Diverges from Flutter (`flutter_client/lib/library/home_screen.dart`
|
||||||
* `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
|
* `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
|
||||||
* surfaces the secondary kinds on Home. Operator authorized the
|
* surfaces the secondary kinds on Home. Operator authorized the
|
||||||
* divergence on 2026-06-01; web UI catch-up tracked as task #53.
|
* divergence on 2026-06-01; web UI catch-up tracked as task #53.
|
||||||
*/
|
*/
|
||||||
private fun buildPlaylistsRow(
|
internal fun buildPlaylistsRow(
|
||||||
owned: List<PlaylistRef>,
|
owned: List<PlaylistRef>,
|
||||||
status: SystemPlaylistsStatus,
|
status: SystemPlaylistsStatus,
|
||||||
offline: Boolean,
|
offline: Boolean,
|
||||||
|
): List<PlaylistRowItem> {
|
||||||
|
if (!offline) return buildOnlineRow(owned, status)
|
||||||
|
val out = mutableListOf<PlaylistRowItem>(
|
||||||
|
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<PlaylistRef>,
|
||||||
|
status: SystemPlaylistsStatus,
|
||||||
): List<PlaylistRowItem> {
|
): List<PlaylistRowItem> {
|
||||||
val out = mutableListOf<PlaylistRowItem>()
|
val out = mutableListOf<PlaylistRowItem>()
|
||||||
if (offline) {
|
|
||||||
out += PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED)
|
|
||||||
out += PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED)
|
|
||||||
}
|
|
||||||
out += owned.firstOrNull { it.systemVariant == "for_you" }
|
out += owned.firstOrNull { it.systemVariant == "for_you" }
|
||||||
?.let { PlaylistRowItem.Real(it) }
|
?.let { PlaylistRowItem.Real(it) }
|
||||||
?: PlaylistRowItem.Placeholder("For You", variantFor("for-you", status))
|
?: PlaylistRowItem.Placeholder("For You", variantFor("for-you", status))
|
||||||
out += owned.firstOrNull { it.systemVariant == "discover" }
|
out += owned.firstOrNull { it.systemVariant == "discover" }
|
||||||
?.let { PlaylistRowItem.Real(it) }
|
?.let { PlaylistRowItem.Real(it) }
|
||||||
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
|
?: 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) {
|
for (i in 0 until SONGS_LIKE_SLOTS) {
|
||||||
out += songsLike.getOrNull(i)
|
out += songsLike.getOrNull(i)
|
||||||
?.let { PlaylistRowItem.Real(it) }
|
?.let { PlaylistRowItem.Real(it) }
|
||||||
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
?: 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) {
|
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||||
owned.firstOrNull { it.systemVariant == variant }
|
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
|
||||||
?.let { out += PlaylistRowItem.Real(it) }
|
|
||||||
}
|
}
|
||||||
owned.filter { it.systemVariant == null }.forEach { out += PlaylistRowItem.Real(it) }
|
owned.filter { it.systemVariant == null }.forEach { out += PlaylistRowItem.Real(it) }
|
||||||
return out
|
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<PlaylistRef>): List<PlaylistRef> {
|
||||||
|
val out = mutableListOf<PlaylistRef>()
|
||||||
|
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 {
|
private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
|
||||||
s.inFlight -> "building"
|
s.inFlight -> "building"
|
||||||
s.lastError != null -> "failed"
|
s.lastError != null -> "failed"
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ data class PlaylistRef(
|
|||||||
val trackCount: Int = 0,
|
val trackCount: Int = 0,
|
||||||
val coverUrl: String = "",
|
val coverUrl: String = "",
|
||||||
val ownerUsername: 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
|
val isSystem: Boolean get() = systemVariant != null
|
||||||
|
|
||||||
@@ -30,6 +32,13 @@ data class PlaylistRef(
|
|||||||
* expose a refresh trigger; rebuilds are scheduler-driven).
|
* expose a refresh trigger; rebuilds are scheduler-driven).
|
||||||
*/
|
*/
|
||||||
val refreshable: Boolean get() = isSystem && systemVariant != "songs_like_artist"
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+19
-6
@@ -3,14 +3,14 @@ package com.fabledsword.minstrel.player
|
|||||||
import androidx.media3.datasource.DataSource
|
import androidx.media3.datasource.DataSource
|
||||||
import androidx.media3.datasource.DataSpec
|
import androidx.media3.datasource.DataSpec
|
||||||
import androidx.media3.datasource.TransferListener
|
import androidx.media3.datasource.TransferListener
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InterruptedIOException
|
import java.io.InterruptedIOException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DataSource wrapper that fails the network read immediately when
|
* 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
|
* 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
|
* 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
|
* and gets a fast, meaningful error instead of a multi-second network timeout
|
||||||
@@ -22,10 +22,23 @@ import java.io.InterruptedIOException
|
|||||||
*/
|
*/
|
||||||
class OfflineGatedDataSource(
|
class OfflineGatedDataSource(
|
||||||
private val delegate: DataSource,
|
private val delegate: DataSource,
|
||||||
private val health: ServerHealthController,
|
private val health: NetworkStatusController,
|
||||||
) : DataSource {
|
) : DataSource {
|
||||||
|
|
||||||
override fun open(dataSpec: DataSpec): Long {
|
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) {
|
when (health.state.value) {
|
||||||
ServerHealth.Offline -> throw OfflineException(
|
ServerHealth.Offline -> throw OfflineException(
|
||||||
"Track not in the on-device cache and the device is offline.",
|
"Track not in the on-device cache and the device is offline.",
|
||||||
@@ -33,9 +46,9 @@ class OfflineGatedDataSource(
|
|||||||
ServerHealth.ServerDown -> throw OfflineException(
|
ServerHealth.ServerDown -> throw OfflineException(
|
||||||
"Track not in the on-device cache and the Minstrel server is unreachable.",
|
"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()
|
override fun close() = delegate.close()
|
||||||
@@ -49,7 +62,7 @@ class OfflineGatedDataSource(
|
|||||||
|
|
||||||
class OfflineGatedDataSourceFactory(
|
class OfflineGatedDataSourceFactory(
|
||||||
private val upstream: DataSource.Factory,
|
private val upstream: DataSource.Factory,
|
||||||
private val health: ServerHealthController,
|
private val health: NetworkStatusController,
|
||||||
) : DataSource.Factory {
|
) : DataSource.Factory {
|
||||||
override fun createDataSource(): DataSource =
|
override fun createDataSource(): DataSource =
|
||||||
OfflineGatedDataSource(upstream.createDataSource(), health)
|
OfflineGatedDataSource(upstream.createDataSource(), health)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.fabledsword.minstrel.player
|
package com.fabledsword.minstrel.player
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
@@ -40,6 +41,7 @@ private const val DEBOUNCE_MS = 2_000L
|
|||||||
class PlaybackErrorReporter @Inject constructor(
|
class PlaybackErrorReporter @Inject constructor(
|
||||||
private val playerController: PlayerController,
|
private val playerController: PlayerController,
|
||||||
private val repository: PlaybackErrorRepository,
|
private val repository: PlaybackErrorRepository,
|
||||||
|
private val networkStatus: NetworkStatusController,
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
) {
|
) {
|
||||||
private val outChannel = Channel<String>(Channel.BUFFERED)
|
private val outChannel = Channel<String>(Channel.BUFFERED)
|
||||||
@@ -52,6 +54,10 @@ class PlaybackErrorReporter @Inject constructor(
|
|||||||
val buffer = mutableListOf<String>()
|
val buffer = mutableListOf<String>()
|
||||||
var debounceJob: kotlinx.coroutines.Job? = null
|
var debounceJob: kotlinx.coroutines.Job? = null
|
||||||
playerController.playbackErrorEvents.collect { event ->
|
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
|
// Fire-and-forget the server report — repository handles
|
||||||
// success/queue branching so callers don't see throws.
|
// success/queue branching so callers don't see throws.
|
||||||
scope.launch { repository.report(event) }
|
scope.launch { repository.report(event) }
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class PlayerFactory @Inject constructor(
|
|||||||
private val cacheConfig: CacheConfig,
|
private val cacheConfig: CacheConfig,
|
||||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
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() }
|
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
|
||||||
|
|
||||||
|
|||||||
+28
-2
@@ -7,6 +7,7 @@ import retrofit2.HttpException
|
|||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
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.CachedPlaylistEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
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.models.wire.PlaylistWire
|
||||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.create
|
import retrofit2.create
|
||||||
@@ -51,9 +53,16 @@ class PlaylistsRepository @Inject constructor(
|
|||||||
|
|
||||||
// ── Reads (Flow, cache-first; the cache is the source of truth) ──
|
// ── 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<List<PlaylistRef>> =
|
fun observeAll(): Flow<List<PlaylistRef>> =
|
||||||
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). */
|
/** User-owned playlists only (systemVariant IS NULL). */
|
||||||
fun observeUserPlaylists(): Flow<List<PlaylistRef>> =
|
fun observeUserPlaylists(): Flow<List<PlaylistRef>> =
|
||||||
@@ -233,6 +242,23 @@ fun List<PlaylistTrackRef>.toPlayableTrackRefs(): List<TrackRef> =
|
|||||||
|
|
||||||
// ── Mappers (internal — keep Room + wire types out of the UI layer) ──
|
// ── 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<CachedPlaylistEntity>,
|
||||||
|
counts: List<PlaylistCachedCount>,
|
||||||
|
): List<PlaylistRef> {
|
||||||
|
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 =
|
private fun CachedPlaylistEntity.toDomain(): PlaylistRef =
|
||||||
PlaylistRef(
|
PlaylistRef(
|
||||||
id = id,
|
id = id,
|
||||||
|
|||||||
+14
-10
@@ -27,7 +27,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.NavHostController
|
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.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.PlaylistRef
|
import com.fabledsword.minstrel.models.PlaylistRef
|
||||||
import com.fabledsword.minstrel.nav.PlaylistDetail
|
import com.fabledsword.minstrel.nav.PlaylistDetail
|
||||||
@@ -66,7 +67,7 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
private val repository: PlaylistsRepository,
|
private val repository: PlaylistsRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
connectivity: ConnectivityObserver,
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val poolMessages = Channel<String>(Channel.BUFFERED)
|
private val poolMessages = Channel<String>(Channel.BUFFERED)
|
||||||
@@ -74,9 +75,9 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
/** Transient snackbar messages from playlist tile play taps. */
|
/** Transient snackbar messages from playlist tile play taps. */
|
||||||
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
||||||
|
|
||||||
/** True when the device has no usable internet -- gates refreshable system tiles. */
|
/** Cache-only: no link OR server unreachable (Unstable stays calm). Greys tiles. */
|
||||||
val offline: StateFlow<Boolean> = connectivity.online
|
val offline: StateFlow<Boolean> = networkStatus.state
|
||||||
.map { !it }
|
.map { it == ServerHealth.Offline || it == ServerHealth.ServerDown }
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||||
@@ -191,14 +192,15 @@ private fun PlaylistsGrid(
|
|||||||
SectionHeader("System playlists")
|
SectionHeader("System playlists")
|
||||||
}
|
}
|
||||||
items(items = systemPlaylists, key = { it.id }) { playlist ->
|
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(
|
PlaylistCard(
|
||||||
playlist = playlist,
|
playlist = playlist,
|
||||||
onClick = { onPlaylistClick(playlist.id) },
|
onClick = { onPlaylistClick(playlist.id) },
|
||||||
onPlay = { onPlay(playlist) },
|
onPlay = { onPlay(playlist) },
|
||||||
// Refreshable system tiles need server endpoints (systemShuffle);
|
playEnabled = playlist.trackCount > 0 && !greyed,
|
||||||
// disable when offline. Empty mixes show a snackbar after tap.
|
greyed = greyed,
|
||||||
playEnabled = playlist.trackCount > 0 &&
|
|
||||||
!(offline && playlist.refreshable),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,11 +209,13 @@ private fun PlaylistsGrid(
|
|||||||
SectionHeader("Your playlists")
|
SectionHeader("Your playlists")
|
||||||
}
|
}
|
||||||
items(items = userPlaylists, key = { it.id }) { playlist ->
|
items(items = userPlaylists, key = { it.id }) { playlist ->
|
||||||
|
val greyed = offline && playlist.unavailableOffline
|
||||||
PlaylistCard(
|
PlaylistCard(
|
||||||
playlist = playlist,
|
playlist = playlist,
|
||||||
onClick = { onPlaylistClick(playlist.id) },
|
onClick = { onPlaylistClick(playlist.id) },
|
||||||
onPlay = { onPlay(playlist) },
|
onPlay = { onPlay(playlist) },
|
||||||
playEnabled = playlist.trackCount > 0,
|
playEnabled = playlist.trackCount > 0 && !greyed,
|
||||||
|
greyed = greyed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -15,6 +15,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
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
|
* [playEnabled] disables the overlay (50% alpha + ignore taps) — Home
|
||||||
* uses it for system playlists in offline mode (their server-side
|
* uses it for system playlists in offline mode (their server-side
|
||||||
* shuffle endpoint is unreachable) and for empty playlists.
|
* 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
|
@Composable
|
||||||
fun PlaylistCard(
|
fun PlaylistCard(
|
||||||
@@ -48,11 +53,13 @@ fun PlaylistCard(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onPlay: (suspend () -> Unit)? = null,
|
onPlay: (suspend () -> Unit)? = null,
|
||||||
playEnabled: Boolean = true,
|
playEnabled: Boolean = true,
|
||||||
|
greyed: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val seedCache = LocalDetailSeedCache.current
|
val seedCache = LocalDetailSeedCache.current
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.width(176.dp)
|
.width(176.dp)
|
||||||
|
.alpha(if (greyed) GREYED_ALPHA else 1f) // dimmed, still clickable
|
||||||
.clickable {
|
.clickable {
|
||||||
seedCache.stashPlaylist(playlist)
|
seedCache.stashPlaylist(playlist)
|
||||||
onClick()
|
onClick()
|
||||||
@@ -63,7 +70,7 @@ fun PlaylistCard(
|
|||||||
PlaylistCardCover(
|
PlaylistCardCover(
|
||||||
playlist = playlist,
|
playlist = playlist,
|
||||||
onPlay = onPlay,
|
onPlay = onPlay,
|
||||||
playEnabled = playEnabled,
|
playEnabled = playEnabled && !greyed,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -148,6 +155,7 @@ private fun VariantPill(label: String, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const val PILL_BG_ALPHA = 0.85f
|
private const val PILL_BG_ALPHA = 0.85f
|
||||||
|
private const val GREYED_ALPHA = 0.45f
|
||||||
|
|
||||||
private fun subtitleFor(playlist: PlaylistRef): String = when {
|
private fun subtitleFor(playlist: PlaylistRef): String = when {
|
||||||
playlist.trackCount > 0 -> "${playlist.trackCount} tracks"
|
playlist.trackCount > 0 -> "${playlist.trackCount} tracks"
|
||||||
|
|||||||
@@ -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.CachedAlbumDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
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.ServerHealth
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
|
||||||
import com.fabledsword.minstrel.library.data.toDomain
|
import com.fabledsword.minstrel.library.data.toDomain
|
||||||
import com.fabledsword.minstrel.models.SearchResponseRef
|
import com.fabledsword.minstrel.models.SearchResponseRef
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
@@ -16,8 +16,8 @@ import javax.inject.Singleton
|
|||||||
private const val LOCAL_SEARCH_LIMIT = 20
|
private const val LOCAL_SEARCH_LIMIT = 20
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache-first when offline. When [ServerHealthController] is Healthy the
|
* Cache-first when offline. When [NetworkStatusController] reports Healthy or
|
||||||
* repository hits `/api/search` and returns the server's three-facet
|
* 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
|
* paged response. When health is Offline or ServerDown it falls back to
|
||||||
* Room LIKE queries against `cached_*` so the user can still find
|
* Room LIKE queries against `cached_*` so the user can still find
|
||||||
* something to play from what's already on the device; the outcome's
|
* 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
|
@Singleton
|
||||||
class SearchRepository @Inject constructor(
|
class SearchRepository @Inject constructor(
|
||||||
retrofit: Retrofit,
|
retrofit: Retrofit,
|
||||||
private val serverHealth: ServerHealthController,
|
private val serverHealth: NetworkStatusController,
|
||||||
private val trackDao: CachedTrackDao,
|
private val trackDao: CachedTrackDao,
|
||||||
private val albumDao: CachedAlbumDao,
|
private val albumDao: CachedAlbumDao,
|
||||||
private val artistDao: CachedArtistDao,
|
private val artistDao: CachedArtistDao,
|
||||||
@@ -35,7 +35,8 @@ class SearchRepository @Inject constructor(
|
|||||||
private val api: SearchApi = retrofit.create()
|
private val api: SearchApi = retrofit.create()
|
||||||
|
|
||||||
suspend fun search(query: String): SearchOutcome = when (serverHealth.state.value) {
|
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 ->
|
ServerHealth.Offline, ServerHealth.ServerDown ->
|
||||||
SearchOutcome(localSearch(query), localOnly = true)
|
SearchOutcome(localSearch(query), localOnly = true)
|
||||||
}
|
}
|
||||||
|
|||||||
+21
@@ -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()
|
||||||
|
}
|
||||||
+6
-1
@@ -10,12 +10,15 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps [content] in a Material3 PullToRefreshBox so the user can
|
* Wraps [content] in a Material3 PullToRefreshBox so the user can
|
||||||
* swipe-down to trigger [onRefresh]. The wrapper manages the
|
* 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
|
* [onRefresh] is suspend: pass `{ viewModel.refresh().join() }` so the
|
||||||
* indicator hides exactly when the underlying coroutine completes,
|
* indicator hides exactly when the underlying coroutine completes,
|
||||||
@@ -32,6 +35,7 @@ import kotlinx.coroutines.launch
|
|||||||
fun PullToRefreshScaffold(
|
fun PullToRefreshScaffold(
|
||||||
onRefresh: suspend () -> Unit,
|
onRefresh: suspend () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
netVm: PullRefreshNetworkViewModel = hiltViewModel(),
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
var isRefreshing by remember { mutableStateOf(false) }
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
@@ -42,6 +46,7 @@ fun PullToRefreshScaffold(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
try {
|
try {
|
||||||
|
netVm.recheck() // deliberate pull → re-probe the server now
|
||||||
onRefresh()
|
onRefresh()
|
||||||
} finally {
|
} finally {
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
|
|||||||
@@ -61,8 +61,11 @@ fun TrackRow(
|
|||||||
trailing: @Composable RowScope.() -> Unit = {},
|
trailing: @Composable RowScope.() -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val offlineUnavailable = LocalServerHealth.current != ServerHealth.Healthy &&
|
val health = LocalServerHealth.current
|
||||||
trackId !in LocalCachedTrackIds.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) {
|
val titleColor = if (nowPlaying) {
|
||||||
MaterialTheme.colorScheme.primary
|
MaterialTheme.colorScheme.primary
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import retrofit2.http.GET
|
|||||||
/**
|
/**
|
||||||
* Retrofit interface for `GET /healthz` — unauthenticated health probe
|
* Retrofit interface for `GET /healthz` — unauthenticated health probe
|
||||||
* returning the server's running version + the minimum client version
|
* returning the server's running version + the minimum client version
|
||||||
* it'll talk to. Used by [com.fabledsword.minstrel.update.data.VersionCheckController]
|
* it'll talk to. Polled by [com.fabledsword.minstrel.connectivity.NetworkStatusController]
|
||||||
* to surface the VersionTooOld banner.
|
* for both reachability and the VersionTooOld banner.
|
||||||
*/
|
*/
|
||||||
interface HealthzApi {
|
interface HealthzApi {
|
||||||
@GET("healthz")
|
@GET("healthz")
|
||||||
|
|||||||
-112
@@ -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<VersionResult> = 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<Boolean> = 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
||||||
+4
-4
@@ -2,21 +2,21 @@ package com.fabledsword.minstrel.update.ui
|
|||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
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 com.fabledsword.minstrel.update.data.VersionResult
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import javax.inject.Inject
|
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.
|
* [VersionResult] plus its recheck trigger for the banner button.
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class VersionTooOldViewModel @Inject constructor(
|
class VersionTooOldViewModel @Inject constructor(
|
||||||
private val controller: VersionCheckController,
|
private val controller: NetworkStatusController,
|
||||||
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
|
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val result: StateFlow<VersionResult> = controller.result
|
val result: StateFlow<VersionResult> = controller.versionResult
|
||||||
fun recheck() = controller.recheck()
|
fun recheck() = controller.recheck()
|
||||||
}
|
}
|
||||||
|
|||||||
+116
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PlaylistRowItem.Real>().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<PlaylistRowItem.Real>().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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user