Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 962b4dbc8c | |||
| aa4089118e | |||
| dabca3ad24 | |||
| 7c791dc8e4 | |||
| 8f29cc7414 | |||
| c8b21aa76e | |||
| 483804fc9e | |||
| 12a8cfccb5 | |||
| e358a92cf4 | |||
| 63b25e65ad | |||
| a766b7193f | |||
| e95138d412 | |||
| 7426f6c718 | |||
| e994aae613 | |||
| c39a9ca18f | |||
| 06e155abb6 | |||
| 6b11208e5a | |||
| 41da012494 | |||
| 8a3104443c | |||
| 5f5ab69da6 | |||
| 5386ae870f | |||
| d7b011e52f | |||
| 11466e1525 | |||
| e185b36138 | |||
| 31d8c30dfe | |||
| 69ccd7b25d | |||
| 8b77c6be97 | |||
| c33ef18a50 | |||
| 4c9450c117 | |||
| b467cb7532 | |||
| c78bbb7ba5 | |||
| 301c3bfb86 | |||
| 5c0db429b3 | |||
| 4d8c7d6566 | |||
| 58810a860b | |||
| d6e6caa223 | |||
| 9a31955fa4 | |||
| e7d7cb2471 | |||
| 8017934334 | |||
| 8b08482d13 | |||
| faa0c7024b | |||
| 7cf04fe24b | |||
| 1e17eeda72 | |||
| 1daea79f64 | |||
| 48de720514 | |||
| fced6b681e | |||
| 80a6be25aa | |||
| 4d0a0b8e09 | |||
| 6184c62721 | |||
| 222a0ff636 | |||
| 28300e19fd | |||
| 024493f2a7 | |||
| edd198cdf5 | |||
| d75c1ae37f | |||
| 8cd2383a42 | |||
| 27bd38e005 | |||
| aa23a72693 | |||
| 4021938046 | |||
| 7486bc2444 | |||
| ee8a1fdc93 | |||
| 8e578d2068 | |||
| cacb280832 | |||
| 36054506c2 | |||
| 5db90844cb | |||
| d5437d517e | |||
| 3085d6f409 | |||
| c5b326c620 | |||
| 389c896d65 | |||
| 41230b5afb | |||
| c245b1ef0b | |||
| 2425a305eb | |||
| 88b161193d | |||
| 9628ed1749 | |||
| 85926f4ec0 | |||
| 47b0894ad6 | |||
| e62fac3a0e | |||
| eae5dcad23 | |||
| 3576e241c0 | |||
| 8f89279fa4 | |||
| b1a66f18bd | |||
| 6a7958c921 | |||
| 33285b53c6 | |||
| 87ad7f4dc2 | |||
| 9c0013f4b6 | |||
| 6129536153 | |||
| e6c3c959fa | |||
| e011b04e04 | |||
| e2866795ef | |||
| e20d7b1438 | |||
| 2a098a78fe | |||
| ece37e9a92 | |||
| 8a1203c4a1 | |||
| 487d1bd430 | |||
| 5c99341b34 | |||
| 8fe3308afd | |||
| 96594ba52b | |||
| 2c61d7a333 | |||
| 8652b86f40 | |||
| 75132a2afe | |||
| 673f98487f | |||
| c556388a6b | |||
| edffdec2b2 | |||
| 1ab21d81ca | |||
| 81794e2475 | |||
| 29309d9bfb | |||
| 70b29567fb | |||
| 2f4d67d3c8 | |||
| b2bfe96559 | |||
| e9dd3e4d2a | |||
| b29875fd30 | |||
| 85cea8d559 | |||
| ab6c3a1354 | |||
| 3aee2276bc | |||
| 9a7d3b2d30 | |||
| 9002cf5559 | |||
| a5e4570f01 | |||
| bfcb9c42a0 | |||
| 799d50024c | |||
| 8c0c4c8600 | |||
| 3cdb416f94 | |||
| d62a3b8134 | |||
| 574bf29a7e | |||
| 24b7c92abd |
@@ -116,6 +116,27 @@ jobs:
|
||||
# Wait for Postgres to accept TCP (no health-check dependency).
|
||||
for i in $(seq 1 60); do (echo > "/dev/tcp/${PG_IP}/5432") 2>/dev/null && break; sleep 2; done
|
||||
|
||||
# Relax durability on the throwaway CI Postgres. Our test pattern
|
||||
# is dbtest.ResetDB → TRUNCATE … RESTART IDENTITY CASCADE before
|
||||
# every test, and the per-TRUNCATE commit fsync is the dominant
|
||||
# cost of the integration suite. The CI DB is rebuilt every run so
|
||||
# fsync / full_page_writes / synchronous_commit buy nothing. Apply
|
||||
# via docker exec because:
|
||||
# - The act_runner `services:` block can't override the container
|
||||
# command, so `postgres -c fsync=off` at boot isn't an option.
|
||||
# - ALTER SYSTEM cannot run inside a transaction; psql -c
|
||||
# auto-commits each statement, which is what we need.
|
||||
# - fsync / full_page_writes are sighup GUCs and
|
||||
# synchronous_commit is user-context, so pg_reload_conf() picks
|
||||
# all three up with no restart.
|
||||
# Non-fatal: a perms surprise degrades to "slower", never red CI.
|
||||
docker exec "$PG_ID" psql -U minstrel -d minstrel_test \
|
||||
-c "ALTER SYSTEM SET fsync = off" \
|
||||
-c "ALTER SYSTEM SET synchronous_commit = off" \
|
||||
-c "ALTER SYSTEM SET full_page_writes = off" \
|
||||
-c "SELECT pg_reload_conf()" \
|
||||
|| echo "WARN: durability relax failed; continuing"
|
||||
|
||||
# Apply embedded migrations to the fresh test DB, then run the
|
||||
# full suite (no -short → integration tests execute). -p 1:
|
||||
# every integration package TRUNCATEs the one shared test DB;
|
||||
|
||||
Generated
+13
@@ -1754,6 +1754,19 @@
|
||||
<option name="screenX" value="1600" />
|
||||
<option name="screenY" value="2560" />
|
||||
</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>
|
||||
<option name="api" value="35" />
|
||||
<option name="brand" value="google" />
|
||||
|
||||
Generated
-1
@@ -1,4 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
|
||||
import com.fabledsword.minstrel.cache.CachedTrackIds
|
||||
import com.fabledsword.minstrel.connectivity.LocalServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
||||
@@ -38,6 +41,7 @@ import javax.inject.Inject
|
||||
class MainActivity : ComponentActivity() {
|
||||
@Inject lateinit var seedCache: DetailSeedCache
|
||||
@Inject lateinit var cachedTrackIds: CachedTrackIds
|
||||
@Inject lateinit var serverHealth: NetworkStatusController
|
||||
|
||||
// Flipped to true when the user taps the media notification (or
|
||||
// any other entry point that asks for the full player). The App
|
||||
@@ -54,6 +58,7 @@ class MainActivity : ComponentActivity() {
|
||||
App(
|
||||
seedCache = seedCache,
|
||||
cachedTrackIds = cachedTrackIds,
|
||||
serverHealth = serverHealth,
|
||||
pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
|
||||
onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
|
||||
)
|
||||
@@ -86,6 +91,7 @@ class MainActivity : ComponentActivity() {
|
||||
private fun App(
|
||||
seedCache: DetailSeedCache,
|
||||
cachedTrackIds: CachedTrackIds,
|
||||
serverHealth: NetworkStatusController,
|
||||
pendingOpenNowPlaying: StateFlow<Boolean>,
|
||||
onOpenedNowPlaying: () -> Unit,
|
||||
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
||||
@@ -93,11 +99,13 @@ private fun App(
|
||||
) {
|
||||
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
|
||||
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
|
||||
val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle()
|
||||
val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
|
||||
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
|
||||
CompositionLocalProvider(
|
||||
LocalDetailSeedCache provides seedCache,
|
||||
LocalCachedTrackIds provides cached,
|
||||
LocalServerHealth provides health,
|
||||
) {
|
||||
val startDestination by gate.startDestination.collectAsStateWithLifecycle()
|
||||
val resolved = startDestination
|
||||
|
||||
@@ -19,7 +19,7 @@ import com.fabledsword.minstrel.player.PlayEventsReporter
|
||||
import com.fabledsword.minstrel.player.PlaybackErrorReporter
|
||||
import com.fabledsword.minstrel.player.ResumeController
|
||||
import com.fabledsword.minstrel.update.data.UpdateBannerController
|
||||
import com.fabledsword.minstrel.update.data.VersionCheckController
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -114,12 +114,13 @@ class MinstrelApplication :
|
||||
@Suppress("unused") @Inject lateinit var audioPrefetcher: AudioPrefetcher
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — VersionCheckController's
|
||||
* init block starts a 5-min poll loop against /healthz so the
|
||||
* shell-level VersionTooOldBanner can surface min_client_version
|
||||
* mismatches without waiting for the next user-driven request.
|
||||
* Same construct-the-singleton trick — NetworkStatusController owns the
|
||||
* /healthz poll loop + the device-link collector + the reachability state
|
||||
* machine, and is the single authority on the tri-state ServerHealth
|
||||
* signal (plus the VersionTooOld byproduct). It must exist from launch so
|
||||
* the poll loop runs and the StateFlow stays warm for every consumer.
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var versionCheckController: VersionCheckController
|
||||
@Suppress("unused") @Inject lateinit var networkStatusController: NetworkStatusController
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — UpdateBannerController polls
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.fabledsword.minstrel.api
|
||||
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.connectivity.ReachabilityReportingInterceptor
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@@ -45,9 +46,15 @@ object NetworkModule {
|
||||
fun provideOkHttp(
|
||||
baseUrl: BaseUrlInterceptor,
|
||||
auth: AuthCookieInterceptor,
|
||||
reachability: ReachabilityReportingInterceptor,
|
||||
logging: HttpLoggingInterceptor,
|
||||
): OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
// ReachabilityReportingInterceptor MUST run first: it identifies
|
||||
// Minstrel-bound requests by the still-unrewritten PLACEHOLDER_HOST
|
||||
// (so external artwork fetches don't read as server reachability)
|
||||
// and observes the final transport outcome by wrapping the chain.
|
||||
.addInterceptor(reachability)
|
||||
// AuthCookieInterceptor MUST run before BaseUrlInterceptor.
|
||||
// Both scope on `host == PLACEHOLDER_HOST` to distinguish
|
||||
// Minstrel-server requests from external image fetches
|
||||
|
||||
@@ -39,11 +39,18 @@ data class StreamTokenRequest(
|
||||
/**
|
||||
* Response body. [url] is a fully-formed stream URL with [token] and
|
||||
* [exp] already embedded as query params — callers pass it verbatim
|
||||
* to `AVTransport.SetAVTransportURI`.
|
||||
* to `AVTransport.SetAVTransportURI`. [mime] + [title] are the bits
|
||||
* the client needs to build DIDL-Lite metadata for that call: Sonos
|
||||
* rejects empty DIDL with vendor error 1023, so the server hands back
|
||||
* the track's MIME (from `tracks.file_format`) and title so the
|
||||
* client can populate `<res protocolInfo>` and `<dc:title>` without
|
||||
* a follow-up round trip.
|
||||
*/
|
||||
@Serializable
|
||||
data class StreamTokenResponse(
|
||||
val token: String,
|
||||
val exp: Long,
|
||||
val url: String,
|
||||
val mime: String = "audio/mpeg",
|
||||
val title: String = "",
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.api.endpoints
|
||||
|
||||
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
|
||||
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
|
||||
import com.fabledsword.minstrel.models.wire.ArtistWire
|
||||
import com.fabledsword.minstrel.models.wire.TrackWire
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
@@ -35,9 +36,26 @@ interface LibraryApi {
|
||||
@GET("api/artists/{id}/tracks")
|
||||
suspend fun getArtistTracks(@Path("id") id: String): List<TrackWire>
|
||||
|
||||
@GET("api/artists/{id}/similar")
|
||||
suspend fun getSimilarArtists(
|
||||
@Path("id") id: String,
|
||||
@Query("limit") limit: Int = SIMILAR_ARTISTS_LIMIT,
|
||||
): List<ArtistWire>
|
||||
|
||||
@GET("api/artists/{id}/top-tracks")
|
||||
suspend fun getArtistTopTracks(
|
||||
@Path("id") id: String,
|
||||
@Query("limit") limit: Int = TOP_TRACKS_LIMIT,
|
||||
): List<TrackWire>
|
||||
|
||||
@GET("api/albums/{id}")
|
||||
suspend fun getAlbumDetail(@Path("id") id: String): AlbumDetailWire
|
||||
|
||||
@GET("api/library/shuffle")
|
||||
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
|
||||
|
||||
private companion object {
|
||||
const val SIMILAR_ARTISTS_LIMIT = 12
|
||||
const val TOP_TRACKS_LIMIT = 5
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -24,6 +24,14 @@ interface CachedAlbumDao {
|
||||
@Query("SELECT * FROM cached_albums WHERE id = :id")
|
||||
fun observeById(id: String): Flow<CachedAlbumEntity?>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM cached_albums " +
|
||||
"WHERE title LIKE '%' || :q || '%' COLLATE NOCASE " +
|
||||
"ORDER BY sortTitle COLLATE NOCASE ASC " +
|
||||
"LIMIT :limit",
|
||||
)
|
||||
suspend fun searchByTitle(q: String, limit: Int): List<CachedAlbumEntity>
|
||||
|
||||
@Query("SELECT id FROM cached_albums WHERE fetchedAt < :before LIMIT :limit")
|
||||
suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
|
||||
|
||||
|
||||
+8
@@ -18,6 +18,14 @@ interface CachedArtistDao {
|
||||
@Query("SELECT * FROM cached_artists WHERE id = :id")
|
||||
fun observeById(id: String): Flow<CachedArtistEntity?>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM cached_artists " +
|
||||
"WHERE name LIKE '%' || :q || '%' COLLATE NOCASE " +
|
||||
"ORDER BY sortName COLLATE NOCASE ASC " +
|
||||
"LIMIT :limit",
|
||||
)
|
||||
suspend fun searchByName(q: String, limit: Int): List<CachedArtistEntity>
|
||||
|
||||
@Query("SELECT id FROM cached_artists WHERE fetchedAt < :before LIMIT :limit")
|
||||
suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
|
||||
|
||||
|
||||
+14
@@ -31,6 +31,20 @@ interface CachedPlaylistDao {
|
||||
@Query("SELECT * FROM cached_playlists WHERE id = :id")
|
||||
suspend fun getById(id: String): CachedPlaylistEntity?
|
||||
|
||||
/**
|
||||
* Per-playlist count of member tracks resident in the audio cache index.
|
||||
* LEFT JOINs so playlists with zero cached tracks still appear
|
||||
* (cachedCount = 0). Drives the offline "fully cached" greying.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT p.id AS playlistId, COUNT(a.trackId) AS cachedCount " +
|
||||
"FROM cached_playlists p " +
|
||||
"LEFT JOIN cached_playlist_tracks t ON t.playlistId = p.id " +
|
||||
"LEFT JOIN audio_cache_index a ON a.trackId = t.trackId " +
|
||||
"GROUP BY p.id",
|
||||
)
|
||||
fun observeCachedCounts(): Flow<List<PlaylistCachedCount>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(rows: List<CachedPlaylistEntity>)
|
||||
|
||||
|
||||
+8
@@ -24,6 +24,14 @@ interface CachedTrackDao {
|
||||
@Query("SELECT * FROM cached_tracks WHERE id IN (:ids)")
|
||||
suspend fun getByIds(ids: List<String>): List<CachedTrackEntity>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM cached_tracks " +
|
||||
"WHERE title LIKE '%' || :q || '%' COLLATE NOCASE " +
|
||||
"ORDER BY title COLLATE NOCASE ASC " +
|
||||
"LIMIT :limit",
|
||||
)
|
||||
suspend fun searchByTitle(q: String, limit: Int): List<CachedTrackEntity>
|
||||
|
||||
@Query("SELECT id FROM cached_tracks WHERE fetchedAt < :before LIMIT :limit")
|
||||
suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
|
||||
|
||||
|
||||
+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,
|
||||
)
|
||||
+63
-43
@@ -2,12 +2,18 @@ package com.fabledsword.minstrel.cache.mutations
|
||||
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val QUEUED_MESSAGE = "Saved — will sync when online"
|
||||
|
||||
/**
|
||||
* Stable mutation kinds the queue knows how to replay. Strings are
|
||||
* persisted in `cached_mutations.kind` so renaming a variant breaks
|
||||
@@ -71,47 +77,59 @@ class MutationQueue @Inject constructor(
|
||||
private val dao: CachedMutationDao,
|
||||
private val json: Json,
|
||||
) {
|
||||
// capacity=1 DROP_OLDEST so a burst of user enqueues (e.g. liking N
|
||||
// tracks while offline) surfaces as one snackbar rather than queueing
|
||||
// N. replay=0 because a hint observed at enqueue time isn't useful
|
||||
// to a screen that mounts later.
|
||||
private val _userEnqueueHints = MutableSharedFlow<String>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
/**
|
||||
* Hint stream consumed by [com.fabledsword.minstrel.shared.widgets.ShellScaffold]
|
||||
* to surface "Saved — will sync when online" as a snackbar whenever a
|
||||
* user-driven write hits the offline-fallback path. Background-only
|
||||
* enqueues (play-events, playback-error reports) do not emit — those
|
||||
* fire from non-foreground paths where a snackbar would be either
|
||||
* dropped (no shell mounted) or jarring (lock-screen toggle).
|
||||
*/
|
||||
val userEnqueueHints: SharedFlow<String> = _userEnqueueHints.asSharedFlow()
|
||||
|
||||
suspend fun enqueueLikeToggle(
|
||||
entityType: String,
|
||||
entityId: String,
|
||||
desiredState: Boolean,
|
||||
): Long = dao.insert(
|
||||
CachedMutationEntity(
|
||||
kind = MutationKind.LIKE_TOGGLE,
|
||||
payload = json.encodeToString(
|
||||
LikeTogglePayload.serializer(),
|
||||
LikeTogglePayload(entityType, entityId, desiredState),
|
||||
),
|
||||
): Long = insertUserDriven(
|
||||
MutationKind.LIKE_TOGGLE,
|
||||
json.encodeToString(
|
||||
LikeTogglePayload.serializer(),
|
||||
LikeTogglePayload(entityType, entityId, desiredState),
|
||||
),
|
||||
)
|
||||
|
||||
suspend fun enqueueRequestCreate(payload: RequestCreatePayload): Long = dao.insert(
|
||||
CachedMutationEntity(
|
||||
kind = MutationKind.REQUEST_CREATE,
|
||||
payload = json.encodeToString(RequestCreatePayload.serializer(), payload),
|
||||
),
|
||||
suspend fun enqueueRequestCreate(payload: RequestCreatePayload): Long = insertUserDriven(
|
||||
MutationKind.REQUEST_CREATE,
|
||||
json.encodeToString(RequestCreatePayload.serializer(), payload),
|
||||
)
|
||||
|
||||
suspend fun enqueueQuarantineUnflag(trackId: String): Long = dao.insert(
|
||||
CachedMutationEntity(
|
||||
kind = MutationKind.QUARANTINE_UNFLAG,
|
||||
payload = json.encodeToString(
|
||||
QuarantineUnflagPayload.serializer(),
|
||||
QuarantineUnflagPayload(trackId),
|
||||
),
|
||||
suspend fun enqueueQuarantineUnflag(trackId: String): Long = insertUserDriven(
|
||||
MutationKind.QUARANTINE_UNFLAG,
|
||||
json.encodeToString(
|
||||
QuarantineUnflagPayload.serializer(),
|
||||
QuarantineUnflagPayload(trackId),
|
||||
),
|
||||
)
|
||||
|
||||
suspend fun enqueuePlaylistAppend(
|
||||
playlistId: String,
|
||||
trackIds: List<String>,
|
||||
): Long = dao.insert(
|
||||
CachedMutationEntity(
|
||||
kind = MutationKind.PLAYLIST_APPEND,
|
||||
payload = json.encodeToString(
|
||||
PlaylistAppendPayload.serializer(),
|
||||
PlaylistAppendPayload(playlistId, trackIds),
|
||||
),
|
||||
): Long = insertUserDriven(
|
||||
MutationKind.PLAYLIST_APPEND,
|
||||
json.encodeToString(
|
||||
PlaylistAppendPayload.serializer(),
|
||||
PlaylistAppendPayload(playlistId, trackIds),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -119,13 +137,19 @@ class MutationQueue @Inject constructor(
|
||||
trackId: String,
|
||||
reason: String,
|
||||
notes: String,
|
||||
): Long = dao.insert(
|
||||
CachedMutationEntity(
|
||||
kind = MutationKind.QUARANTINE_FLAG,
|
||||
payload = json.encodeToString(
|
||||
QuarantineFlagPayload.serializer(),
|
||||
QuarantineFlagPayload(trackId, reason, notes),
|
||||
),
|
||||
): Long = insertUserDriven(
|
||||
MutationKind.QUARANTINE_FLAG,
|
||||
json.encodeToString(
|
||||
QuarantineFlagPayload.serializer(),
|
||||
QuarantineFlagPayload(trackId, reason, notes),
|
||||
),
|
||||
)
|
||||
|
||||
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
||||
MutationKind.REQUEST_CANCEL,
|
||||
json.encodeToString(
|
||||
RequestCancelPayload.serializer(),
|
||||
RequestCancelPayload(requestId),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -136,22 +160,18 @@ class MutationQueue @Inject constructor(
|
||||
),
|
||||
)
|
||||
|
||||
suspend fun enqueueRequestCancel(requestId: String): Long = dao.insert(
|
||||
CachedMutationEntity(
|
||||
kind = MutationKind.REQUEST_CANCEL,
|
||||
payload = json.encodeToString(
|
||||
RequestCancelPayload.serializer(),
|
||||
RequestCancelPayload(requestId),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
suspend fun enqueuePlaybackErrorReport(payload: PlaybackErrorReportPayload): Long = dao.insert(
|
||||
CachedMutationEntity(
|
||||
kind = MutationKind.PLAYBACK_ERROR_REPORT,
|
||||
payload = json.encodeToString(PlaybackErrorReportPayload.serializer(), payload),
|
||||
),
|
||||
)
|
||||
|
||||
private suspend fun insertUserDriven(kind: String, payload: String): Long {
|
||||
val id = dao.insert(CachedMutationEntity(kind = kind, payload = payload))
|
||||
_userEnqueueHints.tryEmit(QUEUED_MESSAGE)
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
package com.fabledsword.minstrel.cache.mutations
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Hilt-injectable wrapper exposing [MutationQueue.userEnqueueHints] to
|
||||
* ShellScaffold. The queue itself is an app-scoped singleton; this VM
|
||||
* just bridges its SharedFlow into a `hiltViewModel()`-resolvable
|
||||
* surface so ShellScaffold can collect it without an EntryPoint
|
||||
* accessor. Mirrors PlaybackErrorViewModel.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class OfflineWriteHintViewModel @Inject constructor(
|
||||
mutationQueue: MutationQueue,
|
||||
) : ViewModel() {
|
||||
val messages: Flow<String> = mutationQueue.userEnqueueHints
|
||||
}
|
||||
+27
-15
@@ -14,11 +14,23 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Single source of truth for the device's "is the internet usable
|
||||
* right now" signal — wraps [ConnectivityManager] and exposes a hot
|
||||
* cold-startable Flow that emits `false` while the active network
|
||||
* lacks INTERNET + VALIDATED capabilities (airplane mode, no carrier,
|
||||
* captive portal, etc.) and `true` once a usable network appears.
|
||||
* Single source of truth for "does the device have a network link at
|
||||
* all" — wraps [ConnectivityManager] and exposes a hot cold-startable
|
||||
* Flow that emits `false` only when there is no active INTERNET-capable
|
||||
* network (airplane mode, no carrier/Wi-Fi) and `true` once any network
|
||||
* link appears.
|
||||
*
|
||||
* Deliberately does NOT require `NET_CAPABILITY_VALIDATED`. VALIDATED
|
||||
* tracks whether Android reached its own WAN internet-validation probe
|
||||
* (Google's `generate_204`) — which is the wrong question for a
|
||||
* self-hosted server that is usually on the LAN. A transient WAN/DNS
|
||||
* blip (or Android's periodic re-validation) momentarily drops VALIDATED
|
||||
* while the Minstrel box stays perfectly reachable; gating on it flipped
|
||||
* the app to Offline with no debounce and fast-failed in-flight playback
|
||||
* via [com.fabledsword.minstrel.player.OfflineGatedDataSource]. The
|
||||
* authority on whether *Minstrel* is reachable is the `/healthz` poll
|
||||
* ([com.fabledsword.minstrel.connectivity.NetworkStatusController], which
|
||||
* has its own failure hysteresis), not this coarse device-link signal.
|
||||
*
|
||||
* Used by the shell-level ConnectionErrorBanner; downstream
|
||||
* repositories can also collect this to gate retry loops.
|
||||
@@ -35,36 +47,36 @@ class ConnectivityObserver @Inject constructor(
|
||||
.build()
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
trySend(hasUsableInternet())
|
||||
trySend(hasActiveNetwork())
|
||||
}
|
||||
override fun onLost(network: Network) {
|
||||
trySend(hasUsableInternet())
|
||||
trySend(hasActiveNetwork())
|
||||
}
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
capabilities: NetworkCapabilities,
|
||||
) {
|
||||
// INTERNET only -- NOT VALIDATED. A WAN/validation flicker
|
||||
// must not read as "device offline" when the LAN (and the
|
||||
// Minstrel server on it) is still reachable. /healthz is the
|
||||
// authority on server reachability.
|
||||
trySend(
|
||||
capabilities.hasCapability(
|
||||
NetworkCapabilities.NET_CAPABILITY_INTERNET,
|
||||
) &&
|
||||
capabilities.hasCapability(
|
||||
NetworkCapabilities.NET_CAPABILITY_VALIDATED,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
cm.registerNetworkCallback(request, callback)
|
||||
// Seed the initial value so the banner doesn't flash before the
|
||||
// first capability callback fires.
|
||||
trySend(hasUsableInternet())
|
||||
trySend(hasActiveNetwork())
|
||||
awaitClose { cm.unregisterNetworkCallback(callback) }
|
||||
}.distinctUntilChanged()
|
||||
|
||||
private fun hasUsableInternet(): Boolean {
|
||||
private fun hasActiveNetwork(): Boolean {
|
||||
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) }
|
||||
return caps != null &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
}
|
||||
|
||||
+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 }
|
||||
+83
-39
@@ -14,77 +14,121 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.composables.icons.lucide.CloudOff
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val ONLINE_SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
private const val BACK_ONLINE_FLASH_MS = 2_000L
|
||||
|
||||
/**
|
||||
* Tiny VM that just lifts the [ConnectivityObserver] singleton's
|
||||
* Flow into a StateFlow with the standard sharing strategy. Keeps
|
||||
* the banner composable pure-presentation.
|
||||
* Exposes [NetworkStatusController]'s tri-state directly to the banner. No
|
||||
* re-wrapping StateFlow — the controller's is already app-scoped and warm.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ConnectivityBannerViewModel @Inject constructor(
|
||||
observer: ConnectivityObserver,
|
||||
networkStatus: NetworkStatusController,
|
||||
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
val online: StateFlow<Boolean> = observer.online.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(ONLINE_SHARE_STOP_TIMEOUT_MS),
|
||||
initialValue = true,
|
||||
)
|
||||
val health: StateFlow<ServerHealth> = networkStatus.state
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner shown at the top of the shell when the device has no usable
|
||||
* internet. Mirrors Flutter's ConnectionErrorBanner: red-tinted error
|
||||
* surface, CloudOff icon, "No connection — check Wi-Fi or mobile
|
||||
* data" copy. Auto-hides via slide+fade when connectivity returns.
|
||||
* Shell banner. Tells the user *why* they're degraded — no link vs server-down
|
||||
* — plus a non-alarming "Reconnecting…" for the transient [ServerHealth.Unstable]
|
||||
* window, and a brief "Back online" confirmation when health recovers so
|
||||
* recovery is unmistakable. Sentence case, understated voice (design system).
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectionErrorBanner(
|
||||
viewModel: ConnectivityBannerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val online by viewModel.online.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(
|
||||
visible = !online,
|
||||
visible = health != ServerHealth.Healthy || showBackOnline,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.errorContainer)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.CloudOff,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
text = "No connection — check Wi-Fi or mobile data.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
BannerContent(health = health, backOnline = showBackOnline)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BannerContent(health: ServerHealth, backOnline: Boolean) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val background: Color
|
||||
val foreground: Color
|
||||
when {
|
||||
backOnline -> {
|
||||
background = scheme.secondaryContainer
|
||||
foreground = scheme.onSecondaryContainer
|
||||
}
|
||||
health == ServerHealth.Unstable -> {
|
||||
background = scheme.surfaceVariant
|
||||
foreground = scheme.onSurfaceVariant
|
||||
}
|
||||
else -> {
|
||||
background = scheme.errorContainer
|
||||
foreground = scheme.onErrorContainer
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(background)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(imageVector = Lucide.CloudOff, contentDescription = null, tint = foreground)
|
||||
Text(
|
||||
text = bannerText(health, backOnline),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = foreground,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bannerText(health: ServerHealth, backOnline: Boolean): String = when {
|
||||
backOnline -> "Back online."
|
||||
health == ServerHealth.Offline -> "No connection — check Wi-Fi or mobile data."
|
||||
health == ServerHealth.ServerDown ->
|
||||
"Server unreachable — your cached content is still available."
|
||||
health == ServerHealth.Unstable -> "Reconnecting…"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ import com.composables.icons.lucide.History
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Music
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.home.data.HomeRepository
|
||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||
@@ -69,7 +70,7 @@ import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.nav.Home
|
||||
import com.fabledsword.minstrel.nav.PlaylistDetail
|
||||
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
||||
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
||||
import com.fabledsword.minstrel.playlists.data.playPlaylistShuffled
|
||||
import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard
|
||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
|
||||
@@ -95,11 +96,9 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
private const val PLAYLIST_FETCH_TIMEOUT_MS = 8_000L
|
||||
private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
|
||||
// Recently Added is laid out in a multi-row LazyHorizontalGrid that
|
||||
// scrolls as one panel (same pattern as Most Played). Two rows trades
|
||||
@@ -136,7 +135,7 @@ class HomeViewModel @Inject constructor(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val player: com.fabledsword.minstrel.player.PlayerController,
|
||||
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
|
||||
connectivity: com.fabledsword.minstrel.connectivity.ConnectivityObserver,
|
||||
networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus())
|
||||
@@ -144,9 +143,13 @@ class HomeViewModel @Inject constructor(
|
||||
/** System-playlist build status for the Home placeholder cards. */
|
||||
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
|
||||
.map { !it }
|
||||
/**
|
||||
* Cache-only when there's no link OR the server is unreachable. Reads the
|
||||
* unified [NetworkStatusController] (not the raw device link) so Home reacts
|
||||
* to ServerDown too; the transient Unstable state stays calm (not offline).
|
||||
*/
|
||||
val offline: StateFlow<Boolean> = networkStatus.state
|
||||
.map { it == ServerHealth.Offline || it == ServerHealth.ServerDown }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
@@ -259,45 +262,9 @@ class HomeViewModel @Inject constructor(
|
||||
*/
|
||||
suspend fun playPlaylist(playlist: PlaylistRef) {
|
||||
viewModelScope.launch {
|
||||
val detail = try {
|
||||
withTimeout(PLAYLIST_FETCH_TIMEOUT_MS) {
|
||||
if (playlist.refreshable && playlist.systemVariant != null) {
|
||||
playlistsRepository.systemShuffle(playlist.systemVariant)
|
||||
} else {
|
||||
playlistsRepository.refreshDetail(playlist.id)
|
||||
}
|
||||
}
|
||||
} catch (
|
||||
@Suppress("SwallowedException") _: kotlinx.coroutines.TimeoutCancellationException,
|
||||
) {
|
||||
poolMessages.trySend("Couldn't load playlist - check your connection")
|
||||
return@launch
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
poolMessages.trySend(
|
||||
"Couldn't load playlist: ${ErrorCopy.fromThrowable(e)}",
|
||||
)
|
||||
return@launch
|
||||
playPlaylistShuffled(playlist, playlistsRepository, player) {
|
||||
poolMessages.trySend(it)
|
||||
}
|
||||
// Shared with PlaylistDetailViewModel.play - filters out
|
||||
// unplayable rows (missing trackId or empty streamUrl) so the
|
||||
// queue can't end up with tracks Media3 silently rejects.
|
||||
val tracks = detail.tracks.toPlayableTrackRefs()
|
||||
if (tracks.isEmpty()) {
|
||||
poolMessages.trySend("Mix isn't ready yet - try again in a moment")
|
||||
return@launch
|
||||
}
|
||||
// Drift #564: send the BARE systemVariant string, not
|
||||
// "playlist:<variant>" — the server's rotation matcher
|
||||
// (internal/playevents/writer.go systemPlaylistSources)
|
||||
// keys on the bare variant. Web sends the bare form too
|
||||
// (web/src/lib/components/PlaylistCard.svelte:83), so this
|
||||
// brings Android into alignment. Wrong prefix here meant
|
||||
// system-mix plays from Android Home never advanced the
|
||||
// rotation.
|
||||
val source = if (playlist.refreshable) playlist.systemVariant else null
|
||||
player.setQueue(tracks, initialIndex = 0, source = source)
|
||||
}.join()
|
||||
}
|
||||
|
||||
@@ -739,18 +706,19 @@ private fun PlaylistsRow(
|
||||
icon = iconForPool(item.kind),
|
||||
onClick = { onPlayPool(item.kind) },
|
||||
)
|
||||
is PlaylistRowItem.Real -> PlaylistCard(
|
||||
playlist = item.playlist,
|
||||
onClick = { onPlaylistClick(item.playlist.id) },
|
||||
onPlay = { onPlayPlaylist(item.playlist) },
|
||||
// Match Flutter: refreshable system playlists need
|
||||
// the live server endpoints, so disable their play
|
||||
// overlay when offline (user can still tap into the
|
||||
// detail and shuffle all from cache). User playlists
|
||||
// play from cache + survive offline.
|
||||
playEnabled = item.playlist.trackCount > 0 &&
|
||||
!(offline && item.playlist.refreshable),
|
||||
)
|
||||
is PlaylistRowItem.Real -> {
|
||||
// Greyed offline when the tile needs the live server or
|
||||
// isn't fully cached — dimmed but still tappable into the
|
||||
// detail to shuffle whatever subset is cached.
|
||||
val greyed = offline && item.playlist.unavailableOffline
|
||||
PlaylistCard(
|
||||
playlist = item.playlist,
|
||||
onClick = { onPlaylistClick(item.playlist.id) },
|
||||
onPlay = { onPlayPlaylist(item.playlist) },
|
||||
playEnabled = item.playlist.trackCount > 0 && !greyed,
|
||||
greyed = greyed,
|
||||
)
|
||||
}
|
||||
is PlaylistRowItem.Placeholder -> PlaylistPlaceholderCard(
|
||||
label = item.label,
|
||||
variant = item.variant,
|
||||
@@ -766,7 +734,7 @@ private fun iconForPool(kind: OfflinePoolKind) = when (kind) {
|
||||
}
|
||||
|
||||
/** A cache-backed offline pool, a real playlist tile, or a not-yet-generated slot. */
|
||||
private sealed interface PlaylistRowItem {
|
||||
internal sealed interface PlaylistRowItem {
|
||||
data class OfflinePool(val kind: OfflinePoolKind) : PlaylistRowItem
|
||||
data class Real(val playlist: PlaylistRef) : PlaylistRowItem
|
||||
data class Placeholder(val label: String, val variant: String) : PlaylistRowItem
|
||||
@@ -779,55 +747,81 @@ enum class OfflinePoolKind(val label: String) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Home Playlists row. When [offline], the two cache-backed
|
||||
* pool cards (Recently played, Liked) lead the row. Then For You +
|
||||
* Discover + 3× Songs-like fixed slots (real card when generated,
|
||||
* placeholder otherwise), then the secondary system kinds (deep cuts /
|
||||
* rediscover / new for you / on this day / first listens) when they
|
||||
* exist — no placeholders for these since they're conditional on
|
||||
* library shape, not guaranteed singletons. Finally user-owned
|
||||
* playlists.
|
||||
* Builds the Home Playlists row.
|
||||
*
|
||||
* Online: For You + Discover + 3× Songs-like fixed slots (real card when
|
||||
* generated, placeholder otherwise), then the secondary system kinds (deep cuts
|
||||
* / rediscover / new for you / on this day / first listens) when they exist —
|
||||
* no placeholders for these since they're conditional on library shape — then
|
||||
* user-owned playlists.
|
||||
*
|
||||
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
|
||||
* same real playlists in curated order but stably partitioned fully-cached
|
||||
* first / greyed after, and the "building/pending" placeholders are dropped
|
||||
* (they need the server to generate, so they're meaningless offline).
|
||||
*
|
||||
* Diverges from Flutter (`flutter_client/lib/library/home_screen.dart`
|
||||
* `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
|
||||
* surfaces the secondary kinds on Home. Operator authorized the
|
||||
* divergence on 2026-06-01; web UI catch-up tracked as task #53.
|
||||
*/
|
||||
private fun buildPlaylistsRow(
|
||||
internal fun buildPlaylistsRow(
|
||||
owned: List<PlaylistRef>,
|
||||
status: SystemPlaylistsStatus,
|
||||
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> {
|
||||
val out = mutableListOf<PlaylistRowItem>()
|
||||
if (offline) {
|
||||
out += PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED)
|
||||
out += PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED)
|
||||
}
|
||||
out += owned.firstOrNull { it.systemVariant == "for_you" }
|
||||
?.let { PlaylistRowItem.Real(it) }
|
||||
?: PlaylistRowItem.Placeholder("For You", variantFor("for-you", status))
|
||||
out += owned.firstOrNull { it.systemVariant == "discover" }
|
||||
?.let { PlaylistRowItem.Real(it) }
|
||||
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
|
||||
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(3)
|
||||
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
|
||||
for (i in 0 until SONGS_LIKE_SLOTS) {
|
||||
out += songsLike.getOrNull(i)
|
||||
?.let { PlaylistRowItem.Real(it) }
|
||||
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
||||
}
|
||||
// Secondary system kinds in server-registry order. Only included
|
||||
// when actually generated — these depend on library shape (Deep
|
||||
// cuts needs deep albums, On this day needs prior history, etc.)
|
||||
// so a missing one means "not enough data" rather than "still
|
||||
// building".
|
||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||
owned.firstOrNull { it.systemVariant == variant }
|
||||
?.let { out += PlaylistRowItem.Real(it) }
|
||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
|
||||
}
|
||||
owned.filter { it.systemVariant == null }.forEach { out += PlaylistRowItem.Real(it) }
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated real-playlist order (system primaries, then secondary, then user).
|
||||
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
|
||||
* only differs by dropping placeholders + partitioning available-first.
|
||||
*/
|
||||
private fun orderedRealPlaylists(owned: List<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 {
|
||||
s.inFlight -> "building"
|
||||
s.lastError != null -> "failed"
|
||||
|
||||
@@ -105,6 +105,22 @@ class LibraryRepository @Inject constructor(
|
||||
suspend fun fetchArtistTracks(id: String): List<TrackRef> =
|
||||
api.getArtistTracks(id).map { it.toDomain() }
|
||||
|
||||
/**
|
||||
* Pulls related artists for the ArtistDetail "Similar artists"
|
||||
* strip. Network-only — non-critical UI, callers swallow failures
|
||||
* and render nothing rather than blocking the screen.
|
||||
*/
|
||||
suspend fun fetchSimilarArtists(id: String): List<ArtistRef> =
|
||||
api.getSimilarArtists(id).map { it.toDomain() }
|
||||
|
||||
/**
|
||||
* Pulls the artist's top tracks for the ArtistDetail "Top tracks"
|
||||
* panel. Network-only; tapping a row plays this exact list as a
|
||||
* queue, so no persistence is needed.
|
||||
*/
|
||||
suspend fun fetchArtistTopTracks(id: String): List<TrackRef> =
|
||||
api.getArtistTopTracks(id).map { it.toDomain() }
|
||||
|
||||
/**
|
||||
* Pulls the album detail (AlbumRef + tracks), persists both, and
|
||||
* returns the in-memory snapshot. Lets the AlbumDetail screen
|
||||
|
||||
+116
-13
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("TooManyFunctions") // Compose screen + private helper composables
|
||||
|
||||
package com.fabledsword.minstrel.library.ui
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
@@ -18,6 +20,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items as lazyItems
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -46,21 +49,27 @@ import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Play
|
||||
import com.composables.icons.lucide.User
|
||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||
import com.fabledsword.minstrel.models.ArtistDetailRef
|
||||
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
||||
import com.fabledsword.minstrel.models.ArtistRef
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.models.albumCoverPath
|
||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ArtistDetailScreen(
|
||||
navController: NavHostController,
|
||||
viewModel: ArtistDetailViewModel = hiltViewModel(),
|
||||
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
@@ -102,23 +111,35 @@ fun ArtistDetailScreen(
|
||||
title = "Couldn't load artist",
|
||||
body = s.message,
|
||||
)
|
||||
is ArtistDetailUiState.Success -> {
|
||||
val artistLiked by viewModel.artistLiked
|
||||
.collectAsStateWithLifecycle()
|
||||
ArtistBody(
|
||||
detail = s.detail,
|
||||
artistLiked = artistLiked,
|
||||
onPlay = viewModel::playArtist,
|
||||
onToggleLike = viewModel::toggleLikeArtist,
|
||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||
)
|
||||
}
|
||||
is ArtistDetailUiState.Success ->
|
||||
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistSuccessBody(
|
||||
state: ArtistDetailUiState.Success,
|
||||
viewModel: ArtistDetailViewModel,
|
||||
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
|
||||
navController: NavHostController,
|
||||
) {
|
||||
val artistLiked by viewModel.artistLiked.collectAsStateWithLifecycle()
|
||||
val playerState by playerViewModel.uiState.collectAsStateWithLifecycle()
|
||||
ArtistBody(
|
||||
state = state,
|
||||
artistLiked = artistLiked,
|
||||
playingTrackId = playerState.currentTrack?.id,
|
||||
onPlay = viewModel::playArtist,
|
||||
onToggleLike = viewModel::toggleLikeArtist,
|
||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||
onTopTrackClick = viewModel::playTopTracks,
|
||||
onSimilarArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun titleFor(state: ArtistDetailUiState): String = when (state) {
|
||||
is ArtistDetailUiState.Success -> state.detail.artist.name
|
||||
is ArtistDetailUiState.Loading -> state.seed?.name ?: "Artist"
|
||||
@@ -127,12 +148,16 @@ private fun titleFor(state: ArtistDetailUiState): String = when (state) {
|
||||
|
||||
@Composable
|
||||
private fun ArtistBody(
|
||||
detail: ArtistDetailRef,
|
||||
state: ArtistDetailUiState.Success,
|
||||
artistLiked: Boolean,
|
||||
playingTrackId: String?,
|
||||
onPlay: () -> Unit,
|
||||
onToggleLike: () -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
onTopTrackClick: (Int) -> Unit,
|
||||
onSimilarArtistClick: (String) -> Unit,
|
||||
) {
|
||||
val detail = state.detail
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 176.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -149,6 +174,23 @@ private fun ArtistBody(
|
||||
onToggleLike = onToggleLike,
|
||||
)
|
||||
}
|
||||
if (state.topTracks.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
TopTracksPanel(
|
||||
tracks = state.topTracks,
|
||||
playingTrackId = playingTrackId,
|
||||
onTrackClick = onTopTrackClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.similarArtists.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
SimilarArtistsStrip(
|
||||
artists = state.similarArtists,
|
||||
onArtistClick = onSimilarArtistClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (detail.albums.isEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
EmptyAlbumsHint()
|
||||
@@ -161,6 +203,67 @@ private fun ArtistBody(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopTracksPanel(
|
||||
tracks: List<TrackRef>,
|
||||
playingTrackId: String?,
|
||||
onTrackClick: (Int) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = "Top tracks",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
tracks.forEachIndexed { index, track ->
|
||||
TopTrackRow(
|
||||
track = track,
|
||||
nowPlaying = track.id == playingTrackId,
|
||||
onClick = { onTrackClick(index) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopTrackRow(
|
||||
track: TrackRef,
|
||||
nowPlaying: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
TrackRow(
|
||||
title = track.title,
|
||||
artist = track.artistName,
|
||||
trackId = track.id,
|
||||
onClick = onClick,
|
||||
nowPlaying = nowPlaying,
|
||||
trailing = {
|
||||
Text(
|
||||
text = formatDuration(track.durationSec, zero = "--:--"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SimilarArtistsStrip(
|
||||
artists: List<ArtistRef>,
|
||||
onArtistClick: (String) -> Unit,
|
||||
) {
|
||||
HorizontalScrollRow(title = "Similar artists") {
|
||||
lazyItems(items = artists, key = { it.id }) { artist ->
|
||||
ArtistCard(
|
||||
artist = artist,
|
||||
onClick = { onArtistClick(artist.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistHeader(
|
||||
artist: ArtistRef,
|
||||
|
||||
+49
-1
@@ -9,6 +9,7 @@ import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.models.ArtistDetailRef
|
||||
import com.fabledsword.minstrel.models.ArtistRef
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
@@ -23,13 +24,18 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
|
||||
sealed interface ArtistDetailUiState {
|
||||
data class Loading(val seed: ArtistRef? = null) : ArtistDetailUiState
|
||||
data class Success(val detail: ArtistDetailRef) : ArtistDetailUiState
|
||||
data class Success(
|
||||
val detail: ArtistDetailRef,
|
||||
val similarArtists: List<ArtistRef> = emptyList(),
|
||||
val topTracks: List<TrackRef> = emptyList(),
|
||||
) : ArtistDetailUiState
|
||||
data class Error(val message: String) : ArtistDetailUiState
|
||||
}
|
||||
|
||||
@@ -82,6 +88,7 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
try {
|
||||
val detail = repository.refreshArtistDetail(artistId)
|
||||
internal.value = ArtistDetailUiState.Success(detail)
|
||||
loadSecondarySections()
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
@@ -89,6 +96,31 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls the non-critical "Similar artists" + "Top tracks" sections
|
||||
* and folds them into the live Success state. Each failure is
|
||||
* swallowed (logged only) — these sections render absent rather than
|
||||
* blocking, so the core artist view never depends on them.
|
||||
*/
|
||||
private suspend fun loadSecondarySections() {
|
||||
val similar = sectionOrEmpty { repository.fetchSimilarArtists(artistId) }
|
||||
val top = sectionOrEmpty { repository.fetchArtistTopTracks(artistId) }
|
||||
val current = internal.value
|
||||
if (current is ArtistDetailUiState.Success) {
|
||||
internal.value = current.copy(similarArtists = similar, topTracks = top)
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <T> sectionOrEmpty(block: () -> List<T>): List<T> =
|
||||
try {
|
||||
block()
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
Timber.w(e, "Artist secondary section fetch failed for %s", artistId)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull every track across the artist's albums, shuffle, and start
|
||||
* playing. Mirrors Flutter's artist Play button — pressing Play on
|
||||
@@ -118,4 +150,20 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the loaded "Top tracks" list starting at [index] — the top
|
||||
* tracks are themselves the playable queue (mirrors the web client's
|
||||
* sectionTracks = top-tracks behaviour). No network round-trip; the
|
||||
* list is already in the Success state.
|
||||
*/
|
||||
fun playTopTracks(index: Int) {
|
||||
val tracks = (internal.value as? ArtistDetailUiState.Success)?.topTracks.orEmpty()
|
||||
if (tracks.isEmpty()) return
|
||||
player.setQueue(
|
||||
tracks = tracks,
|
||||
initialIndex = index.coerceIn(0, tracks.lastIndex),
|
||||
source = "artist-top:$artistId",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ data class PlaylistRef(
|
||||
val trackCount: Int = 0,
|
||||
val coverUrl: String = "",
|
||||
val ownerUsername: String = "",
|
||||
/** All member tracks resident in the audio cache — playable fully offline. */
|
||||
val fullyCached: Boolean = false,
|
||||
) {
|
||||
val isSystem: Boolean get() = systemVariant != null
|
||||
|
||||
@@ -30,6 +32,13 @@ data class PlaylistRef(
|
||||
* expose a refresh trigger; rebuilds are scheduler-driven).
|
||||
*/
|
||||
val refreshable: Boolean get() = isSystem && systemVariant != "songs_like_artist"
|
||||
|
||||
/**
|
||||
* Can't be relied on while offline — either it needs the live server to
|
||||
* (re)generate (a refreshable system mix) or not all its tracks are cached.
|
||||
* Callers combine this with the current offline state to grey the tile.
|
||||
*/
|
||||
val unavailableOffline: Boolean get() = refreshable || !fullyCached
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -58,59 +58,98 @@ class AudioPrefetcher @Inject constructor(
|
||||
private val activeJobs = mutableMapOf<String, Job>()
|
||||
private val mutex = Mutex()
|
||||
|
||||
private data class ReconcileInput(
|
||||
val queue: List<Pair<String, String>>,
|
||||
val index: Int,
|
||||
val window: Int,
|
||||
val isPlaying: Boolean,
|
||||
)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
combine(
|
||||
playerController.uiState.map { it.queue.map { t -> t.id to t.streamUrl } },
|
||||
playerController.uiState.map { it.queueIndex },
|
||||
authStore.cacheSettings.map { it.prefetchWindow },
|
||||
) { queue, index, window -> Triple(queue, index, window) }
|
||||
playerController.uiState.map { it.isPlaying },
|
||||
) { queue, index, window, isPlaying ->
|
||||
ReconcileInput(queue, index, window, isPlaying)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { (queue, index, window) -> reconcile(queue, index, window) }
|
||||
.collect { input ->
|
||||
reconcile(input.queue, input.index, input.window, input.isPlaying)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the prefetch window to the current queue.
|
||||
*
|
||||
* Cancellation of out-of-window jobs always runs -- a queue mutation
|
||||
* or skip should free bandwidth from stale prefetches immediately.
|
||||
* Starting new prefetches is gated on [isPlaying]: until the current
|
||||
* track is actually playing, every byte of upstream bandwidth should
|
||||
* land on it, not on upcoming-track prefetches. Without this gate a
|
||||
* cold start fanned out 4-6 concurrent CacheWriter jobs against the
|
||||
* same OkHttp client as the playback DataSource and the user waited
|
||||
* ~25 s for the first audio to start; with the gate the current
|
||||
* track gets the full pipe to its first STATE_READY, then the
|
||||
* prefetcher fills in the next window.
|
||||
*/
|
||||
private suspend fun reconcile(
|
||||
queue: List<Pair<String, String>>,
|
||||
index: Int,
|
||||
window: Int,
|
||||
isPlaying: Boolean,
|
||||
) {
|
||||
mutex.withLock {
|
||||
if (index < 0 || queue.isEmpty() || window <= 0) {
|
||||
val targets = computeTargets(queue, index, window)
|
||||
if (targets.isEmpty()) {
|
||||
cancelAllLocked()
|
||||
return
|
||||
}
|
||||
// Exclude the currently-playing track (it's loaded by the
|
||||
// player itself) and walk `window` tracks forward.
|
||||
val firstIdx = (index + 1).coerceAtMost(queue.size)
|
||||
val lastIdx = (index + window).coerceAtMost(queue.size - 1)
|
||||
if (firstIdx > lastIdx) {
|
||||
cancelAllLocked()
|
||||
return
|
||||
}
|
||||
val targets = queue.subList(firstIdx, lastIdx + 1)
|
||||
val targetIds = targets.mapTo(mutableSetOf()) { it.first }
|
||||
// Cancellation always runs so a queue mutation or skip frees
|
||||
// the pipe immediately, even while paused.
|
||||
cancelOutOfWindowLocked(targetIds)
|
||||
if (isPlaying) startInWindowLocked(targets)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel jobs for tracks that have slid out of the window.
|
||||
activeJobs.entries
|
||||
.filter { it.key !in targetIds }
|
||||
.toList()
|
||||
.forEach { (id, job) ->
|
||||
job.cancel()
|
||||
activeJobs.remove(id)
|
||||
}
|
||||
private fun computeTargets(
|
||||
queue: List<Pair<String, String>>,
|
||||
index: Int,
|
||||
window: Int,
|
||||
): List<Pair<String, String>> {
|
||||
// Exclude the currently-playing track (it's loaded by the player
|
||||
// itself) and walk `window` tracks forward.
|
||||
val firstIdx = index + 1
|
||||
val lastIdx = (index + window).coerceAtMost(queue.size - 1)
|
||||
val isValid = index >= 0 && queue.isNotEmpty() && window > 0 && firstIdx <= lastIdx
|
||||
return if (isValid) queue.subList(firstIdx, lastIdx + 1) else emptyList()
|
||||
}
|
||||
|
||||
// Start prefetches for new arrivals. Skip blank URLs (these
|
||||
// come from minimal TrackRefs synthesized from playlist rows
|
||||
// when the upstream track was removed from the library).
|
||||
for ((trackId, streamUrl) in targets) {
|
||||
if (trackId in activeJobs || streamUrl.isBlank()) continue
|
||||
val job = scope.launch(Dispatchers.IO) {
|
||||
runCatching { prefetchOne(trackId, streamUrl) }
|
||||
mutex.withLock { activeJobs.remove(trackId) }
|
||||
}
|
||||
activeJobs[trackId] = job
|
||||
private fun cancelOutOfWindowLocked(targetIds: Set<String>) {
|
||||
activeJobs.entries
|
||||
.filter { it.key !in targetIds }
|
||||
.toList()
|
||||
.forEach { (id, job) ->
|
||||
job.cancel()
|
||||
activeJobs.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startInWindowLocked(targets: List<Pair<String, String>>) {
|
||||
// Skip blank URLs (these come from minimal TrackRefs synthesized
|
||||
// from playlist rows when the upstream track was removed from
|
||||
// the library).
|
||||
for ((trackId, streamUrl) in targets) {
|
||||
if (trackId in activeJobs || streamUrl.isBlank()) continue
|
||||
val job = scope.launch(Dispatchers.IO) {
|
||||
runCatching { prefetchOne(trackId, streamUrl) }
|
||||
mutex.withLock { activeJobs.remove(trackId) }
|
||||
}
|
||||
activeJobs[trackId] = job
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+587
@@ -0,0 +1,587 @@
|
||||
@file:Suppress("TooManyFunctions") // Mirrors Player surface: ~16 methods is the API.
|
||||
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.SystemClock
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.media3.common.ForwardingPlayer
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.selects.onTimeout
|
||||
import kotlinx.coroutines.selects.select
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Integration point for UPnP transport parity. Wraps the local
|
||||
* ExoPlayer; every transport method either forwards (local route
|
||||
* active -- the default) or translates into AVTransport SOAP +
|
||||
* [RemotePlayerState] updates (UPnP route active).
|
||||
*
|
||||
* Created inside [MinstrelPlayerService]; runs on the service's main
|
||||
* looper. Network SOAP calls fire on [Dispatchers.IO]. While UPnP is
|
||||
* active, the MediaSession's reads of [Player.isPlaying] and
|
||||
* [Player.getCurrentPosition] pull from [RemotePlayerState]; the
|
||||
* wrapped ExoPlayer stays paused at the position it had when the
|
||||
* route was selected.
|
||||
*
|
||||
* Drop heuristic: the 1 Hz poll loop is the *sole* arbiter of route
|
||||
* liveness -- [RemotePlayerState.recordPollFailure]'s rolling threshold
|
||||
* (DROP_THRESHOLD consecutive failures) fires [onDrop]. A failed transport
|
||||
* command (play/pause/seek/next) does NOT drop on its own: a locked phone's
|
||||
* WiFi power-save can stall a single command's socket I/O for a second or
|
||||
* two while the renderer is perfectly reachable, so commands retry on
|
||||
* transient IO failure and otherwise defer to the poll loop. The factory
|
||||
* wraps the [onDrop] callback into a SharedFlow consumed by the NowPlaying
|
||||
* surface as a snackbar.
|
||||
*
|
||||
* Queue mode: OutputPickerController loads the full queue into Sonos's
|
||||
* native queue via ClearQueue + AddURIToQueue, then points the
|
||||
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
|
||||
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
|
||||
* advance natively. PollLoop syncs the local cursor by comparing the
|
||||
* 1-based Track index from GetPositionInfo.
|
||||
*/
|
||||
class MinstrelForwardingPlayer(
|
||||
private val delegate: Player,
|
||||
private val holder: ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val onDrop: (routeName: String) -> Unit,
|
||||
) : ForwardingPlayer(delegate) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val handler = Handler(delegate.applicationLooper)
|
||||
private var pollJob: Job? = null
|
||||
|
||||
// Tracks consecutive non-PLAYING poll observations so a single transient
|
||||
// PAUSED_PLAYBACK / STOPPED tick during a Sonos track transition does not
|
||||
// flip the play/pause button. Manual pause still feels instant because it
|
||||
// bypasses the poll entirely via applyTransportPaused().
|
||||
@Volatile private var nonPlayingPollStreak = 0
|
||||
|
||||
// Wall-clock of the most-recent within-track seek we issued to Sonos.
|
||||
// pollOnce uses this to suppress position overwrites for SEEK_ACK_WINDOW_MS
|
||||
// -- Sonos can take 1-2s to apply a Seek, and a poll landing inside that
|
||||
// window reports the *old* position. Without the lockout the scrubber
|
||||
// visibly jumps backwards immediately after a drag, then forwards again.
|
||||
@Volatile private var lastSeekIssuedAtMs: Long = 0L
|
||||
|
||||
// Wake channel for the poll loop. requestImmediatePoll() trySend's a Unit;
|
||||
// pollLoop's select{} races the delay against this channel so the next
|
||||
// pollOnce can fire immediately instead of waiting up to POLL_INTERVAL_MS.
|
||||
// Used on activity resume (ProcessLifecycleOwner.ON_RESUME) so the UI
|
||||
// catches up to Sonos within RTT rather than the full poll cadence.
|
||||
// CONFLATED so repeated trySend's between polls don't queue up.
|
||||
private val pollTrigger = Channel<Unit>(Channel.CONFLATED)
|
||||
|
||||
// External Player.Listener registry (separate from super.addListener which
|
||||
// forwards to the wrapped ExoPlayer). The wrapped player is paused with
|
||||
// no audio loaded while UPnP is active, so it never fires events for our
|
||||
// synthesized remote state -- the MediaSession's notification card and
|
||||
// lock-screen scrubber stay frozen on whatever state was last captured
|
||||
// before UPnP took over. We dual-register: super.addListener keeps the
|
||||
// listener attached to the delegate (so local-playback events still
|
||||
// reach it), AND we hold a ref here so we can directly invoke listener
|
||||
// callbacks on remote-state changes. The listener's read of isPlaying /
|
||||
// duration / position then routes through our overrides to remoteState.
|
||||
private val externalListeners = mutableListOf<Player.Listener>()
|
||||
|
||||
@Volatile private var lastNotifiedIsPlaying: Boolean = false
|
||||
@Volatile private var lastNotifiedTrackIdx: Int = -1
|
||||
|
||||
private val lifecycleObserver = object : DefaultLifecycleObserver {
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
pollTrigger.trySend(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
holder.active.collect { active -> onActiveChanged(active) }
|
||||
}
|
||||
// Process lifecycle is observed on the main thread; ProcessLifecycleOwner's
|
||||
// addObserver requires it. The observer just trySend's to the channel.
|
||||
handler.post {
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isRemote(): Boolean = holder.active.value != null
|
||||
|
||||
/**
|
||||
* Returns true when selectUpnp has marked a UPnP route as the intended
|
||||
* target but loadQueueOnSonos hasn't yet wired ActiveUpnp. During this
|
||||
* window we drop transport commands silently -- they would hit Sonos's
|
||||
* stale state from a prior session and trigger restarts.
|
||||
*/
|
||||
private fun isLoadingUpnp(): Boolean =
|
||||
holder.target.value != null && holder.active.value == null
|
||||
|
||||
// ─── setMediaItems intercepts ──────────────────────────────────────
|
||||
// When PlayerController.setQueue replaces the queue while Sonos is the
|
||||
// active route, the wrapped delegate's queue gets the new items but
|
||||
// Sonos's native queue still holds the OLD tracks -- and the play()
|
||||
// that PlayerController fires immediately after setMediaItems would
|
||||
// resume the old Sonos queue (user reported on-device: "player view
|
||||
// updates but Sonos queue does not"). We clear active + set target
|
||||
// synchronously here so the next play() in the same IPC sequence
|
||||
// drops via isLoadingUpnp() = true; the OutputPickerController
|
||||
// observes the uiState.queue change and runs the resync (re-clears
|
||||
// Sonos's native queue + AddURIToQueue the new tracks + Play).
|
||||
|
||||
override fun setMediaItems(mediaItems: List<MediaItem>) {
|
||||
super.setMediaItems(mediaItems)
|
||||
markPendingResyncIfRemote()
|
||||
}
|
||||
|
||||
override fun setMediaItems(mediaItems: List<MediaItem>, resetPosition: Boolean) {
|
||||
super.setMediaItems(mediaItems, resetPosition)
|
||||
markPendingResyncIfRemote()
|
||||
}
|
||||
|
||||
override fun setMediaItems(mediaItems: List<MediaItem>, startIndex: Int, startPositionMs: Long) {
|
||||
super.setMediaItems(mediaItems, startIndex, startPositionMs)
|
||||
markPendingResyncIfRemote()
|
||||
}
|
||||
|
||||
private fun markPendingResyncIfRemote() {
|
||||
val wasActive = holder.active.value ?: return
|
||||
Timber.w(
|
||||
"setMediaItems while UPnP active (%s) -- marking pending resync",
|
||||
wasActive.routeName,
|
||||
)
|
||||
holder.set(null)
|
||||
holder.setTarget(wasActive.routeId)
|
||||
}
|
||||
|
||||
override fun play() {
|
||||
if (isLoadingUpnp()) {
|
||||
Timber.w("ForwardingPlayer.play() dropped -- UPnP loading")
|
||||
return
|
||||
}
|
||||
val active = holder.active.value
|
||||
Timber.w("ForwardingPlayer.play() active=%s", active?.routeName)
|
||||
if (active == null) {
|
||||
super.play()
|
||||
} else {
|
||||
remoteState.setPlayIntent(true)
|
||||
scope.launch {
|
||||
runCatching { retryTransport { active.avTransport.play() } }
|
||||
.onSuccess {
|
||||
remoteState.applyTransportPlaying()
|
||||
notifyRemoteStateChanged()
|
||||
}
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
if (isLoadingUpnp()) {
|
||||
Timber.w("ForwardingPlayer.pause() dropped -- UPnP loading")
|
||||
return
|
||||
}
|
||||
val active = holder.active.value
|
||||
Timber.w("ForwardingPlayer.pause() active=%s", active?.routeName)
|
||||
if (active == null) {
|
||||
super.pause()
|
||||
} else {
|
||||
remoteState.setPlayIntent(false)
|
||||
scope.launch {
|
||||
runCatching { retryTransport { active.avTransport.pause() } }
|
||||
.onSuccess {
|
||||
remoteState.applyTransportPaused()
|
||||
notifyRemoteStateChanged()
|
||||
}
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun seekTo(positionMs: Long) {
|
||||
if (isLoadingUpnp()) {
|
||||
Timber.w("ForwardingPlayer.seekTo(positionMs) dropped -- UPnP loading")
|
||||
return
|
||||
}
|
||||
val active = holder.active.value
|
||||
Timber.w("ForwardingPlayer.seekTo(%dms) active=%s", positionMs, active?.routeName)
|
||||
if (active == null) {
|
||||
super.seekTo(positionMs)
|
||||
} else {
|
||||
lastSeekIssuedAtMs = SystemClock.elapsedRealtime()
|
||||
remoteState.applyPositionInfo(
|
||||
positionMs = positionMs,
|
||||
durationMs = remoteState.durationMs,
|
||||
trackUri = remoteState.currentTrackUri,
|
||||
trackNumber = remoteState.trackNumber,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { retryTransport { active.avTransport.seek(positionMs) } }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Widget-driven track change (user taps a track in the queue widget).
|
||||
* Seeks Sonos to the correct queue slot, then seeks within-track if
|
||||
* [positionMs] is non-zero.
|
||||
*/
|
||||
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {
|
||||
if (isLoadingUpnp()) {
|
||||
Timber.w("ForwardingPlayer.seekTo(idx, positionMs) dropped -- UPnP loading")
|
||||
return
|
||||
}
|
||||
val active = holder.active.value
|
||||
Timber.w(
|
||||
"ForwardingPlayer.seekTo(idx=%d, %dms) active=%s",
|
||||
mediaItemIndex, positionMs, active?.routeName,
|
||||
)
|
||||
if (active == null) {
|
||||
super.seekTo(mediaItemIndex, positionMs)
|
||||
return
|
||||
}
|
||||
super.seekTo(mediaItemIndex, positionMs)
|
||||
remoteState.beginPendingTransport(
|
||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching {
|
||||
retryTransport {
|
||||
active.avTransport.seekToTrack(mediaItemIndex + 1)
|
||||
if (positionMs > 0L) {
|
||||
active.avTransport.seek(positionMs)
|
||||
}
|
||||
}
|
||||
}.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun seekToNextMediaItem() {
|
||||
if (isLoadingUpnp()) {
|
||||
Timber.w("ForwardingPlayer.seekToNextMediaItem() dropped -- UPnP loading")
|
||||
return
|
||||
}
|
||||
val active = holder.active.value
|
||||
Timber.w("ForwardingPlayer.seekToNextMediaItem() active=%s", active?.routeName)
|
||||
if (active == null) {
|
||||
super.seekToNextMediaItem()
|
||||
return
|
||||
}
|
||||
// Super first for immediate local cursor advance (UI feedback);
|
||||
// then delegate to Sonos Next. PollLoop reconciles cursor via
|
||||
// Track index if they diverge.
|
||||
super.seekToNextMediaItem()
|
||||
remoteState.beginPendingTransport(
|
||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { retryTransport { active.avTransport.next() } }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun seekToPreviousMediaItem() {
|
||||
if (isLoadingUpnp()) {
|
||||
Timber.w("ForwardingPlayer.seekToPreviousMediaItem() dropped -- UPnP loading")
|
||||
return
|
||||
}
|
||||
val active = holder.active.value
|
||||
Timber.w("ForwardingPlayer.seekToPreviousMediaItem() active=%s", active?.routeName)
|
||||
if (active == null) {
|
||||
super.seekToPreviousMediaItem()
|
||||
return
|
||||
}
|
||||
// Super first for immediate local cursor advance (UI feedback);
|
||||
// then delegate to Sonos Previous. PollLoop reconciles.
|
||||
super.seekToPreviousMediaItem()
|
||||
remoteState.beginPendingTransport(
|
||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { retryTransport { active.avTransport.previous() } }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCurrentPosition(): Long =
|
||||
if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
|
||||
|
||||
override fun getDuration(): Long =
|
||||
if (isRemote()) remoteState.durationMs else super.getDuration()
|
||||
|
||||
override fun isPlaying(): Boolean =
|
||||
if (isRemote()) remoteState.isPlaying else super.isPlaying()
|
||||
|
||||
override fun getPlaybackState(): Int =
|
||||
if (isRemote()) Player.STATE_READY else super.getPlaybackState()
|
||||
|
||||
// Mirror remote state so any consumer that gates on playWhenReady --
|
||||
// notably MediaSessionService's foreground-keepalive checks and our own
|
||||
// onTaskRemoved -- sees the remote renderer as the source of truth.
|
||||
// Without this, swiping the app away with Sonos playing would stop the
|
||||
// service, kill the poll loop, and leave Sonos orphaned.
|
||||
override fun getPlayWhenReady(): Boolean =
|
||||
if (isRemote()) remoteState.isPlaying else super.getPlayWhenReady()
|
||||
|
||||
override fun addListener(listener: Player.Listener) {
|
||||
super.addListener(listener)
|
||||
synchronized(externalListeners) { externalListeners.add(listener) }
|
||||
}
|
||||
|
||||
override fun removeListener(listener: Player.Listener) {
|
||||
super.removeListener(listener)
|
||||
synchronized(externalListeners) { externalListeners.remove(listener) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct-invoke the externally-registered Player.Listeners so the
|
||||
* MediaSession's PlaybackState publisher (notification card, lock-screen
|
||||
* scrubber, BT/AVRCP, Auto, Wear OS tile) re-reads our overridden state.
|
||||
* The listeners then query isPlaying / getDuration / getCurrentPosition,
|
||||
* all of which route through to remoteState while UPnP is active.
|
||||
*
|
||||
* Posted to the player's application looper because Player.Listener
|
||||
* callbacks contract on the application thread.
|
||||
*/
|
||||
private fun notifyRemoteStateChanged() {
|
||||
if (!isRemote()) return
|
||||
val playing = remoteState.isPlaying
|
||||
val trackIdx = (remoteState.trackNumber - 1).coerceAtLeast(0)
|
||||
val isPlayingChanged = playing != lastNotifiedIsPlaying
|
||||
val trackChanged = trackIdx != lastNotifiedTrackIdx
|
||||
if (!isPlayingChanged && !trackChanged) return
|
||||
lastNotifiedIsPlaying = playing
|
||||
lastNotifiedTrackIdx = trackIdx
|
||||
val snapshot = synchronized(externalListeners) { externalListeners.toList() }
|
||||
handler.post {
|
||||
for (l in snapshot) {
|
||||
if (isPlayingChanged) {
|
||||
l.onIsPlayingChanged(playing)
|
||||
l.onPlaybackStateChanged(Player.STATE_READY)
|
||||
}
|
||||
if (trackChanged) {
|
||||
val item = if (trackIdx < delegate.mediaItemCount) {
|
||||
delegate.getMediaItemAt(trackIdx)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
l.onMediaItemTransition(item, Player.MEDIA_ITEM_TRANSITION_REASON_AUTO)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
pollJob?.cancel()
|
||||
scope.cancel()
|
||||
handler.post {
|
||||
ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver)
|
||||
}
|
||||
super.release()
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a transport SOAP block, retrying transport-level (IO) failures a
|
||||
* few times with backoff. A locked phone's WiFi power-save can stall the
|
||||
* first socket I/O for a second or two; a retry lets the command land once
|
||||
* WiFi wakes instead of being abandoned. A [SoapFaultException] (the
|
||||
* renderer answered and rejected the action) is NOT retried -- the device
|
||||
* is alive and retrying won't change its verdict.
|
||||
*/
|
||||
private suspend fun <T> retryTransport(block: suspend () -> T): T =
|
||||
retryTransientIo(TRANSPORT_RETRY_ATTEMPTS, TRANSPORT_RETRY_BACKOFF_MS, block)
|
||||
|
||||
/**
|
||||
* A transport SOAP command failed even after retries. Deliberately does
|
||||
* NOT declare the route dropped: the 1 Hz poll loop is the single arbiter
|
||||
* of liveness (DROP_THRESHOLD consecutive poll failures). A locked phone's
|
||||
* WiFi power-save can fail one command while the renderer is perfectly
|
||||
* reachable; dropping on a single command falsely kicked playback back to
|
||||
* the phone. We log, leave the poll loop running, and nudge an immediate
|
||||
* poll so the UI reconciles to Sonos's actual state -- if the renderer is
|
||||
* truly gone, the poll loop trips the drop on its own.
|
||||
*/
|
||||
private fun handleTransportFailure(active: ActiveUpnp, t: Throwable) {
|
||||
if (t is SoapFaultException) {
|
||||
Timber.w(t, "UPnP transport rejected by %s -- device alive, no drop", active.routeName)
|
||||
} else {
|
||||
Timber.w(t, "UPnP transport failed on %s -- poll loop arbitrates", active.routeName)
|
||||
}
|
||||
pollTrigger.trySend(Unit)
|
||||
}
|
||||
|
||||
private fun onActiveChanged(active: ActiveUpnp?) {
|
||||
pollJob?.cancel()
|
||||
nonPlayingPollStreak = 0
|
||||
// Reset notify cache so the first poll after a route flip republishes
|
||||
// playing/track state to the MediaSession even if it happens to match
|
||||
// the prior session's values numerically.
|
||||
lastNotifiedIsPlaying = false
|
||||
lastNotifiedTrackIdx = -1
|
||||
if (active != null) {
|
||||
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
|
||||
// Pause the wrapped ExoPlayer so we are not playing local audio
|
||||
// simultaneously with the remote renderer. handler.post targets the
|
||||
// application looper, so this runs on the same thread that processes
|
||||
// our override calls -- no race with the pause() override branching
|
||||
// to SOAP (holder.active is already non-null by the time this post
|
||||
// fires, but delegate.pause() bypasses the override entirely).
|
||||
handler.post { delegate.pause() }
|
||||
pollJob = scope.launch { pollLoop(active) }
|
||||
} else {
|
||||
remoteState.reset()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
|
||||
private suspend fun pollLoop(active: ActiveUpnp) {
|
||||
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
|
||||
val outcome = runCatching { pollOnce(active) }
|
||||
if (outcome.isSuccess) {
|
||||
remoteState.recordPollSuccess()
|
||||
} else if (remoteState.recordPollFailure()) {
|
||||
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
|
||||
handler.post { onDrop(active.routeName) }
|
||||
return
|
||||
}
|
||||
// Race the normal cadence against any external wake (activity
|
||||
// resume). Whichever wins continues to the next pollOnce.
|
||||
select<Unit> {
|
||||
onTimeout(POLL_INTERVAL_MS) {}
|
||||
pollTrigger.onReceive {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One poll tick: read position + transport state from Sonos, apply to
|
||||
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
||||
* index when not in queue load.
|
||||
*
|
||||
* Cursor sync is gated on `holder.target == null` (= not loading)
|
||||
* because during load Sonos reports Track=1 while we're still
|
||||
* appending, and syncing would race the SetAV+Seek that lands
|
||||
* after. Outside load, forward sync catches Sonos auto-advances
|
||||
* (queue end-of-track), Sonos-app driven Next presses, and any
|
||||
* drift after a brief poll-failure burst that didn't trip the
|
||||
* drop threshold. Forward-only because a Next override we just
|
||||
* issued can race with a poll still reporting the prior Track --
|
||||
* the next poll catches up safely.
|
||||
*/
|
||||
private suspend fun pollOnce(active: ActiveUpnp) {
|
||||
val info = active.avTransport.getPositionInfo()
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val inSeekAckWindow = lastSeekIssuedAtMs > 0L &&
|
||||
(now - lastSeekIssuedAtMs) < SEEK_ACK_WINDOW_MS
|
||||
// Inside the seek-ack window, keep the optimistic position we wrote in
|
||||
// seekTo -- the poll's reported position is stale until Sonos finishes
|
||||
// processing the Seek SOAP. Other fields still refresh from the poll.
|
||||
remoteState.applyPositionInfo(
|
||||
positionMs = if (inSeekAckWindow) remoteState.positionMs else info.relTimeMs,
|
||||
durationMs = info.trackDurationMs,
|
||||
trackUri = info.trackUri,
|
||||
trackNumber = info.track,
|
||||
)
|
||||
maybeSyncLocalCursor(info.track)
|
||||
val transport = active.avTransport.getTransportInfo()
|
||||
when (transport.state) {
|
||||
TransportState.PLAYING -> {
|
||||
nonPlayingPollStreak = 0
|
||||
remoteState.applyTransportPlaying()
|
||||
}
|
||||
TransportState.PAUSED -> {
|
||||
nonPlayingPollStreak += 1
|
||||
if (nonPlayingPollStreak >= NON_PLAYING_CONFIRM) {
|
||||
remoteState.applyTransportPaused()
|
||||
}
|
||||
}
|
||||
TransportState.STOPPED -> {
|
||||
nonPlayingPollStreak += 1
|
||||
if (nonPlayingPollStreak >= NON_PLAYING_CONFIRM) {
|
||||
remoteState.applyTransportStopped()
|
||||
}
|
||||
}
|
||||
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
|
||||
}
|
||||
notifyRemoteStateChanged()
|
||||
}
|
||||
|
||||
private fun maybeSyncLocalCursor(sonosTrack: Int) {
|
||||
if (holder.target.value != null) return
|
||||
if (sonosTrack <= 0) return
|
||||
val sonosIdx = sonosTrack - 1
|
||||
handler.post {
|
||||
val localIdx = delegate.currentMediaItemIndex
|
||||
if (sonosIdx > localIdx && sonosIdx < delegate.mediaItemCount) {
|
||||
Timber.w(
|
||||
"UPnP cursor catch-up: local=%d -> sonos=%d",
|
||||
localIdx, sonosIdx,
|
||||
)
|
||||
delegate.seekTo(sonosIdx, 0L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val POLL_INTERVAL_MS = 1_000L
|
||||
const val NON_PLAYING_CONFIRM = 2
|
||||
const val SEEK_ACK_WINDOW_MS = 2_000L
|
||||
// Safety upper bound on how long the polling tick will wait for
|
||||
// Sonos to ack a user transport. The common case clears event-driven
|
||||
// when Sonos's reported Track matches the wrapped player; this only
|
||||
// kicks in if SOAP fails or Sonos drops the ack entirely.
|
||||
const val PENDING_TRANSPORT_SAFETY_TIMEOUT_MS = 5_000L
|
||||
// Transport commands retry transient IO failures so a single WiFi
|
||||
// power-save stall (locked phone) doesn't abandon the command. 3
|
||||
// attempts x the SoapClient's 2s connect timeout + backoff bounds the
|
||||
// worst case at ~7s; a still-failing command then defers to the poll.
|
||||
const val TRANSPORT_RETRY_ATTEMPTS = 3
|
||||
const val TRANSPORT_RETRY_BACKOFF_MS = 400L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry [block] on transient transport-level ([IOException]) failures, with
|
||||
* [backoffMs] between attempts, up to [attempts] total. Anything that is not
|
||||
* an [IOException] -- notably [SoapFaultException], where the renderer
|
||||
* answered and rejected the action -- propagates immediately: the device is
|
||||
* alive, so retrying won't change its verdict. Extracted from
|
||||
* [MinstrelForwardingPlayer] so a locked phone's WiFi power-save stall doesn't
|
||||
* abandon a single transport command (the bug that falsely reverted Sonos
|
||||
* playback to local audio).
|
||||
*/
|
||||
internal suspend fun <T> retryTransientIo(
|
||||
attempts: Int,
|
||||
backoffMs: Long,
|
||||
block: suspend () -> T,
|
||||
): T {
|
||||
var attempt = 0
|
||||
while (true) {
|
||||
try {
|
||||
return block()
|
||||
} catch (io: IOException) {
|
||||
attempt += 1
|
||||
if (attempt >= attempts) throw io
|
||||
Timber.w(io, "transient transport failure (attempt %d) -- retrying", attempt)
|
||||
delay(backoffMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,11 +72,12 @@ class MinstrelPlayerService : MediaSessionService() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val player = playerFactory.build()
|
||||
val player: Player = playerFactory.build()
|
||||
val callback = LikeMediaCallback(likesRepository, serviceScope)
|
||||
val session = MediaSession.Builder(this, player)
|
||||
.setSessionActivity(buildNowPlayingPendingIntent())
|
||||
.setCallback(callback)
|
||||
.setBitmapLoader(playerFactory.buildBitmapLoader())
|
||||
.setMediaButtonPreferences(ImmutableList.of(buildLikeButton(isLiked = false)))
|
||||
.build()
|
||||
mediaSession = session
|
||||
@@ -176,7 +177,11 @@ class MinstrelPlayerService : MediaSessionService() {
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
val player = mediaSession?.player ?: return super.onTaskRemoved(rootIntent)
|
||||
val activelyPlaying = player.playWhenReady && player.playbackState != Player.STATE_ENDED
|
||||
// player.isPlaying is overridden on MinstrelForwardingPlayer to return
|
||||
// remoteState.isPlaying while UPnP is active, so a swipe-away with
|
||||
// Sonos playing keeps the service (and its UPnP poll loop) alive.
|
||||
val activelyPlaying = player.isPlaying ||
|
||||
(player.playWhenReady && player.playbackState != Player.STATE_ENDED)
|
||||
if (!activelyPlaying) {
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DataSpec
|
||||
import androidx.media3.datasource.TransferListener
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
|
||||
/**
|
||||
* DataSource wrapper that fails the network read immediately when
|
||||
* [NetworkStatusController] reports a gating state. CacheDataSource only
|
||||
* calls this upstream factory on cache misses, so playback of cached audio is
|
||||
* unaffected -- only "tap a non-cached track while offline" hits this branch
|
||||
* and gets a fast, meaningful error instead of a multi-second network timeout
|
||||
* (which then surfaced as a silent decode failure to the user).
|
||||
*
|
||||
* Wrapping rather than substituting the OkHttp data source lets the cache
|
||||
* write path remain intact for when health returns and we DO want to fetch:
|
||||
* we keep the same upstream all the time, just gate `open()`.
|
||||
*/
|
||||
class OfflineGatedDataSource(
|
||||
private val delegate: DataSource,
|
||||
private val health: NetworkStatusController,
|
||||
) : DataSource {
|
||||
|
||||
override fun open(dataSpec: DataSpec): Long {
|
||||
gateOnHealth()
|
||||
return try {
|
||||
val opened = delegate.open(dataSpec)
|
||||
health.reportSuccess() // bytes flowing from the server == reachable
|
||||
opened
|
||||
} catch (e: IOException) {
|
||||
health.reportFailure() // real network read failed → arbitrate via /healthz
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** Fast-fail before touching the network when the server can't be reached. */
|
||||
private fun gateOnHealth() {
|
||||
when (health.state.value) {
|
||||
ServerHealth.Offline -> throw OfflineException(
|
||||
"Track not in the on-device cache and the device is offline.",
|
||||
)
|
||||
ServerHealth.ServerDown -> throw OfflineException(
|
||||
"Track not in the on-device cache and the Minstrel server is unreachable.",
|
||||
)
|
||||
// Unstable is non-gating: still try the network. Healthy too.
|
||||
ServerHealth.Unstable, ServerHealth.Healthy -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() = delegate.close()
|
||||
override fun getUri() = delegate.uri
|
||||
override fun read(buffer: ByteArray, offset: Int, length: Int): Int =
|
||||
delegate.read(buffer, offset, length)
|
||||
override fun addTransferListener(transferListener: TransferListener) =
|
||||
delegate.addTransferListener(transferListener)
|
||||
override fun getResponseHeaders() = delegate.responseHeaders
|
||||
}
|
||||
|
||||
class OfflineGatedDataSourceFactory(
|
||||
private val upstream: DataSource.Factory,
|
||||
private val health: NetworkStatusController,
|
||||
) : DataSource.Factory {
|
||||
override fun createDataSource(): DataSource =
|
||||
OfflineGatedDataSource(upstream.createDataSource(), health)
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals the audio-source error path that the request was denied because the
|
||||
* device is offline / the server is unreachable. ExoPlayer's [androidx.media3
|
||||
* .common.PlaybackException] catches it via [InterruptedIOException]'s
|
||||
* `IOException` ancestor and surfaces it as a SOURCE error, which then flows
|
||||
* through the existing [PlaybackErrorReporter] -> snackbar path.
|
||||
*/
|
||||
class OfflineException(message: String) : IOException(message)
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
@@ -40,6 +41,7 @@ private const val DEBOUNCE_MS = 2_000L
|
||||
class PlaybackErrorReporter @Inject constructor(
|
||||
private val playerController: PlayerController,
|
||||
private val repository: PlaybackErrorRepository,
|
||||
private val networkStatus: NetworkStatusController,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
) {
|
||||
private val outChannel = Channel<String>(Channel.BUFFERED)
|
||||
@@ -52,6 +54,10 @@ class PlaybackErrorReporter @Inject constructor(
|
||||
val buffer = mutableListOf<String>()
|
||||
var debounceJob: kotlinx.coroutines.Job? = null
|
||||
playerController.playbackErrorEvents.collect { event ->
|
||||
// A track failing to play is ambiguous (dead server vs. one bad
|
||||
// file) — let the controller arbitrate via /healthz. No-op when
|
||||
// already Offline; cheap otherwise.
|
||||
networkStatus.reportFailure()
|
||||
// Fire-and-forget the server report — repository handles
|
||||
// success/queue branching so callers don't see throws.
|
||||
scope.launch { repository.report(event) }
|
||||
|
||||
@@ -5,6 +5,8 @@ import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Player
|
||||
@@ -20,8 +22,10 @@ import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -59,7 +63,17 @@ class PlayerController @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
private val radio: RadioController,
|
||||
private val playerFactory: PlayerFactory,
|
||||
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
) {
|
||||
|
||||
/**
|
||||
* UPnP drop events surfaced from [PlayerFactory.dropEvents]. NowPlaying
|
||||
* collects this into its snackbar host so a transport / poll failure
|
||||
* during UPnP playback shows "Disconnected from <name>" to the user.
|
||||
*/
|
||||
val dropEvents: SharedFlow<String> = playerFactory.dropEvents
|
||||
private val sessionToken =
|
||||
SessionToken(context, ComponentName(context, MinstrelPlayerService::class.java))
|
||||
|
||||
@@ -130,11 +144,24 @@ class PlayerController @Inject constructor(
|
||||
|
||||
// ── Transport (no-op until the controller is connected) ──────────────
|
||||
|
||||
fun play() { mediaController?.play() }
|
||||
fun pause() { mediaController?.pause() }
|
||||
fun seekTo(positionMs: Long) { mediaController?.seekTo(positionMs) }
|
||||
fun skipToNext() { mediaController?.seekToNextMediaItem() }
|
||||
fun skipToPrevious() { mediaController?.seekToPreviousMediaItem() }
|
||||
// Each transport call must run on the MediaController's
|
||||
// applicationLooper; calling from a background coroutine throws
|
||||
// IllegalStateException (see PlayerController.setQueue's note).
|
||||
// UI tap handlers are already on Main so the in-place branch hits;
|
||||
// the background path only fires for cross-thread callers like
|
||||
// OutputPickerController.selectUpnp (which calls pause() after
|
||||
// handing playback off to a UPnP renderer).
|
||||
fun play() { mediaController?.let { runOnControllerThread(it) { it.play() } } }
|
||||
fun pause() { mediaController?.let { runOnControllerThread(it) { it.pause() } } }
|
||||
fun seekTo(positionMs: Long) {
|
||||
mediaController?.let { runOnControllerThread(it) { it.seekTo(positionMs) } }
|
||||
}
|
||||
fun skipToNext() {
|
||||
mediaController?.let { runOnControllerThread(it) { it.seekToNextMediaItem() } }
|
||||
}
|
||||
fun skipToPrevious() {
|
||||
mediaController?.let { runOnControllerThread(it) { it.seekToPreviousMediaItem() } }
|
||||
}
|
||||
|
||||
/** Flip shuffle on/off. Media3 emits onEvents → uiState reflects. */
|
||||
fun toggleShuffle() {
|
||||
@@ -299,7 +326,15 @@ class PlayerController @Inject constructor(
|
||||
* and advance past the dead track. Otherwise no-op.
|
||||
*/
|
||||
private fun handleZeroDurationIfNeeded(controller: MediaController, idx: Int) {
|
||||
val current = queueRefs.getOrNull(idx) ?: return
|
||||
// Skip during UPnP playback (active) AND during the activation load
|
||||
// window (target set, active not yet wired). ExoPlayer is intentionally
|
||||
// paused throughout both windows so its STATE_READY duration is always
|
||||
// 0 / TIME_UNSET -- without this guard we rapid-advance through the
|
||||
// entire local queue (and spam /api/playback-errors).
|
||||
val current = queueRefs.getOrNull(idx)
|
||||
val upnpEngaged = activeUpnpHolder.active.value != null ||
|
||||
activeUpnpHolder.target.value != null
|
||||
if (current == null || upnpEngaged) return
|
||||
val duration = controller.duration
|
||||
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
|
||||
if (!isZeroDuration) return
|
||||
@@ -341,6 +376,19 @@ class PlayerController @Inject constructor(
|
||||
// awaitReady, the Player.Listener is wired too.
|
||||
if (!readyDeferred.isCompleted) readyDeferred.complete(Unit)
|
||||
startPositionPolling(controller)
|
||||
// Keep isUpnpLoading current between player-event fires: holder state
|
||||
// changes (setTarget / set(active)) are independent of Media3 events, so
|
||||
// onEvents alone would lag behind by up to one event cycle. This collector
|
||||
// runs for the process lifetime alongside the position poller.
|
||||
scope.launch {
|
||||
combine(
|
||||
activeUpnpHolder.target,
|
||||
activeUpnpHolder.active,
|
||||
) { target, active -> target != null && active == null }
|
||||
.collect { isLoading ->
|
||||
uiStateInternal.value = uiStateInternal.value.copy(isUpnpLoading = isLoading)
|
||||
}
|
||||
}
|
||||
controller.addListener(
|
||||
object : Player.Listener {
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
@@ -364,6 +412,11 @@ class PlayerController @Inject constructor(
|
||||
// Reset the per-item evaluation guard so the new
|
||||
// item's STATE_READY transition gets a fresh check.
|
||||
lastEvaluatedItemIndex = -1
|
||||
// The track flipped -- re-anchor the position interpolator
|
||||
// so the next polling tick treats the new track's
|
||||
// remoteState.positionMs as fresh rather than carrying the
|
||||
// old anchor + elapsed forward.
|
||||
lastSeenRemotePositionMs = -1L
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
@@ -381,15 +434,38 @@ class PlayerController @Inject constructor(
|
||||
?.mediaMetadata
|
||||
?.extras
|
||||
?.getString(MINSTREL_SOURCE_KEY)
|
||||
val isUpnpLoading = activeUpnpHolder.target.value != null &&
|
||||
activeUpnpHolder.active.value == null
|
||||
// When UPnP is active, the wrapped ExoPlayer is paused with
|
||||
// no real audio loaded -- player.duration / isPlaying /
|
||||
// currentPosition all reflect that. Read from remoteState
|
||||
// instead so an onEvents fire (e.g. activity resume) doesn't
|
||||
// clobber the UI with zeros. Duration falls back to the
|
||||
// wrapped player's value when Sonos hasn't reported one yet
|
||||
// (pre-first-poll window, or Sonos still buffering) -- the
|
||||
// wrapped ExoPlayer was prepared with the same MediaItem so
|
||||
// it knows the real duration before any SOAP poll lands.
|
||||
val upnpActive = activeUpnpHolder.active.value != null
|
||||
uiStateInternal.value =
|
||||
PlayerUiState(
|
||||
currentTrack = current,
|
||||
queue = queueRefs,
|
||||
queueIndex = idx,
|
||||
isPlaying = player.isPlaying,
|
||||
isBuffering = player.playbackState == Player.STATE_BUFFERING,
|
||||
positionMs = player.currentPosition.coerceAtLeast(0),
|
||||
durationMs = player.duration.coerceAtLeast(0),
|
||||
isPlaying = if (upnpActive) remoteState.isPlaying else player.isPlaying,
|
||||
isBuffering = !upnpActive &&
|
||||
player.playbackState == Player.STATE_BUFFERING,
|
||||
positionMs = if (upnpActive) {
|
||||
remoteState.positionMs
|
||||
} else {
|
||||
player.currentPosition
|
||||
}.coerceAtLeast(0),
|
||||
durationMs = effectiveDuration(
|
||||
upnpActive,
|
||||
remoteState.durationMs,
|
||||
player.duration,
|
||||
desiredIdx = idx,
|
||||
controllerIdx = idx,
|
||||
),
|
||||
bufferedPositionMs = player.bufferedPosition.coerceAtLeast(0),
|
||||
playbackError = player.playerError?.message,
|
||||
currentSource = source,
|
||||
@@ -399,6 +475,7 @@ class PlayerController @Inject constructor(
|
||||
Player.REPEAT_MODE_ONE -> RepeatMode.ONE
|
||||
else -> RepeatMode.OFF
|
||||
},
|
||||
isUpnpLoading = isUpnpLoading,
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -423,15 +500,138 @@ class PlayerController @Inject constructor(
|
||||
scope.launch(Dispatchers.Main.immediate) {
|
||||
while (isActive) {
|
||||
delay(POSITION_POLL_INTERVAL_MS)
|
||||
if (!controller.isPlaying) continue
|
||||
uiStateInternal.value = uiStateInternal.value.copy(
|
||||
positionMs = controller.currentPosition.coerceAtLeast(0),
|
||||
bufferedPositionMs = controller.bufferedPosition.coerceAtLeast(0),
|
||||
)
|
||||
tickPositionPoll(controller)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One position-polling tick. Owns track-change detection too: when UPnP
|
||||
* is active and the wrapped ExoPlayer is paused, `delegate.seekTo` from
|
||||
* `maybeSyncLocalCursor` may not fire `onMediaItemTransition`, leaving
|
||||
* uiState.queueIndex stuck on the old track even after Sonos has
|
||||
* advanced. So the tick reads Sonos's reported Track as the source of
|
||||
* truth, rebuilds the index/title fields itself, and force-syncs the
|
||||
* wrapped player as defense in depth.
|
||||
*/
|
||||
private fun tickPositionPoll(controller: MediaController) {
|
||||
val upnpActive = activeUpnpHolder.active.value != null
|
||||
resolvePendingTransport(controller, upnpActive)
|
||||
val pendingTransport = upnpActive && remoteState.pendingTransportDeadlineMs > 0L
|
||||
val effectiveIsPlaying =
|
||||
if (upnpActive) remoteState.isPlaying else controller.isPlaying
|
||||
val effectivePosition = if (upnpActive) {
|
||||
interpolatedRemotePosition(effectiveIsPlaying)
|
||||
} else {
|
||||
controller.currentPosition
|
||||
}
|
||||
val desiredIdx = desiredQueueIndex(controller, upnpActive)
|
||||
val current = uiStateInternal.value
|
||||
val newPos = effectivePosition.coerceAtLeast(0)
|
||||
val newDur = effectiveDuration(
|
||||
upnpActive,
|
||||
remoteState.durationMs,
|
||||
controller.duration,
|
||||
desiredIdx = desiredIdx,
|
||||
controllerIdx = controller.currentMediaItemIndex,
|
||||
)
|
||||
val newBuf = controller.bufferedPosition.coerceAtLeast(0)
|
||||
// Track adjustments are forward-only AND suppressed while a user
|
||||
// transport press is pending Sonos confirmation. Together those keep
|
||||
// either direction of user input from being undone by a stale poll.
|
||||
val trackChanged = !pendingTransport &&
|
||||
desiredIdx > current.queueIndex &&
|
||||
desiredIdx in queueRefs.indices
|
||||
publishTickIfChanged(
|
||||
current, trackChanged, desiredIdx,
|
||||
effectiveIsPlaying, newPos, newDur, newBuf,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Event-driven primary path: clear pending the moment Sonos's reported
|
||||
* Track matches the wrapped player's index. Safety fallback: clear on
|
||||
* deadline so we don't ignore Sonos's actual state forever if SOAP fails.
|
||||
*/
|
||||
private fun resolvePendingTransport(controller: MediaController, upnpActive: Boolean) {
|
||||
if (!upnpActive || remoteState.pendingTransportDeadlineMs <= 0L) return
|
||||
val sonosIdx = (remoteState.trackNumber - 1).coerceAtLeast(0)
|
||||
val timedOut = SystemClock.elapsedRealtime() > remoteState.pendingTransportDeadlineMs
|
||||
if (sonosIdx == controller.currentMediaItemIndex || timedOut) {
|
||||
remoteState.clearPendingTransport()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList") // assembled at one tick call site; refactor would cost clarity
|
||||
private fun publishTickIfChanged(
|
||||
current: PlayerUiState,
|
||||
trackChanged: Boolean,
|
||||
desiredIdx: Int,
|
||||
effectiveIsPlaying: Boolean,
|
||||
newPos: Long,
|
||||
newDur: Long,
|
||||
newBuf: Long,
|
||||
) {
|
||||
val somethingChanged = trackChanged ||
|
||||
current.isPlaying != effectiveIsPlaying ||
|
||||
current.positionMs != newPos ||
|
||||
current.durationMs != newDur
|
||||
if (!somethingChanged) return
|
||||
val newTrack = if (trackChanged) queueRefs[desiredIdx] else current.currentTrack
|
||||
val newIdx = if (trackChanged) desiredIdx else current.queueIndex
|
||||
uiStateInternal.value = current.copy(
|
||||
currentTrack = newTrack,
|
||||
queueIndex = newIdx,
|
||||
isPlaying = effectiveIsPlaying,
|
||||
positionMs = newPos,
|
||||
durationMs = newDur,
|
||||
bufferedPositionMs = newBuf,
|
||||
)
|
||||
// Intentionally do NOT call controller.seekTo here. That would route
|
||||
// through MinstrelForwardingPlayer's seekTo override and re-issue
|
||||
// AVTransport.SeekToTrack to Sonos -- which seeks Sonos back to the
|
||||
// start of the same track it's already playing, restarting the song.
|
||||
// The wrapped player's index is kept in sync by maybeSyncLocalCursor's
|
||||
// delegate.seekTo (which bypasses the override). If it lags briefly,
|
||||
// the next pollOnce catches up; the uiState above already reflects
|
||||
// Sonos's truth for the user.
|
||||
}
|
||||
|
||||
private fun desiredQueueIndex(controller: MediaController, upnpActive: Boolean): Int =
|
||||
if (upnpActive) {
|
||||
(remoteState.trackNumber - 1).coerceAtLeast(0)
|
||||
} else {
|
||||
controller.currentMediaItemIndex
|
||||
}
|
||||
|
||||
// ── Remote position interpolation state ──────────────────────────────
|
||||
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
|
||||
// SOAP poll (and only when the round-trip completes -- screen-off WiFi
|
||||
// sleep can stall it for many seconds). To keep the scrubber moving
|
||||
// smoothly we anchor each fresh reading + an elapsed-realtime stamp;
|
||||
// between updates we display anchor + elapsed when Sonos is playing.
|
||||
// A real correction lands as soon as the next poll arrives.
|
||||
@Volatile private var lastSeenRemotePositionMs: Long = -1L
|
||||
@Volatile private var positionAnchorMs: Long = 0L
|
||||
@Volatile private var positionAnchorAtRealtimeMs: Long = 0L
|
||||
|
||||
private fun interpolatedRemotePosition(isPlaying: Boolean): Long {
|
||||
val raw = remoteState.positionMs
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (raw != lastSeenRemotePositionMs) {
|
||||
lastSeenRemotePositionMs = raw
|
||||
positionAnchorMs = raw
|
||||
positionAnchorAtRealtimeMs = now
|
||||
}
|
||||
if (!isPlaying) return positionAnchorMs
|
||||
// Cap how far past the last anchor we extrapolate. After
|
||||
// MAX_INTERPOLATION_DRIFT_MS without a poll update, freeze the
|
||||
// displayed position at anchor + cap rather than projecting wildly.
|
||||
// The next successful poll re-anchors and motion resumes.
|
||||
val delta = (now - positionAnchorAtRealtimeMs).coerceAtMost(MAX_INTERPOLATION_DRIFT_MS)
|
||||
return positionAnchorMs + delta
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges Media3's `ListenableFuture<MediaController>.buildAsync()`
|
||||
* to a suspend function without pulling in `kotlinx-coroutines-guava`
|
||||
@@ -467,7 +667,23 @@ class PlayerController @Inject constructor(
|
||||
.setArtist(artistName)
|
||||
.setAlbumTitle(albumTitle)
|
||||
.apply {
|
||||
// Server-known duration -- gives the lock-screen / notification
|
||||
// scrubber a real total even when the wrapped ExoPlayer is
|
||||
// paused under UPnP (it never probes a duration in that state).
|
||||
if (durationSec > 0) setDurationMs(durationSec.toLong() * MS_PER_SECOND)
|
||||
if (source != null) setExtras(sourceExtras(source))
|
||||
// Point the notification / lock-screen art at the SAME album
|
||||
// cover the in-app surfaces use (TrackRef.coverUrl ->
|
||||
// /api/albums/{id}/cover). Without this, Media3 falls back to
|
||||
// whatever art is embedded in the stream's tags, which can be a
|
||||
// different image than the server's album cover. Setting
|
||||
// artworkUri here is load-bearing: MediaMetadata.populate()
|
||||
// overwrites artworkUri + artworkData as a pair, so the
|
||||
// MediaItem's URI clears any embedded artworkData ExoPlayer
|
||||
// extracts from the stream -- the cover endpoint wins on both
|
||||
// surfaces. The session's OkHttp-backed BitmapLoader (see
|
||||
// PlayerFactory) is what makes this authed placeholder URL load.
|
||||
if (coverUrl.isNotEmpty()) setArtworkUri(coverUrl.toUri())
|
||||
}
|
||||
.build()
|
||||
// Server's stream_url is a relative path (/api/tracks/{id}/stream);
|
||||
@@ -489,8 +705,40 @@ class PlayerController @Inject constructor(
|
||||
private fun sourceExtras(source: String): Bundle =
|
||||
Bundle().apply { putString(MINSTREL_SOURCE_KEY, source) }
|
||||
|
||||
/**
|
||||
* Duration to surface to the UI. Priority: Sonos's reported duration
|
||||
* (only when UPnP active and non-zero) -> wrapped ExoPlayer's value
|
||||
* (only valid once it's probed the stream) -> TrackRef.durationSec
|
||||
* (always known from the server response). The third tier is what
|
||||
* keeps the scrubber populated when the user taps Sonos before the
|
||||
* wrapped player has had time to probe its own duration -- without
|
||||
* it, both top tiers report 0/TIME_UNSET and the field reads empty
|
||||
* until the first SOAP poll lands.
|
||||
*/
|
||||
@Suppress("ReturnCount") // 3-tier fallback reads cleanest as a ladder of early returns
|
||||
private fun effectiveDuration(
|
||||
upnpActive: Boolean,
|
||||
remoteMs: Long,
|
||||
localMs: Long,
|
||||
desiredIdx: Int,
|
||||
controllerIdx: Int,
|
||||
): Long {
|
||||
if (upnpActive && remoteMs > 0) return remoteMs
|
||||
// Tier 2 (wrapped player's probed duration) only valid when the
|
||||
// wrapped player is on the same track we're trying to show. After a
|
||||
// Sonos natural advance the polling tick updates desiredIdx from
|
||||
// Sonos's truth while controllerIdx is briefly stale -- using the
|
||||
// wrapped player's duration here would surface the old track's
|
||||
// length under the new track's title.
|
||||
if (localMs > 0 && controllerIdx == desiredIdx) return localMs
|
||||
val ref = queueRefs.getOrNull(desiredIdx) ?: return 0
|
||||
return ref.durationSec.toLong() * MS_PER_SECOND
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MINSTREL_SOURCE_KEY: String = "minstrel_source"
|
||||
private const val MS_PER_SECOND = 1_000L
|
||||
private const val MAX_INTERPOLATION_DRIFT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ package com.fabledsword.minstrel.player
|
||||
import android.content.Context
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.BitmapLoader
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.DataSourceBitmapLoader
|
||||
import androidx.media3.datasource.cache.CacheDataSink
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||
@@ -11,15 +14,21 @@ import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.session.CacheBitmapLoader
|
||||
import com.fabledsword.minstrel.cache.audiocache.CacheConfig
|
||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Builds the process-singleton ExoPlayer with our shared OkHttp +
|
||||
* Builds the process-singleton player with our shared OkHttp +
|
||||
* SimpleCache chain. The MinstrelPlayerService (Phase 6.2) calls
|
||||
* `build()` once during onCreate.
|
||||
*
|
||||
@@ -31,12 +40,22 @@ import javax.inject.Singleton
|
||||
* (sizeBytes cap = rollingCap); our policy layer in the worker layers
|
||||
* the 2-bucket protection on top by feeding `removeSpan` only for
|
||||
* unprotected tracks.
|
||||
*
|
||||
* `build()` returns a [MinstrelForwardingPlayer] wrapping the internal
|
||||
* ExoPlayer. When the UPnP route drops (3 consecutive poll failures or
|
||||
* a SOAP failure), [dropEvents] emits the route name so the NowPlaying
|
||||
* surface can show a snackbar. The MutableSharedFlow uses DROP_OLDEST
|
||||
* with capacity=1 so a burst of failures during a single tear-down
|
||||
* surfaces as one event rather than queueing N.
|
||||
*/
|
||||
@Singleton
|
||||
class PlayerFactory @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val cacheConfig: CacheConfig,
|
||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||
) {
|
||||
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
|
||||
|
||||
@@ -46,11 +65,36 @@ class PlayerFactory @Inject constructor(
|
||||
StandaloneDatabaseProvider(context),
|
||||
)
|
||||
|
||||
fun build(): ExoPlayer {
|
||||
// MutableSharedFlow with extraBufferCapacity=1 + DROP_OLDEST so a burst
|
||||
// of drop events (rapid SOAP failures during a single tear-down) surfaces
|
||||
// as one snackbar rather than queueing N.
|
||||
private val dropEventsInternal = MutableSharedFlow<String>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
val dropEvents: SharedFlow<String> = dropEventsInternal.asSharedFlow()
|
||||
|
||||
fun build(): Player {
|
||||
val exo = buildExoPlayer()
|
||||
return MinstrelForwardingPlayer(
|
||||
delegate = exo,
|
||||
holder = activeUpnpHolder,
|
||||
remoteState = remoteState,
|
||||
onDrop = { name -> emitDrop(name) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildExoPlayer(): ExoPlayer {
|
||||
val httpDataSource = OkHttpDataSource.Factory(okHttpClient)
|
||||
// Gate network reads on ServerHealth so a cache miss while offline
|
||||
// fails fast with an OfflineException instead of hitting an OkHttp
|
||||
// timeout. CacheDataSource only consults the upstream factory on a
|
||||
// cache miss, so playback of cached audio is unaffected.
|
||||
val gatedUpstream = OfflineGatedDataSourceFactory(httpDataSource, serverHealth)
|
||||
val cacheDataSource = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(httpDataSource)
|
||||
.setUpstreamDataSourceFactory(gatedUpstream)
|
||||
.setCacheWriteDataSinkFactory(
|
||||
CacheDataSink.Factory()
|
||||
.setCache(simpleCache)
|
||||
@@ -71,4 +115,26 @@ class PlayerFactory @Inject constructor(
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* BitmapLoader for the MediaSession's notification / lock-screen art.
|
||||
* Backed by the shared [okHttpClient] so it inherits the same
|
||||
* BaseUrlInterceptor placeholder rewrite + auth cookie that Coil uses
|
||||
* for in-app covers — without it the default DefaultHttpDataSource
|
||||
* loader can't resolve `http://placeholder.invalid/...` and would 401
|
||||
* on the cover endpoint. Wrapped in CacheBitmapLoader so a cover the
|
||||
* notification already fetched isn't re-loaded on every metadata
|
||||
* refresh. Lets the album-cover artworkUri set in
|
||||
* [PlayerController.toMediaItem] actually render on the media card.
|
||||
*/
|
||||
fun buildBitmapLoader(): BitmapLoader =
|
||||
CacheBitmapLoader(
|
||||
DataSourceBitmapLoader.Builder(context)
|
||||
.setDataSourceFactory(OkHttpDataSource.Factory(okHttpClient))
|
||||
.build(),
|
||||
)
|
||||
|
||||
private fun emitDrop(routeName: String) {
|
||||
dropEventsInternal.tryEmit(routeName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,4 +28,6 @@ data class PlayerUiState(
|
||||
val currentSource: String? = null,
|
||||
val shuffleEnabled: Boolean = false,
|
||||
val repeatMode: RepeatMode = RepeatMode.OFF,
|
||||
/** True while the UPnP initial-batch load is in progress (target set, active not yet wired). */
|
||||
val isUpnpLoading: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Synthesized state for the UPnP route -- what the ForwardingPlayer
|
||||
* exposes via Player.getCurrentPosition / isPlaying / etc. when the
|
||||
* remote leg is active. Not a Player; a container.
|
||||
*
|
||||
* Updates flow in from:
|
||||
* - 1Hz GetPositionInfo poll -> applyPositionInfo()
|
||||
* - Transport SOAP calls landing 200 OK -> applyTransport{Playing,Paused,Stopped}()
|
||||
* - Poll failures -> recordPollFailure()
|
||||
*
|
||||
* The poll-failure counter is the sole drop arbiter: [DROP_THRESHOLD]
|
||||
* consecutive poll failures = remote considered dropped (returns true
|
||||
* from recordPollFailure for the caller to surface). Any success resets it.
|
||||
* Failed transport commands no longer drop the route -- they retry and
|
||||
* otherwise defer to this counter (see MinstrelForwardingPlayer).
|
||||
*/
|
||||
@Singleton
|
||||
class RemotePlayerState @Inject constructor() {
|
||||
|
||||
@Volatile var positionMs: Long = 0L; private set
|
||||
@Volatile var durationMs: Long = 0L; private set
|
||||
@Volatile var isPlaying: Boolean = false; private set
|
||||
@Volatile var currentTrackUri: String = ""; private set
|
||||
@Volatile var lastError: Throwable? = null; private set
|
||||
@Volatile var trackNumber: Int = 0; private set
|
||||
|
||||
// The operator's last explicit transport intent (play=true, pause=false),
|
||||
// distinct from the observed remote isPlaying. handleRemoteDrop resumes
|
||||
// local playback based on THIS, not isPlaying -- so a play() that fails
|
||||
// its SOAP (stale/dead renderer) still resumes on the phone instead of
|
||||
// swallowing the tap. Deliberately NOT cleared by applyError.
|
||||
@Volatile var lastPlayIntent: Boolean = false; private set
|
||||
|
||||
// Pending-transport deadline (SystemClock.elapsedRealtime() at which we
|
||||
// give up waiting). When > 0, a user transport action (next/prev/seekTo
|
||||
// idx) is in flight: ForwardingPlayer has already moved the wrapped
|
||||
// player's index, but Sonos's reported Track hasn't refreshed via a
|
||||
// SOAP poll yet. PlayerController.tickPositionPoll skips track
|
||||
// adjustments while pending is non-zero. Pending clears when:
|
||||
// (a) [event-driven, primary] a poll lands and Sonos's reported Track
|
||||
// matches the wrapped player's currentMediaItemIndex; or
|
||||
// (b) [safety fallback] the deadline expires (covers SOAP-fail cases
|
||||
// where Sonos never acks).
|
||||
@Volatile var pendingTransportDeadlineMs: Long = 0L; private set
|
||||
|
||||
fun beginPendingTransport(deadlineMs: Long) {
|
||||
pendingTransportDeadlineMs = deadlineMs
|
||||
}
|
||||
|
||||
fun clearPendingTransport() {
|
||||
pendingTransportDeadlineMs = 0L
|
||||
}
|
||||
|
||||
@Volatile private var consecutivePollFailures: Int = 0
|
||||
|
||||
fun applyPositionInfo(positionMs: Long, durationMs: Long, trackUri: String, trackNumber: Int) {
|
||||
this.positionMs = positionMs
|
||||
this.durationMs = durationMs
|
||||
this.currentTrackUri = trackUri
|
||||
this.trackNumber = trackNumber
|
||||
}
|
||||
|
||||
fun applyTransportPlaying() { isPlaying = true }
|
||||
fun applyTransportPaused() { isPlaying = false }
|
||||
fun applyTransportStopped() {
|
||||
isPlaying = false
|
||||
positionMs = 0L
|
||||
}
|
||||
|
||||
fun setPlayIntent(intended: Boolean) { lastPlayIntent = intended }
|
||||
|
||||
fun applyError(t: Throwable) {
|
||||
isPlaying = false
|
||||
lastError = t
|
||||
}
|
||||
|
||||
/** Returns true when the rolling threshold trips this call. */
|
||||
fun recordPollFailure(): Boolean {
|
||||
consecutivePollFailures += 1
|
||||
return consecutivePollFailures >= DROP_THRESHOLD
|
||||
}
|
||||
|
||||
fun recordPollSuccess() { consecutivePollFailures = 0 }
|
||||
|
||||
fun reset() {
|
||||
positionMs = 0L
|
||||
durationMs = 0L
|
||||
isPlaying = false
|
||||
lastPlayIntent = false
|
||||
currentTrackUri = ""
|
||||
lastError = null
|
||||
consecutivePollFailures = 0
|
||||
trackNumber = 0
|
||||
pendingTransportDeadlineMs = 0L
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// ~30 seconds of consecutive poll failures before declaring the route
|
||||
// dropped. Bumped from 3 because screen-off WiFi sleep / brief Doze
|
||||
// can stall socket I/O for several seconds without the renderer
|
||||
// actually being unreachable -- a 3-failure drop kicked us back to
|
||||
// local audio every time the phone went into a pocket.
|
||||
const val DROP_THRESHOLD = 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.CastApi
|
||||
import com.fabledsword.minstrel.api.endpoints.StreamTokenRequest
|
||||
import com.fabledsword.minstrel.api.endpoints.StreamTokenResponse
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Mints stream tokens for a given track id. Pulled into its own Hilt
|
||||
* singleton so [MinstrelForwardingPlayer] (service-side) and
|
||||
* [com.fabledsword.minstrel.player.output.OutputPickerController]
|
||||
* (controller-side) don't each construct their own [CastApi] from
|
||||
* Retrofit. The shared Retrofit instance is unchanged.
|
||||
*/
|
||||
@Singleton
|
||||
class StreamTokenProvider @Inject constructor(retrofit: Retrofit) {
|
||||
private val api: CastApi = retrofit.create()
|
||||
|
||||
suspend fun mint(trackId: String): StreamTokenResponse =
|
||||
api.streamToken(StreamTokenRequest(trackId = trackId))
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Shared singleton handle to the currently-active UPnP route's transport
|
||||
* + rendering clients. Decouples [com.fabledsword.minstrel.player.MinstrelForwardingPlayer]
|
||||
* from [OutputPickerController] -- the picker writes; the forwarding
|
||||
* player reads. Null = no UPnP active (local ExoPlayer path).
|
||||
*/
|
||||
data class ActiveUpnp(
|
||||
val routeId: String,
|
||||
val routeName: String,
|
||||
val avTransport: AVTransportClient,
|
||||
val rendering: RenderingControlClient?,
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class ActiveUpnpHolder @Inject constructor() {
|
||||
|
||||
private val internal = MutableStateFlow<ActiveUpnp?>(null)
|
||||
val active: StateFlow<ActiveUpnp?> = internal.asStateFlow()
|
||||
|
||||
/**
|
||||
* Pending route id during selectUpnp's queue-load window. Set when
|
||||
* loadQueueOnSonos starts; cleared on completion (success or failure).
|
||||
* ForwardingPlayer overrides treat (target != null && active == null)
|
||||
* as "UPnP intended but SOAP not yet wired" -- drop transport commands
|
||||
* silently rather than send them to a half-loaded queue.
|
||||
*/
|
||||
private val targetInternal = MutableStateFlow<String?>(null)
|
||||
val target: StateFlow<String?> = targetInternal.asStateFlow()
|
||||
|
||||
fun set(active: ActiveUpnp?) { internal.value = active }
|
||||
|
||||
fun setTarget(routeId: String?) { targetInternal.value = routeId }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
/** Action the idle-revert collector should take on a player-state change. */
|
||||
internal enum class IdleRevertAction { ARM, CANCEL, IGNORE }
|
||||
|
||||
/**
|
||||
* Pure decision for the UPnP idle-revert timer. [armed] is whether the
|
||||
* idle timer Job is currently running. We ARM only on the transition INTO
|
||||
* "engaged + not playing" (so repeated paused emissions don't reset the
|
||||
* countdown), CANCEL whenever playback resumes or UPnP disengages, and
|
||||
* otherwise IGNORE.
|
||||
*/
|
||||
internal fun idleRevertAction(
|
||||
upnpEngaged: Boolean,
|
||||
isPlaying: Boolean,
|
||||
armed: Boolean,
|
||||
): IdleRevertAction = when {
|
||||
upnpEngaged && !isPlaying -> if (armed) IdleRevertAction.IGNORE else IdleRevertAction.ARM
|
||||
armed -> IdleRevertAction.CANCEL
|
||||
else -> IdleRevertAction.IGNORE
|
||||
}
|
||||
+527
-53
@@ -1,24 +1,35 @@
|
||||
@file:Suppress("TooManyFunctions") // 5 MediaRouter.Callback overrides inflate the count
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import android.content.Context
|
||||
import androidx.mediarouter.media.MediaControlIntent
|
||||
import androidx.mediarouter.media.MediaRouteSelector
|
||||
import androidx.mediarouter.media.MediaRouter
|
||||
import com.fabledsword.minstrel.api.endpoints.CastApi
|
||||
import com.fabledsword.minstrel.api.endpoints.StreamTokenRequest
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
import com.fabledsword.minstrel.player.PlayerFactory
|
||||
import com.fabledsword.minstrel.player.RemotePlayerState
|
||||
import com.fabledsword.minstrel.player.StreamTokenProvider
|
||||
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
||||
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -26,9 +37,9 @@ import javax.inject.Singleton
|
||||
/**
|
||||
* Snapshot of the audio output route state. [current] is the live
|
||||
* route audio is being delivered to. [available] is every route the
|
||||
* picker knows about — MediaRouter system routes merged with
|
||||
* UPnP/DLNA renderers discovered on the LAN — sorted current-first
|
||||
* then by [OutputRoute.Kind] (Bluetooth, Wired, BuiltIn, Other).
|
||||
* picker knows about -- MediaRouter system routes merged with
|
||||
* UPnP/DLNA renderers discovered on the LAN -- with the BuiltIn
|
||||
* "Phone speaker" pinned first, everything else alphabetical.
|
||||
*/
|
||||
data class RouteSnapshot(
|
||||
val current: OutputRoute,
|
||||
@@ -48,7 +59,7 @@ data class RouteSnapshot(
|
||||
* - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
|
||||
* wired, Bluetooth)
|
||||
* - [OutputRoute.Protocol.UPNP] — mint a signed stream token via
|
||||
* [CastApi.streamToken], drive the discovered renderer with
|
||||
* [StreamTokenProvider.mint], drive the discovered renderer with
|
||||
* AVTransport.SetAVTransportURI + Play, pause local playback so
|
||||
* audio yields to the network speaker
|
||||
* - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —
|
||||
@@ -65,10 +76,12 @@ class OutputPickerController @Inject constructor(
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
private val upnpDiscovery: UpnpDiscoveryController,
|
||||
private val playerController: PlayerController,
|
||||
retrofit: Retrofit,
|
||||
private val playerFactory: PlayerFactory,
|
||||
private val streamTokens: StreamTokenProvider,
|
||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val okHttp: OkHttpClient,
|
||||
) {
|
||||
private val castApi: CastApi = retrofit.create()
|
||||
|
||||
private val mediaRouter = MediaRouter.getInstance(context)
|
||||
|
||||
private val selector = MediaRouteSelector.Builder()
|
||||
@@ -82,12 +95,26 @@ class OutputPickerController @Inject constructor(
|
||||
*/
|
||||
private val systemRoutesInternal = MutableStateFlow(snapshotFromRouter())
|
||||
|
||||
private val selectedUpnpRouteIdInternal = MutableStateFlow<String?>(null)
|
||||
private val selectUpnpMutex = Mutex()
|
||||
|
||||
val routesState: StateFlow<RouteSnapshot> = combine(
|
||||
systemRoutesInternal,
|
||||
upnpDiscovery.routes,
|
||||
) { sys, upnp ->
|
||||
val merged = sys.available + upnp.map { OutputRoute.fromUpnpRoute(it) }
|
||||
RouteSnapshot(current = sys.current, available = sortRoutes(sys.current, merged))
|
||||
upnpDiscovery.sonosTopology,
|
||||
selectedUpnpRouteIdInternal,
|
||||
) { sys, upnp, _, upnpSelected ->
|
||||
val suppressed = upnpDiscovery.nonCoordinatorMemberUdns()
|
||||
val visibleUpnp = upnp
|
||||
.filter { it.id.bareUdn() !in suppressed } // suppressed set is bare UDNs
|
||||
.map { OutputRoute.fromUpnpRoute(it) }
|
||||
val merged = sys.available + visibleUpnp
|
||||
val current = if (upnpSelected != null) {
|
||||
merged.firstOrNull { it.id == upnpSelected } ?: sys.current
|
||||
} else {
|
||||
sys.current
|
||||
}
|
||||
RouteSnapshot(current = current, available = sortRoutes(merged))
|
||||
}.stateIn(scope, SharingStarted.Eagerly, systemRoutesInternal.value)
|
||||
|
||||
private val callback = object : MediaRouter.Callback() {
|
||||
@@ -113,6 +140,20 @@ class OutputPickerController @Inject constructor(
|
||||
) = refresh()
|
||||
}
|
||||
|
||||
// Last-synced queue identity. Used by observeQueueChangesForSonosResync
|
||||
// to detect when the user has mutated the queue (full replacement,
|
||||
// playNext insert, or radio-append) while UPnP is active and apply the
|
||||
// minimum-incremental set of Sonos SOAP operations to bring its native
|
||||
// queue back in sync. Stored as the full id list (not a join-key) so we
|
||||
// can run the longest-common-prefix / common-suffix diff.
|
||||
private var lastSyncedQueueIds: List<String>? = null
|
||||
|
||||
// Idle-revert timer. Armed when playback is paused/stopped on a UPnP
|
||||
// route; fires revertToPhoneOnIdle after IDLE_REVERT_MS of continuous
|
||||
// non-playing. Cancelled on resume, route change, or explicit disconnect.
|
||||
// See idleRevertAction for the arm/cancel decision.
|
||||
private var idleRevertJob: Job? = null
|
||||
|
||||
init {
|
||||
// Two-arg addCallback registers with no discovery flag —
|
||||
// androidx.mediarouter 1.7.0's default passive behavior:
|
||||
@@ -120,6 +161,284 @@ class OutputPickerController @Inject constructor(
|
||||
// without forcing Bluetooth scans. (There is no
|
||||
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
|
||||
mediaRouter.addCallback(selector, callback)
|
||||
// When MinstrelForwardingPlayer reports the active UPnP route has
|
||||
// dropped (the poll loop's consecutive-failure threshold tripping --
|
||||
// the sole drop arbiter; a failed transport command no longer drops),
|
||||
// clear the UPnP selection state and fall back to local ExoPlayer at
|
||||
// the last-known remote position. This mirrors selectSystem's disconnect
|
||||
// path but skips the Stop SOAP since the device is already unreachable.
|
||||
scope.launch {
|
||||
playerFactory.dropEvents.collect { handleRemoteDrop() }
|
||||
}
|
||||
scope.launch { observeQueueChangesForSonosResync() }
|
||||
scope.launch { observeIdleRevertWhileUpnp() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm/cancel the idle-revert timer as playback state changes while a
|
||||
* UPnP route is engaged. "Engaged" = active OR target set, so the brief
|
||||
* selectUpnp/resync window (active momentarily null, target set) doesn't
|
||||
* spuriously fire. The arm/cancel choice is the pure idleRevertAction.
|
||||
*/
|
||||
private suspend fun observeIdleRevertWhileUpnp() {
|
||||
playerController.uiState.collect { state ->
|
||||
val engaged = activeUpnpHolder.active.value != null ||
|
||||
activeUpnpHolder.target.value != null
|
||||
val armed = idleRevertJob?.isActive == true
|
||||
when (idleRevertAction(engaged, state.isPlaying, armed)) {
|
||||
IdleRevertAction.ARM ->
|
||||
idleRevertJob = scope.launch {
|
||||
delay(IDLE_REVERT_MS)
|
||||
revertToPhoneOnIdle()
|
||||
}
|
||||
IdleRevertAction.CANCEL -> {
|
||||
idleRevertJob?.cancel()
|
||||
idleRevertJob = null
|
||||
}
|
||||
IdleRevertAction.IGNORE -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Timer fired: the operator paused on a UPnP route and never resumed.
|
||||
* Stop the renderer (best-effort -- it's reachable, unlike a drop),
|
||||
* clear the UPnP overlay so routesState falls back to the phone, and
|
||||
* seek local ExoPlayer to the last remote position. No play(): the
|
||||
* operator was paused, so we revert routing silently. Guarded by the
|
||||
* selectUpnpMutex so it can't race selectUpnp/resync.
|
||||
*/
|
||||
private suspend fun revertToPhoneOnIdle() = selectUpnpMutex.withLock {
|
||||
if (remoteState.isPlaying) return@withLock // resumed at the boundary
|
||||
val active = activeUpnpHolder.active.value
|
||||
val capturedPositionMs = remoteState.positionMs
|
||||
runCatching { active?.avTransport?.stop() }
|
||||
.onFailure { Timber.w(it, "UPnP Stop failed during idle revert") }
|
||||
activeUpnpHolder.set(null)
|
||||
activeUpnpHolder.setTarget(null)
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
idleRevertJob = null
|
||||
playerController.seekTo(capturedPositionMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* When the user plays a different playlist while Sonos is active,
|
||||
* PlayerController.setQueue replaces the local queue but Sonos's
|
||||
* native queue still holds the OLD tracks. MinstrelForwardingPlayer's
|
||||
* setMediaItems override clears holder.active + sets target so the
|
||||
* imminent play() call drops (drops via isLoadingUpnp() = true). Then
|
||||
* this collector observes the uiState.queue change and re-runs
|
||||
* loadQueueOnSonos to push the new tracks to Sonos.
|
||||
*
|
||||
* Discrimination: selectUpnp's initial-load path doesn't change
|
||||
* uiState.queue (the queue was already populated before route
|
||||
* selection), so this collector doesn't fire during that window. Only
|
||||
* a fresh setQueue from PlayerController bumps the joined-ids key.
|
||||
*/
|
||||
private suspend fun observeQueueChangesForSonosResync() {
|
||||
playerController.uiState.collect { state ->
|
||||
val newIds = state.queue.map { it.id }
|
||||
val oldIds = lastSyncedQueueIds
|
||||
if (newIds == oldIds) return@collect
|
||||
lastSyncedQueueIds = newIds
|
||||
// Route can be in target (setMediaItems-induced clearing already
|
||||
// ran in ForwardingPlayer) OR in active (queue changed via
|
||||
// addMediaItem / removeMediaItems etc. which don't hit the
|
||||
// markPending hook).
|
||||
val routeId = activeUpnpHolder.target.value
|
||||
?: activeUpnpHolder.active.value?.routeId
|
||||
?: return@collect
|
||||
if (state.queue.isEmpty()) {
|
||||
Timber.w("Sonos resync skipped: empty queue (clearing target)")
|
||||
activeUpnpHolder.setTarget(null)
|
||||
return@collect
|
||||
}
|
||||
scope.launch {
|
||||
resyncSonosQueue(routeId, oldIds.orEmpty(), state.queue, state.queueIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring Sonos's native queue back in sync with the local queue after a
|
||||
* mutation. Tries an incremental SOAP diff first (RemoveTrackRangeFromQueue
|
||||
* + AddURIToQueue at the insertion point) so playback continues without
|
||||
* interruption -- that's what playNext / radio-append want. Falls back to
|
||||
* the full removeAllTracks + reload path when the diff implies the current
|
||||
* Sonos track was deleted (e.g. user switched playlists), which is what
|
||||
* the user-reported "Sonos queue does not update" bug needed.
|
||||
*/
|
||||
private suspend fun resyncSonosQueue(
|
||||
routeId: String,
|
||||
oldIds: List<String>,
|
||||
newQueue: List<TrackRef>,
|
||||
newCurrentIndex: Int,
|
||||
) = selectUpnpMutex.withLock {
|
||||
val upnpRoute = upnpDiscovery.routes.value.firstOrNull { it.id == routeId }
|
||||
val transport = upnpDiscovery.transportFor(routeId)
|
||||
if (upnpRoute == null || transport == null) {
|
||||
Timber.w(
|
||||
"Sonos resync: route or transport gone for %s, dropping to local",
|
||||
routeId,
|
||||
)
|
||||
activeUpnpHolder.setTarget(null)
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
return@withLock
|
||||
}
|
||||
val handledIncrementally = runCatching {
|
||||
tryIncrementalResync(transport, oldIds, newQueue)
|
||||
}.getOrElse { e ->
|
||||
Timber.w(e, "Sonos incremental resync errored; falling back to full reload")
|
||||
false
|
||||
}
|
||||
if (handledIncrementally) {
|
||||
// Active was never cleared on the incremental path; clear any
|
||||
// target that markPendingResyncIfRemote set (it didn't, for
|
||||
// incremental cases that don't go through setMediaItems, but
|
||||
// belt-and-suspenders).
|
||||
activeUpnpHolder.setTarget(null)
|
||||
return@withLock
|
||||
}
|
||||
// Full rebuild: ensure active is cleared so transport calls drop
|
||||
// (markPendingResyncIfRemote may already have done this on the
|
||||
// setMediaItems path).
|
||||
if (activeUpnpHolder.active.value != null) {
|
||||
activeUpnpHolder.set(null)
|
||||
activeUpnpHolder.setTarget(routeId)
|
||||
}
|
||||
val outputRoute = OutputRoute.fromUpnpRoute(upnpRoute)
|
||||
val rendering = renderingClientFor(routeId)
|
||||
Timber.w("Sonos resync: full reload of %d tracks on %s", newQueue.size, outputRoute.name)
|
||||
runCatching {
|
||||
loadQueueOnSonos(transport, outputRoute, newQueue, newCurrentIndex)
|
||||
activeUpnpHolder.set(
|
||||
ActiveUpnp(
|
||||
routeId = routeId,
|
||||
routeName = outputRoute.name,
|
||||
avTransport = transport,
|
||||
rendering = rendering,
|
||||
),
|
||||
)
|
||||
activeUpnpHolder.setTarget(null)
|
||||
}.onFailure { e ->
|
||||
Timber.w(e, "Sonos resync (full) failed -- dropping to local")
|
||||
activeUpnpHolder.setTarget(null)
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff-based incremental Sonos queue sync. Returns true when the new
|
||||
* queue can be produced from the old one with a remove-then-insert at
|
||||
* the same middle slice -- the common-prefix and common-suffix portions
|
||||
* stay untouched, and the current Sonos track must lie in the preserved
|
||||
* prefix (otherwise the diff would orphan playback). Returns false to
|
||||
* signal the caller to fall back to a full reload.
|
||||
*/
|
||||
private suspend fun tryIncrementalResync(
|
||||
transport: AVTransportClient,
|
||||
oldIds: List<String>,
|
||||
newQueue: List<TrackRef>,
|
||||
): Boolean {
|
||||
val newIds = newQueue.map { it.id }
|
||||
if (oldIds == newIds) return true
|
||||
val prefixLen = commonPrefixLength(oldIds, newIds)
|
||||
val suffixLen = commonSuffixLength(
|
||||
oldIds.subList(prefixLen, oldIds.size),
|
||||
newIds.subList(prefixLen, newIds.size),
|
||||
)
|
||||
val removedCount = oldIds.size - prefixLen - suffixLen
|
||||
val addedCount = newIds.size - prefixLen - suffixLen
|
||||
// Sonos's current track number is 1-based; compare against the
|
||||
// preserved-prefix range as 0-based. If the current track is in
|
||||
// the removed slice, incremental can't preserve playback -- caller
|
||||
// falls back to full rebuild.
|
||||
val currentSonosIdx0 = remoteState.trackNumber - 1
|
||||
val canApply = currentSonosIdx0 in 0 until prefixLen
|
||||
if (canApply) {
|
||||
applyQueueDiff(transport, newQueue, prefixLen, removedCount, addedCount)
|
||||
} else {
|
||||
Timber.w(
|
||||
"Sonos incremental: current track %d not in preserved prefix [0,%d); full rebuild",
|
||||
currentSonosIdx0,
|
||||
prefixLen,
|
||||
)
|
||||
}
|
||||
return canApply
|
||||
}
|
||||
|
||||
private suspend fun applyQueueDiff(
|
||||
transport: AVTransportClient,
|
||||
newQueue: List<TrackRef>,
|
||||
prefixLen: Int,
|
||||
removedCount: Int,
|
||||
addedCount: Int,
|
||||
) {
|
||||
if (removedCount > 0) {
|
||||
Timber.w(
|
||||
"Sonos incremental: RemoveTrackRangeFromQueue start=%d count=%d",
|
||||
prefixLen + 1,
|
||||
removedCount,
|
||||
)
|
||||
transport.removeTrackRangeFromQueue(
|
||||
startingIndex = prefixLen + 1,
|
||||
numberOfTracks = removedCount,
|
||||
)
|
||||
}
|
||||
if (addedCount == 0) return
|
||||
Timber.w(
|
||||
"Sonos incremental: AddURIToQueue x%d starting at position %d",
|
||||
addedCount,
|
||||
prefixLen + 1,
|
||||
)
|
||||
for (i in 0 until addedCount) {
|
||||
val ref = newQueue[prefixLen + i]
|
||||
val token = streamTokens.mint(ref.id)
|
||||
transport.addURIToQueue(
|
||||
uri = token.url,
|
||||
mime = token.mime,
|
||||
title = token.title,
|
||||
enqueuedURIPosition = prefixLen + i + 1,
|
||||
)
|
||||
if (i > 0) delay(EXTEND_THROTTLE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun commonPrefixLength(a: List<String>, b: List<String>): Int {
|
||||
val limit = minOf(a.size, b.size)
|
||||
for (i in 0 until limit) {
|
||||
if (a[i] != b[i]) return i
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
private fun commonSuffixLength(a: List<String>, b: List<String>): Int {
|
||||
val limit = minOf(a.size, b.size)
|
||||
for (i in 0 until limit) {
|
||||
if (a[a.size - 1 - i] != b[b.size - 1 - i]) return i
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the active UPnP route drops unexpectedly (the poll loop's
|
||||
* consecutive-failure threshold tripping). Captures the last remote
|
||||
* position + play state, clears UPnP selection, and resumes local ExoPlayer at
|
||||
* the same point. Skips the Stop SOAP (device already unreachable).
|
||||
* The snackbar is handled independently by the NowPlaying surface
|
||||
* collecting the same [PlayerFactory.dropEvents] via PlayerController.
|
||||
*/
|
||||
private fun handleRemoteDrop() {
|
||||
val capturedPositionMs = remoteState.positionMs
|
||||
// Resume on the operator's last play-intent, not the observed remote
|
||||
// state: after a pause, isPlaying is false, but a failed play() that
|
||||
// triggered this drop means the operator DID ask to play -- honor it.
|
||||
val wasPlayingRemote = remoteState.lastPlayIntent
|
||||
activeUpnpHolder.set(null)
|
||||
activeUpnpHolder.setTarget(null)
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
playerController.seekTo(capturedPositionMs)
|
||||
if (wasPlayingRemote) playerController.play()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,48 +482,201 @@ class OutputPickerController @Inject constructor(
|
||||
}
|
||||
|
||||
private fun selectSystem(route: OutputRoute) {
|
||||
val wasUpnp = selectedUpnpRouteIdInternal.value
|
||||
if (wasUpnp != null) {
|
||||
val active = activeUpnpHolder.active.value
|
||||
val capturedPositionMs = remoteState.positionMs
|
||||
val wasPlayingRemote = remoteState.isPlaying
|
||||
scope.launch {
|
||||
runCatching { active?.avTransport?.stop() }
|
||||
.onFailure { Timber.w(it, "UPnP Stop failed during disconnect") }
|
||||
activeUpnpHolder.set(null)
|
||||
activeUpnpHolder.setTarget(null)
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
playerController.seekTo(capturedPositionMs)
|
||||
if (wasPlayingRemote) playerController.play()
|
||||
}
|
||||
}
|
||||
val target = mediaRouter.routes.firstOrNull { it.id == route.id } ?: return
|
||||
mediaRouter.selectRoute(target)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the UPnP renderer: mint a token for the currently playing
|
||||
* track, set the renderer's URI, play, then pause local playback so
|
||||
* audio yields to the speaker. Wrapped in `runCatching` at each
|
||||
* step — token failure, transport-lookup failure, and SOAP failure
|
||||
* each abandon the selection cleanly rather than crashing. Failures
|
||||
* log at warn level via Timber so on-device verification can find
|
||||
* the cause in logcat (OkHttp's logger doesn't cover our own
|
||||
* deserialize / SOAP-parse code paths).
|
||||
* Drive the UPnP renderer using Sonos native queue mode:
|
||||
* clear the device's queue, load every track from our local queue
|
||||
* via AddURIToQueue, point the transport at the queue URI, seek to
|
||||
* the current index, and play. Wrapped in `runCatching` — SOAP
|
||||
* failure abandons the selection cleanly.
|
||||
*
|
||||
* Order of operations is deliberate:
|
||||
* 1. Pause local so the user doesn't keep hearing local audio.
|
||||
* 2. Set target early so ForwardingPlayer drops transport taps
|
||||
* while the 17-second queue load is in progress.
|
||||
* 3. Wire active LAST (after loadQueueOnSonos) so SOAP commands
|
||||
* are never routed to a half-loaded Sonos queue.
|
||||
*/
|
||||
private suspend fun selectUpnp(route: OutputRoute) {
|
||||
val trackId = playerController.uiState.value.currentTrack?.id
|
||||
if (trackId == null) {
|
||||
private suspend fun selectUpnp(route: OutputRoute) = selectUpnpMutex.withLock {
|
||||
val uiState = playerController.uiState.value
|
||||
val currentTrack = uiState.currentTrack
|
||||
if (currentTrack == null) {
|
||||
Timber.w("UPnP select skipped: no currentTrack (start playback first)")
|
||||
return
|
||||
return@withLock
|
||||
}
|
||||
val transport = upnpDiscovery.transportFor(route.id)
|
||||
// Honor Sonos topology: pick the coordinator's route when the user
|
||||
// tapped a group row. Suppression in routesState already keeps the
|
||||
// visible row at the coordinator's id, so this is identity in the
|
||||
// common case -- defensive for follow-up flows.
|
||||
val effectiveRoute = upnpDiscovery.coordinatorRouteFor(route.id)
|
||||
?.let { OutputRoute.fromUpnpRoute(it) } ?: route
|
||||
val transport = upnpDiscovery.transportFor(effectiveRoute.id)
|
||||
if (transport == null) {
|
||||
Timber.w(
|
||||
"UPnP select skipped: no transport for route id=${route.id} " +
|
||||
"UPnP select skipped: no transport for route id=${effectiveRoute.id} " +
|
||||
"(route disappeared or id mismatch with discovery list)",
|
||||
)
|
||||
return
|
||||
return@withLock
|
||||
}
|
||||
val rendering = renderingClientFor(effectiveRoute.id)
|
||||
// Pause local before flipping UI state -- user shouldn't keep hearing
|
||||
// local audio while we queue up Sonos.
|
||||
playerController.pause()
|
||||
selectedUpnpRouteIdInternal.value = effectiveRoute.id
|
||||
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
|
||||
// silently while target is set but active is null -- the user's premature
|
||||
// taps don't hit Sonos's stale state from a prior session.
|
||||
activeUpnpHolder.setTarget(effectiveRoute.id)
|
||||
runCatching {
|
||||
Timber.i("UPnP select: mint token for track=$trackId, route=${route.name}")
|
||||
val token = castApi.streamToken(StreamTokenRequest(trackId = trackId))
|
||||
Timber.i("UPnP select: SetAVTransportURI to ${token.url}")
|
||||
transport.setAVTransportURI(token.url)
|
||||
Timber.i("UPnP select: Play")
|
||||
transport.play()
|
||||
playerController.pause()
|
||||
Timber.i("UPnP select: done")
|
||||
loadQueueOnSonos(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
|
||||
// Wire active LAST -- SOAP path is now safe to use.
|
||||
activeUpnpHolder.set(
|
||||
ActiveUpnp(
|
||||
routeId = effectiveRoute.id,
|
||||
routeName = effectiveRoute.name,
|
||||
avTransport = transport,
|
||||
rendering = rendering,
|
||||
),
|
||||
)
|
||||
// Selecting a route loads the queue and Plays on the renderer, so
|
||||
// the standing intent is "play" -- a later drop should resume local.
|
||||
remoteState.setPlayIntent(true)
|
||||
}.onFailure { e ->
|
||||
Timber.w(e, "UPnP select failed for route ${route.id}")
|
||||
Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}")
|
||||
activeUpnpHolder.set(null)
|
||||
activeUpnpHolder.setTarget(null)
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadQueueOnSonos(
|
||||
transport: AVTransportClient,
|
||||
route: OutputRoute,
|
||||
queue: List<TrackRef>,
|
||||
currentIndex: Int,
|
||||
) {
|
||||
Timber.w("UPnP select: clear queue on %s", route.name)
|
||||
transport.removeAllTracksFromQueue()
|
||||
val initialEnd = (currentIndex + 1).coerceAtMost(queue.size)
|
||||
val initialBatch = queue.subList(0, initialEnd)
|
||||
Timber.w(
|
||||
"UPnP select: add %d initial tracks (currentIndex=%d, totalQueue=%d)",
|
||||
initialBatch.size, currentIndex, queue.size,
|
||||
)
|
||||
initialBatch.forEachIndexed { idx, ref ->
|
||||
val token = streamTokens.mint(ref.id)
|
||||
transport.addURIToQueue(
|
||||
uri = token.url,
|
||||
mime = token.mime,
|
||||
title = token.title,
|
||||
enqueuedURIPosition = idx + 1,
|
||||
)
|
||||
}
|
||||
val coordinatorUdn = route.id.bareUdn()
|
||||
val queueUri = "x-rincon-queue:$coordinatorUdn#0"
|
||||
Timber.w("UPnP select: SetAVTransportURI %s", queueUri)
|
||||
transport.setAVTransportURI(queueUri, "")
|
||||
Timber.w("UPnP select: Seek to track %d", currentIndex + 1)
|
||||
transport.seekToTrack(currentIndex + 1)
|
||||
Timber.w("UPnP select: Play")
|
||||
transport.play()
|
||||
Timber.w("UPnP select: initial done; backgrounding remainder")
|
||||
val remaining = queue.drop(initialEnd)
|
||||
if (remaining.isNotEmpty()) {
|
||||
scope.launch { extendQueueOnSonos(transport, route, remaining, initialEnd) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Background-append tracks after activation. Runs concurrently with
|
||||
* Sonos playback. Cancels if the user disconnects from this route
|
||||
* (active.routeId changes or becomes null). Tolerates individual
|
||||
* AddURIToQueue failures — log and continue so some tracks loaded
|
||||
* is better than zero tracks loaded.
|
||||
*/
|
||||
private suspend fun extendQueueOnSonos(
|
||||
transport: AVTransportClient,
|
||||
route: OutputRoute,
|
||||
tracks: List<TrackRef>,
|
||||
startPosition: Int,
|
||||
) {
|
||||
Timber.w(
|
||||
"UPnP extend: appending %d tracks starting at position %d",
|
||||
tracks.size, startPosition + 1,
|
||||
)
|
||||
var consecutiveFailures = 0
|
||||
var succeeded = 0
|
||||
var aborted = false
|
||||
for ((i, ref) in tracks.withIndex()) {
|
||||
if (aborted) break
|
||||
if (activeUpnpHolder.active.value?.routeId != route.id) {
|
||||
Timber.w("UPnP extend: cancelled at offset %d (route changed)", i)
|
||||
aborted = true
|
||||
} else {
|
||||
val outcome = runCatching {
|
||||
val token = streamTokens.mint(ref.id)
|
||||
transport.addURIToQueue(
|
||||
uri = token.url,
|
||||
mime = token.mime,
|
||||
title = token.title,
|
||||
enqueuedURIPosition = startPosition + i + 1,
|
||||
)
|
||||
}
|
||||
if (outcome.isSuccess) {
|
||||
consecutiveFailures = 0
|
||||
succeeded += 1
|
||||
// Throttle the burst so we don't tickle Sonos's burst-add
|
||||
// rejection -- logcat 2026-06-04 showed 33 consecutive
|
||||
// failures clustered at ~10ms intervals once offset 39 was
|
||||
// reached, which looks like a rate-limit kicking in. The
|
||||
// delay is small enough that extending 100 tracks adds
|
||||
// only ~5s to background work that's already async.
|
||||
delay(EXTEND_THROTTLE_MS)
|
||||
} else {
|
||||
consecutiveFailures += 1
|
||||
val e = outcome.exceptionOrNull()
|
||||
val detail = (e as? SoapFaultException)?.let {
|
||||
"code=${it.code} desc=${it.description}"
|
||||
} ?: e?.message
|
||||
Timber.w(e, "UPnP extend: append failed at offset %d -- %s", i, detail)
|
||||
if (consecutiveFailures >= EXTEND_ABORT_AFTER_FAILURES) {
|
||||
Timber.w(
|
||||
"UPnP extend: aborting after %d consecutive failures",
|
||||
consecutiveFailures,
|
||||
)
|
||||
aborted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Timber.w("UPnP extend: done (%d / %d appended)", succeeded, tracks.size)
|
||||
}
|
||||
|
||||
private fun renderingClientFor(routeId: String): RenderingControlClient? {
|
||||
val rcUrl = upnpDiscovery.routes.value
|
||||
.firstOrNull { it.id == routeId }
|
||||
?.renderingControlUrl ?: return null
|
||||
return RenderingControlClient(SoapClient(okHttp), rcUrl)
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
systemRoutesInternal.value = snapshotFromRouter()
|
||||
}
|
||||
@@ -214,24 +686,26 @@ class OutputPickerController @Inject constructor(
|
||||
.filter { it.matchesSelector(selector) }
|
||||
.map { OutputRoute.fromRouteInfo(it) }
|
||||
val current = OutputRoute.fromRouteInfo(mediaRouter.selectedRoute)
|
||||
return RouteSnapshot(current = current, available = sortRoutes(current, all))
|
||||
return RouteSnapshot(current = current, available = sortRoutes(all))
|
||||
}
|
||||
|
||||
/**
|
||||
* Selected first, then Bluetooth, then Wired, then BuiltIn, then
|
||||
* Other (UPnP renderers fall in Other). Keeps the active output at
|
||||
* the top + likely-wanted alternatives next + fallback last.
|
||||
* BuiltIn "Phone speaker" pinned first; everything else
|
||||
* alphabetical. Selection state is conveyed by the radio button
|
||||
* indicator in the picker row, not by sort order.
|
||||
*/
|
||||
private fun sortRoutes(current: OutputRoute, all: List<OutputRoute>): List<OutputRoute> {
|
||||
val rank: (OutputRoute) -> Int = { route ->
|
||||
when {
|
||||
route.id == current.id -> 0
|
||||
route.kind == OutputRoute.Kind.Bluetooth -> 1
|
||||
route.kind == OutputRoute.Kind.Wired -> 2
|
||||
route.kind == OutputRoute.Kind.BuiltIn -> 3
|
||||
else -> 4
|
||||
}
|
||||
}
|
||||
return all.sortedBy(rank)
|
||||
private fun sortRoutes(all: List<OutputRoute>): List<OutputRoute> {
|
||||
val (builtIn, rest) = all.partition { it.kind == OutputRoute.Kind.BuiltIn }
|
||||
return builtIn + rest.sortedBy { it.name.lowercase() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val EXTEND_ABORT_AFTER_FAILURES = 3
|
||||
const val EXTEND_THROTTLE_MS = 50L
|
||||
|
||||
// 5 minutes of continuous non-playing on a UPnP route before we
|
||||
// revert to the phone speaker, so a stale Sonos selection can't make
|
||||
// a later "tap play" do nothing.
|
||||
const val IDLE_REVERT_MS = 5 * 60 * 1000L
|
||||
}
|
||||
}
|
||||
|
||||
+17
-6
@@ -4,8 +4,11 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -69,12 +72,19 @@ fun OutputPickerSheet(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
snapshot.available.forEach { route ->
|
||||
RouteRow(
|
||||
route = route,
|
||||
isSelected = route.id == snapshot.current.id,
|
||||
onClick = { onRouteSelected(route) },
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = ROUTE_LIST_MAX_HEIGHT_DP.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(items = snapshot.available, key = { it.id }) { route ->
|
||||
RouteRow(
|
||||
route = route,
|
||||
isSelected = route.id == snapshot.current.id,
|
||||
onClick = { onRouteSelected(route) },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (permissionDenied) {
|
||||
PermissionHintRow()
|
||||
@@ -198,3 +208,4 @@ private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) {
|
||||
|
||||
private const val ROW_ICON_DP = 24
|
||||
private const val HINT_ICON_DP = 20
|
||||
private const val ROUTE_LIST_MAX_HEIGHT_DP = 400
|
||||
|
||||
+4
@@ -24,6 +24,7 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class OutputPickerViewModel @Inject constructor(
|
||||
private val controller: OutputPickerController,
|
||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||
) : ViewModel() {
|
||||
|
||||
val routes: StateFlow<RouteSnapshot> = controller.routesState
|
||||
@@ -33,6 +34,9 @@ class OutputPickerViewModel @Inject constructor(
|
||||
initialValue = controller.routesState.value,
|
||||
)
|
||||
|
||||
val activeUpnp: StateFlow<ActiveUpnp?> = activeUpnpHolder.active
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), null)
|
||||
|
||||
private val sheetVisibleInternal = MutableStateFlow(false)
|
||||
val sheetVisible: StateFlow<Boolean> = sheetVisibleInternal.asStateFlow()
|
||||
|
||||
|
||||
+251
-5
@@ -3,12 +3,13 @@ package com.fabledsword.minstrel.player.output.upnp
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/**
|
||||
* High-level wrapper for the UPnP AVTransport service. Three calls
|
||||
* for v1: SetAVTransportURI / Play / Stop. Pause + Seek deferred
|
||||
* until we have hardware in the loop to verify each device's quirks
|
||||
* (Sonos and BubbleUPnP accept the standard shape; some smart TVs
|
||||
* reject Pause without DIDL).
|
||||
* High-level wrapper for the UPnP AVTransport service.
|
||||
* Covers SetAVTransportURI / Play / Pause / Stop / Seek /
|
||||
* GetPositionInfo / GetTransportInfo / queue management
|
||||
* (RemoveAllTracksFromQueue, AddURIToQueue, Next, Previous, SeekToTrack).
|
||||
*/
|
||||
// One method per AVTransport SOAP verb; splitting would obscure the 1:1 protocol mapping.
|
||||
@Suppress("TooManyFunctions")
|
||||
class AVTransportClient(
|
||||
private val soap: SoapClient,
|
||||
private val controlUrl: HttpUrl,
|
||||
@@ -26,6 +27,135 @@ class AVTransportClient(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience overload that builds DIDL-Lite metadata around [uri]
|
||||
* + [mime] + [title] and forwards to [setAVTransportURI]. Sonos
|
||||
* rejects empty DIDL with vendor error 1023; this constructs the
|
||||
* minimal-but-Sonos-acceptable shape:
|
||||
* <DIDL-Lite>
|
||||
* <item id="0" parentID="-1" restricted="1">
|
||||
* <dc:title>...</dc:title>
|
||||
* <upnp:class>object.item.audioItem.musicTrack</upnp:class>
|
||||
* <res protocolInfo="http-get:*:<mime>:*">...</res>
|
||||
* </item>
|
||||
* </DIDL-Lite>
|
||||
* Generic UPnP renderers tolerate this shape too — there's no
|
||||
* downside to always sending it. Title falls back to "Minstrel"
|
||||
* when the caller doesn't supply one.
|
||||
*/
|
||||
suspend fun setAVTransportURIWithMetadata(uri: String, mime: String, title: String) {
|
||||
val safeTitle = title.ifBlank { "Minstrel" }
|
||||
setAVTransportURI(uri, buildDidlLite(uri, mime, safeTitle))
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all tracks from the renderer's queue. UPnP action name is
|
||||
* `RemoveAllTracksFromQueue`. Used at activation time to clear out any
|
||||
* leftover queue from prior sessions before loading our local queue.
|
||||
*/
|
||||
suspend fun removeAllTracksFromQueue() {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "RemoveAllTracksFromQueue",
|
||||
args = mapOf("InstanceID" to "0"),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a contiguous range of tracks from the renderer's native queue.
|
||||
* Sonos-specific extension to AVTransport; `UpdateID=0` skips the queue-
|
||||
* version check so this works without first calling GetQueue to learn
|
||||
* the current update id.
|
||||
*
|
||||
* [startingIndex] is 1-based per Sonos convention; [numberOfTracks] is
|
||||
* the count to remove. Used by OutputPickerController's incremental
|
||||
* queue resync path (radio-append: remove tail, then AddURIToQueue
|
||||
* the new items).
|
||||
*/
|
||||
suspend fun removeTrackRangeFromQueue(startingIndex: Int, numberOfTracks: Int) {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "RemoveTrackRangeFromQueue",
|
||||
args = mapOf(
|
||||
"InstanceID" to "0",
|
||||
"UpdateID" to "0",
|
||||
"StartingIndex" to startingIndex.toString(),
|
||||
"NumberOfTracks" to numberOfTracks.toString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a track to the renderer's queue. Sonos returns the assigned
|
||||
* track number + new total queue length in the response, but we don't
|
||||
* read those (we know our intended position). DIDL-Lite metadata is
|
||||
* required; reuses the same shape as [setAVTransportURIWithMetadata].
|
||||
*
|
||||
* [enqueuedURIPosition] is 1-based; 0 means "append to end" per UPnP.
|
||||
* Our caller passes 1, 2, 3, ... to ensure stable order.
|
||||
*/
|
||||
suspend fun addURIToQueue(
|
||||
uri: String,
|
||||
mime: String,
|
||||
title: String,
|
||||
enqueuedURIPosition: Int = 0,
|
||||
) {
|
||||
val safeTitle = title.ifBlank { "Minstrel" }
|
||||
val didl = buildDidlLite(uri, mime, safeTitle)
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "AddURIToQueue",
|
||||
args = mapOf(
|
||||
"InstanceID" to "0",
|
||||
"EnqueuedURI" to uri,
|
||||
"EnqueuedURIMetaData" to didl,
|
||||
"DesiredFirstTrackNumberEnqueued" to enqueuedURIPosition.toString(),
|
||||
"EnqueueAsNext" to "0",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Skip to the next track in the renderer's queue. */
|
||||
suspend fun next() {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "Next",
|
||||
args = mapOf("InstanceID" to "0"),
|
||||
)
|
||||
}
|
||||
|
||||
/** Skip to the previous track in the renderer's queue. */
|
||||
suspend fun previous() {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "Previous",
|
||||
args = mapOf("InstanceID" to "0"),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to a specific track in the queue. UPnP Seek unit "TRACK_NR";
|
||||
* target is the 1-based track index. Separate from the existing
|
||||
* [seek] which uses unit REL_TIME for position-within-track.
|
||||
*/
|
||||
suspend fun seekToTrack(trackNumber: Int) {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "Seek",
|
||||
args = mapOf(
|
||||
"InstanceID" to "0",
|
||||
"Unit" to "TRACK_NR",
|
||||
"Target" to trackNumber.toString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun play() {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
@@ -35,6 +165,15 @@ class AVTransportClient(
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun pause() {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "Pause",
|
||||
args = mapOf("InstanceID" to "0"),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun stop() {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
@@ -44,7 +183,114 @@ class AVTransportClient(
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun seek(positionMs: Long) {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "Seek",
|
||||
args = mapOf(
|
||||
"InstanceID" to "0",
|
||||
"Unit" to "REL_TIME",
|
||||
"Target" to formatHhMmSs(positionMs),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getPositionInfo(): PositionInfo {
|
||||
val result = soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "GetPositionInfo",
|
||||
args = mapOf("InstanceID" to "0"),
|
||||
)
|
||||
return PositionInfo(
|
||||
track = result["Track"]?.toIntOrNull() ?: 0,
|
||||
trackUri = result["TrackURI"].orEmpty(),
|
||||
relTimeMs = parseHhMmSs(result["RelTime"].orEmpty()),
|
||||
trackDurationMs = parseHhMmSs(result["TrackDuration"].orEmpty()),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getTransportInfo(): TransportInfo {
|
||||
val result = soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "GetTransportInfo",
|
||||
args = mapOf("InstanceID" to "0"),
|
||||
)
|
||||
val state = when (result["CurrentTransportState"]) {
|
||||
"PLAYING" -> TransportState.PLAYING
|
||||
"PAUSED_PLAYBACK" -> TransportState.PAUSED
|
||||
"STOPPED" -> TransportState.STOPPED
|
||||
"TRANSITIONING" -> TransportState.TRANSITIONING
|
||||
else -> TransportState.UNKNOWN
|
||||
}
|
||||
return TransportInfo(state)
|
||||
}
|
||||
|
||||
private fun buildDidlLite(uri: String, mime: String, title: String): String {
|
||||
// Sonos requires (a) the rinconnetworks namespace declared on
|
||||
// <DIDL-Lite> even if we don't use Rincon elements directly, and
|
||||
// (b) a <desc id="cdudn"> element identifying the URI as an
|
||||
// external (non-Sonos-library) source. Without those, Sonos
|
||||
// discards our metadata content and regenerates its own with
|
||||
// class=object.item and the URL query string as the title
|
||||
// (logcat 2026-06-04 confirmed). Match SoCo's pattern.
|
||||
val safeTitle = title.ifBlank { "Minstrel" }
|
||||
return buildString {
|
||||
append("<DIDL-Lite ")
|
||||
append("xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\" ")
|
||||
append("xmlns:dc=\"http://purl.org/dc/elements/1.1/\" ")
|
||||
append("xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" ")
|
||||
append("xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
|
||||
append("<item id=\"-1\" parentID=\"-1\" restricted=\"true\">")
|
||||
append("<dc:title>").append(xmlEscape(safeTitle)).append("</dc:title>")
|
||||
append("<upnp:class>object.item.audioItem.musicTrack</upnp:class>")
|
||||
append("<res protocolInfo=\"http-get:*:").append(xmlEscape(mime))
|
||||
append(":*\">").append(xmlEscape(uri)).append("</res>")
|
||||
append("<desc id=\"cdudn\" ")
|
||||
append("nameSpace=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
|
||||
append("RINCON_AssociatedZPUDN")
|
||||
append("</desc>")
|
||||
append("</item>")
|
||||
append("</DIDL-Lite>")
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatHhMmSs(positionMs: Long): String {
|
||||
val totalSec = (positionMs / MILLIS_PER_SECOND).coerceAtLeast(0)
|
||||
val h = totalSec / SECONDS_PER_HOUR
|
||||
val m = (totalSec % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE
|
||||
val s = totalSec % SECONDS_PER_MINUTE
|
||||
return "%d:%02d:%02d".format(h, m, s)
|
||||
}
|
||||
|
||||
private fun parseHhMmSs(raw: String): Long {
|
||||
val parts = raw.split(':').mapNotNull { it.trim().toLongOrNull() }
|
||||
return if (parts.size == EXPECTED_HMS_PARTS) {
|
||||
val (h, m, s) = parts
|
||||
((h * SECONDS_PER_HOUR) + (m * SECONDS_PER_MINUTE) + s) * MILLIS_PER_SECOND
|
||||
} else {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
|
||||
const val MILLIS_PER_SECOND = 1000L
|
||||
const val SECONDS_PER_MINUTE = 60L
|
||||
const val SECONDS_PER_HOUR = 3600L
|
||||
const val EXPECTED_HMS_PARTS = 3
|
||||
}
|
||||
}
|
||||
|
||||
data class PositionInfo(
|
||||
val track: Int,
|
||||
val trackUri: String,
|
||||
val relTimeMs: Long,
|
||||
val trackDurationMs: Long,
|
||||
)
|
||||
|
||||
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, UNKNOWN }
|
||||
|
||||
data class TransportInfo(val state: TransportState)
|
||||
|
||||
+6
@@ -22,10 +22,12 @@ data class DeviceDescription(
|
||||
val modelName: String,
|
||||
val avTransportControlUrl: HttpUrl,
|
||||
val renderingControlUrl: HttpUrl?,
|
||||
val zoneGroupTopologyControlUrl: HttpUrl? = null,
|
||||
) {
|
||||
companion object {
|
||||
private const val AVT_SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
|
||||
private const val RC_SERVICE_TYPE = "urn:schemas-upnp-org:service:RenderingControl:1"
|
||||
private const val ZGT_SERVICE_TYPE = "urn:schemas-upnp-org:service:ZoneGroupTopology:1"
|
||||
|
||||
private const val TAG_SERVICE = "service"
|
||||
private const val TAG_UDN = "UDN"
|
||||
@@ -43,6 +45,7 @@ data class DeviceDescription(
|
||||
*/
|
||||
fun parse(xml: String, base: HttpUrl): DeviceDescription? {
|
||||
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
|
||||
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
|
||||
setInput(xml.reader())
|
||||
}
|
||||
val acc = ParseState()
|
||||
@@ -58,6 +61,7 @@ data class DeviceDescription(
|
||||
modelName = acc.modelName,
|
||||
avTransportControlUrl = avt,
|
||||
renderingControlUrl = acc.rcControlUrl,
|
||||
zoneGroupTopologyControlUrl = acc.zgtControlUrl,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -91,6 +95,7 @@ data class DeviceDescription(
|
||||
when (acc.serviceType) {
|
||||
AVT_SERVICE_TYPE -> acc.avtControlUrl = resolved
|
||||
RC_SERVICE_TYPE -> acc.rcControlUrl = resolved
|
||||
ZGT_SERVICE_TYPE -> acc.zgtControlUrl = resolved
|
||||
}
|
||||
acc.inService = false
|
||||
}
|
||||
@@ -115,6 +120,7 @@ data class DeviceDescription(
|
||||
var modelName: String = ""
|
||||
var avtControlUrl: HttpUrl? = null
|
||||
var rcControlUrl: HttpUrl? = null
|
||||
var zgtControlUrl: HttpUrl? = null
|
||||
var inService: Boolean = false
|
||||
var serviceType: String = ""
|
||||
var serviceControlUrl: String = ""
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/**
|
||||
* RenderingControl service wrapper for hardware-volume routing while a
|
||||
* UPnP route is active. GetVolume seeds an in-memory cache; SetVolume
|
||||
* is invoked by NowPlayingScreen's volume-key interceptor. Clamps to
|
||||
* the UPnP-standard 0..100 range.
|
||||
*/
|
||||
class RenderingControlClient(
|
||||
private val soap: SoapClient,
|
||||
private val controlUrl: HttpUrl,
|
||||
) {
|
||||
suspend fun getVolume(channel: String = "Master"): Int {
|
||||
val args = soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "GetVolume",
|
||||
args = mapOf("InstanceID" to "0", "Channel" to channel),
|
||||
)
|
||||
return args["CurrentVolume"]?.toIntOrNull() ?: 0
|
||||
}
|
||||
|
||||
suspend fun setVolume(volume: Int, channel: String = "Master") {
|
||||
val clamped = volume.coerceIn(VOLUME_MIN, VOLUME_MAX)
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "SetVolume",
|
||||
args = mapOf(
|
||||
"InstanceID" to "0",
|
||||
"Channel" to channel,
|
||||
"DesiredVolume" to clamped.toString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:RenderingControl:1"
|
||||
const val VOLUME_MIN = 0
|
||||
const val VOLUME_MAX = 100
|
||||
}
|
||||
}
|
||||
+98
-8
@@ -1,5 +1,6 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl
|
||||
@@ -8,6 +9,7 @@ import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Minimal SOAP/UPnP envelope builder + POST. Hand-rolled rather than
|
||||
@@ -25,6 +27,7 @@ import org.xmlpull.v1.XmlPullParserFactory
|
||||
*/
|
||||
class SoapClient(
|
||||
private val okHttp: OkHttpClient,
|
||||
private val onRawResponse: ((action: String, body: String) -> Unit)? = null,
|
||||
) {
|
||||
suspend fun call(
|
||||
controlUrl: HttpUrl,
|
||||
@@ -41,6 +44,7 @@ class SoapClient(
|
||||
.build()
|
||||
okHttp.newCall(request).execute().use { response ->
|
||||
val body = response.body?.string().orEmpty()
|
||||
onRawResponse?.invoke(action, body)
|
||||
if (!response.isSuccessful) {
|
||||
throw SoapFaultException(faultCodeOf(body), faultDescriptionOf(body))
|
||||
}
|
||||
@@ -69,15 +73,9 @@ class SoapClient(
|
||||
append("</s:Envelope>")
|
||||
}
|
||||
|
||||
private fun xmlEscape(v: String): String = v
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
|
||||
private fun parseResponseArgs(body: String, action: String): Map<String, String> {
|
||||
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
|
||||
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
|
||||
setInput(body.reader())
|
||||
}
|
||||
val responseTag = "${action}Response"
|
||||
@@ -102,7 +100,7 @@ class SoapClient(
|
||||
} else {
|
||||
if (inResponse) {
|
||||
val name = parser.name
|
||||
val text = runCatching { parser.nextText() }.getOrDefault("")
|
||||
val text = readElementContent(parser, name)
|
||||
args[name] = text
|
||||
}
|
||||
inResponse
|
||||
@@ -112,6 +110,59 @@ class SoapClient(
|
||||
else -> inResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the content of the currently-started element. Try nextText() first
|
||||
* (works when the content is text -- escaped XML included). If that throws
|
||||
* (because the content is nested elements), manually walk to the matching
|
||||
* END_TAG, accumulating text and re-serializing child elements.
|
||||
*
|
||||
* Some Sonos firmware sends the GetZoneGroupState payload as nested XML
|
||||
* elements without escaping; this fallback recovers that path.
|
||||
*/
|
||||
private fun readElementContent(parser: XmlPullParser, tagName: String): String {
|
||||
return runCatching { parser.nextText() }.getOrElse {
|
||||
readUntilEndTag(parser, tagName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readUntilEndTag(parser: XmlPullParser, tagName: String): String {
|
||||
val sb = StringBuilder()
|
||||
var depth = 1
|
||||
var done = false
|
||||
while (!done && depth > 0) {
|
||||
when (parser.next()) {
|
||||
XmlPullParser.START_TAG -> {
|
||||
appendStartTag(sb, parser)
|
||||
depth += 1
|
||||
}
|
||||
XmlPullParser.END_TAG -> {
|
||||
depth -= 1
|
||||
if (depth == 0 && parser.name == tagName) {
|
||||
done = true
|
||||
} else {
|
||||
sb.append("</").append(parser.name).append('>')
|
||||
}
|
||||
}
|
||||
XmlPullParser.TEXT -> sb.append(parser.text.orEmpty())
|
||||
XmlPullParser.END_DOCUMENT -> done = true
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun appendStartTag(sb: StringBuilder, parser: XmlPullParser) {
|
||||
sb.append('<').append(parser.name)
|
||||
for (i in 0 until parser.attributeCount) {
|
||||
sb.append(' ')
|
||||
.append(parser.getAttributeName(i))
|
||||
.append("=\"")
|
||||
.append(parser.getAttributeValue(i))
|
||||
.append('"')
|
||||
}
|
||||
sb.append('>')
|
||||
}
|
||||
|
||||
private fun faultCodeOf(body: String): String =
|
||||
extractBetween(body, "<errorCode>", "</errorCode>") ?: "unknown"
|
||||
|
||||
@@ -138,3 +189,42 @@ class SoapClient(
|
||||
*/
|
||||
class SoapFaultException(val code: String, val description: String) :
|
||||
Exception("SOAP fault $code: $description")
|
||||
|
||||
private const val MAX_DIAGNOSTIC_RESPONSES = 6 // 3 polls x 2 action types
|
||||
private const val MAX_BODY_LOG_CHARS = 2048
|
||||
|
||||
/**
|
||||
* Returns a [SoapClient] that logs the raw response body for the first
|
||||
* [MAX_DIAGNOSTIC_RESPONSES] calls for actions in [DIAGNOSTIC_ACTIONS]
|
||||
* (GetPositionInfo, GetTransportInfo, GetZoneGroupState). After that the
|
||||
* callback is a no-op so there is no persistent log spam. Logged at WARN
|
||||
* so release builds capture it without a separate log-level override.
|
||||
*/
|
||||
internal fun loggingSoapClient(okHttp: OkHttpClient, label: String): SoapClient {
|
||||
val counter = AtomicInteger(0)
|
||||
return SoapClient(okHttp) { action, body ->
|
||||
if (action !in DIAGNOSTIC_ACTIONS) return@SoapClient
|
||||
val n = counter.incrementAndGet()
|
||||
if (n <= MAX_DIAGNOSTIC_RESPONSES) {
|
||||
Timber.w(
|
||||
"UPnP %s response #%d (%s): %s",
|
||||
label, n, action,
|
||||
body.take(MAX_BODY_LOG_CHARS),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val DIAGNOSTIC_ACTIONS = setOf(
|
||||
"GetPositionInfo",
|
||||
"GetTransportInfo",
|
||||
"GetZoneGroupState",
|
||||
)
|
||||
|
||||
/** XML-escapes a string value for embedding as text content inside a SOAP envelope. */
|
||||
internal fun xmlEscape(v: String): String = v
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
|
||||
+107
-3
@@ -1,7 +1,10 @@
|
||||
@file:Suppress("TooManyFunctions") // discovery + Sonos topology + transport-lookup density
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import android.content.Context
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.player.output.upnp.sonos.SonosZoneGroup
|
||||
import com.fabledsword.minstrel.player.output.upnp.sonos.ZoneGroupTopologyClient
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -13,6 +16,8 @@ import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -41,9 +46,21 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
) {
|
||||
private val ssdp = SsdpDiscovery(context)
|
||||
|
||||
// Transport commands (play/pause/seek/next) and the 1 Hz poll go through
|
||||
// this client. A dead renderer should fail fast so OutputPickerController's
|
||||
// drop-recovery reverts to the phone in ~2 s instead of the shared client's
|
||||
// 10 s connect timeout. Derived via newBuilder() so the connection pool +
|
||||
// dispatcher stay shared with the app client.
|
||||
private val controlHttp: OkHttpClient = okHttp.newBuilder()
|
||||
.connectTimeout(CONTROL_CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
|
||||
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
|
||||
|
||||
private val sonosTopologyInternal = MutableStateFlow<List<SonosZoneGroup>>(emptyList())
|
||||
val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()
|
||||
|
||||
init {
|
||||
ssdp.start(appScope)
|
||||
// appScope is process-lifetime (SupervisorJob + Dispatchers.Default),
|
||||
@@ -83,13 +100,77 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
*/
|
||||
fun transportFor(routeId: String): AVTransportClient? {
|
||||
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
|
||||
return AVTransportClient(SoapClient(okHttp), route.avTransportControlUrl)
|
||||
return AVTransportClient(
|
||||
loggingSoapClient(controlHttp, route.name),
|
||||
route.avTransportControlUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun handleDiscovery(locationUrl: String) {
|
||||
val route = fetchRoute(locationUrl) ?: return
|
||||
routesInternal.value =
|
||||
routesInternal.value.filterNot { it.id == route.id } + route
|
||||
upsertRoute(route)
|
||||
if (route.manufacturer.contains("Sonos", ignoreCase = true)) {
|
||||
refreshSonosTopology(route)
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertRoute(route: UpnpRoute) {
|
||||
val current = routesInternal.value
|
||||
val idx = current.indexOfFirst { it.id == route.id }
|
||||
routesInternal.value = if (idx < 0) {
|
||||
current + route
|
||||
} else {
|
||||
current.toMutableList().also { it[idx] = route }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshSonosTopology(anySonos: UpnpRoute) {
|
||||
val zgtUrl = anySonos.zoneGroupTopologyControlUrl ?: run {
|
||||
Timber.w(
|
||||
"Sonos %s has no ZoneGroupTopology URL -- topology grouping disabled",
|
||||
anySonos.id,
|
||||
)
|
||||
return
|
||||
}
|
||||
val groups = runCatching {
|
||||
ZoneGroupTopologyClient(
|
||||
loggingSoapClient(okHttp, "ZGT-${anySonos.id}"),
|
||||
zgtUrl,
|
||||
).getZoneGroupState()
|
||||
}
|
||||
.onFailure { Timber.w(it, "refreshSonosTopology failed for %s", anySonos.id) }
|
||||
.getOrNull() ?: return
|
||||
Timber.w(
|
||||
"Sonos topology refreshed for %s: %d group(s)",
|
||||
anySonos.id,
|
||||
groups.size,
|
||||
)
|
||||
sonosTopologyInternal.value = groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the coordinator UDN's full route for the group [udn] belongs
|
||||
* to, or null if topology hasn't loaded / the udn is in no group. UDN
|
||||
* normalization strips the "uuid:" prefix because DeviceDescription's
|
||||
* <UDN> carries it but Sonos's ZoneGroupState Coordinator attr does not.
|
||||
*/
|
||||
fun coordinatorRouteFor(udn: String): UpnpRoute? {
|
||||
val bare = udn.bareUdn()
|
||||
val coord = sonosTopologyInternal.value
|
||||
.firstOrNull { g -> g.members.any { it.udn.bareUdn() == bare } }
|
||||
?.coordinatorUdn ?: return null
|
||||
return routesInternal.value.firstOrNull { it.id.bareUdn() == coord.bareUdn() }
|
||||
}
|
||||
|
||||
/**
|
||||
* UDNs of every NON-coordinator Sonos group member. The picker
|
||||
* controller suppresses these from the visible list.
|
||||
*/
|
||||
fun nonCoordinatorMemberUdns(): Set<String> {
|
||||
return sonosTopologyInternal.value.flatMap { g ->
|
||||
g.members.map { it.udn.bareUdn() }
|
||||
.filter { it != g.coordinatorUdn.bareUdn() }
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +192,7 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
modelName = desc.modelName,
|
||||
avTransportControlUrl = desc.avTransportControlUrl,
|
||||
renderingControlUrl = desc.renderingControlUrl,
|
||||
zoneGroupTopologyControlUrl = desc.zoneGroupTopologyControlUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -153,5 +235,27 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
// devices append. Anchored to end-of-string so it never eats
|
||||
// a legitimate parenthetical inside a name.
|
||||
val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""")
|
||||
|
||||
// Aggressive connect timeout for transport SOAP so a dead renderer
|
||||
// trips the drop-recovery quickly. Conservative enough not to false-drop
|
||||
// a slow-but-alive LAN; read timeout still inherits the shared client.
|
||||
const val CONTROL_CONNECT_TIMEOUT_SECONDS = 2L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a Sonos UDN to its bare RINCON form for cross-comparison.
|
||||
*
|
||||
* Three places use UDN strings with different conventions:
|
||||
* - DeviceDescription's <UDN> tag carries the `uuid:` prefix.
|
||||
* - Sonos exposes one UDN per embedded device. The MediaRenderer adds
|
||||
* a `_MR` suffix and the MediaServer adds `_MS`.
|
||||
* - The ZGT GetZoneGroupState response uses bare `RINCON_xxx` with
|
||||
* neither the `uuid:` prefix nor any device suffix.
|
||||
*
|
||||
* To compare any pair of those, strip the prefix and the suffix so
|
||||
* everything reduces to the underlying speaker identity.
|
||||
*/
|
||||
internal fun String.bareUdn(): String = removePrefix("uuid:")
|
||||
.removeSuffix("_MR")
|
||||
.removeSuffix("_MS")
|
||||
|
||||
@@ -7,10 +7,10 @@ import okhttp3.HttpUrl
|
||||
* Bose SoundTouch, generic DLNA renderers). Lifted out of the SOAP /
|
||||
* SSDP details so the picker UI consumes a narrow domain shape.
|
||||
*
|
||||
* Generic UPnP only for THIS slice — Sonos-specific grouping value-adds
|
||||
* (group join/leave, zone topology) live in a separate Sonos extension
|
||||
* scoped in
|
||||
* docs/superpowers/specs/2026-06-03-android-output-picker-upnp-scope.md.
|
||||
* Sonos devices additionally populate [zoneGroupTopologyControlUrl] from the
|
||||
* ZoneGroupTopology service in their device description; non-Sonos devices
|
||||
* leave it null. The discovery controller uses that URL to aggregate stereo
|
||||
* pairs and multi-speaker groups into single picker rows.
|
||||
*
|
||||
* [id] is the device UDN (e.g. `uuid:RINCON_ABC...`). [name] is the
|
||||
* raw `<friendlyName>` straight from the device description — callers
|
||||
@@ -24,4 +24,5 @@ data class UpnpRoute(
|
||||
val modelName: String,
|
||||
val avTransportControlUrl: HttpUrl,
|
||||
val renderingControlUrl: HttpUrl?,
|
||||
val zoneGroupTopologyControlUrl: HttpUrl? = null,
|
||||
)
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp.sonos
|
||||
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
|
||||
/**
|
||||
* One Sonos zone group as exposed by ZoneGroupTopology.GetZoneGroupState.
|
||||
* Stereo pairs and multi-speaker groups all appear as one group with
|
||||
* multiple members; one member is the coordinator we send SOAP to.
|
||||
*/
|
||||
data class SonosZoneGroup(
|
||||
val coordinatorUdn: String,
|
||||
val name: String,
|
||||
val members: List<SonosZoneMember>,
|
||||
)
|
||||
|
||||
data class SonosZoneMember(
|
||||
val udn: String,
|
||||
val roomName: String,
|
||||
val location: String?,
|
||||
val channelMapSet: String?,
|
||||
)
|
||||
|
||||
object SonosTopology {
|
||||
|
||||
fun parse(xml: String): List<SonosZoneGroup> {
|
||||
val effective = if (xml.contains("<ZoneGroup")) {
|
||||
unescapeXmlEntities(xml)
|
||||
} else {
|
||||
xml
|
||||
}
|
||||
return runCatching { parseStrict(effective) }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun unescapeXmlEntities(s: String): String = s
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("&", "&") // must be last to avoid double-decoding
|
||||
|
||||
private fun parseStrict(xml: String): List<SonosZoneGroup> {
|
||||
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
|
||||
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
|
||||
setInput(xml.reader())
|
||||
}
|
||||
val groups = mutableListOf<SonosZoneGroup>()
|
||||
var currentCoordinator: String? = null
|
||||
var currentMembers: MutableList<SonosZoneMember>? = null
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (parser.eventType) {
|
||||
XmlPullParser.START_TAG -> when (parser.name) {
|
||||
TAG_ZONE_GROUP -> {
|
||||
currentCoordinator = parser.getAttributeValue(null, ATTR_COORDINATOR)
|
||||
currentMembers = mutableListOf()
|
||||
}
|
||||
TAG_ZONE_GROUP_MEMBER -> currentMembers?.add(memberOf(parser))
|
||||
}
|
||||
XmlPullParser.END_TAG -> if (parser.name == TAG_ZONE_GROUP) {
|
||||
val members = currentMembers ?: emptyList()
|
||||
val coord = currentCoordinator
|
||||
if (coord != null && members.isNotEmpty()) {
|
||||
val name = members.firstOrNull { it.udn == coord }?.roomName
|
||||
?: members.first().roomName
|
||||
groups.add(SonosZoneGroup(coord, name, members))
|
||||
}
|
||||
currentCoordinator = null
|
||||
currentMembers = null
|
||||
}
|
||||
}
|
||||
parser.next()
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
private fun memberOf(parser: XmlPullParser): SonosZoneMember = SonosZoneMember(
|
||||
udn = parser.getAttributeValue(null, ATTR_UUID).orEmpty(),
|
||||
roomName = parser.getAttributeValue(null, ATTR_ZONE_NAME).orEmpty(),
|
||||
location = parser.getAttributeValue(null, ATTR_LOCATION),
|
||||
channelMapSet = parser.getAttributeValue(null, ATTR_CHANNEL_MAP_SET),
|
||||
)
|
||||
|
||||
private const val TAG_ZONE_GROUP = "ZoneGroup"
|
||||
private const val TAG_ZONE_GROUP_MEMBER = "ZoneGroupMember"
|
||||
private const val ATTR_COORDINATOR = "Coordinator"
|
||||
private const val ATTR_UUID = "UUID"
|
||||
private const val ATTR_ZONE_NAME = "ZoneName"
|
||||
private const val ATTR_LOCATION = "Location"
|
||||
private const val ATTR_CHANNEL_MAP_SET = "ChannelMapSet"
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp.sonos
|
||||
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
||||
import okhttp3.HttpUrl
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Sonos's proprietary ZoneGroupTopology service. Same SOAP shape as a
|
||||
* standard UPnP service, exposed on port 1400 at
|
||||
* /ZoneGroupTopology/Control. GetZoneGroupState returns the full
|
||||
* network topology as one XML doc wrapped inside a SOAP arg.
|
||||
*/
|
||||
class ZoneGroupTopologyClient(
|
||||
private val soap: SoapClient,
|
||||
private val controlUrl: HttpUrl,
|
||||
) {
|
||||
suspend fun getZoneGroupState(): List<SonosZoneGroup> {
|
||||
val args = soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "GetZoneGroupState",
|
||||
args = emptyMap(),
|
||||
)
|
||||
val inner = args["ZoneGroupState"].orEmpty()
|
||||
Timber.w(
|
||||
"ZGT extracted ZoneGroupState (%d chars): %s",
|
||||
inner.length,
|
||||
inner.take(ZGT_LOG_TRUNCATE_CHARS),
|
||||
)
|
||||
return SonosTopology.parse(inner)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:ZoneGroupTopology:1"
|
||||
const val ZGT_LOG_TRUNCATE_CHARS = 2048
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -164,6 +165,7 @@ fun MiniPlayer(
|
||||
MiniRow(
|
||||
track = track,
|
||||
isPlaying = state.isPlaying,
|
||||
isUpnpLoading = state.isUpnpLoading,
|
||||
isLiked = isLiked,
|
||||
onExpandClick = onExpandClick,
|
||||
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
|
||||
@@ -204,6 +206,7 @@ private fun MiniProgressFill(positionMs: Long, durationMs: Long) {
|
||||
private fun MiniRow(
|
||||
track: TrackRef,
|
||||
isPlaying: Boolean,
|
||||
isUpnpLoading: Boolean,
|
||||
isLiked: Boolean,
|
||||
onExpandClick: () -> Unit,
|
||||
onPlayPause: () -> Unit,
|
||||
@@ -248,15 +251,39 @@ private fun MiniRow(
|
||||
}
|
||||
LikeButton(liked = isLiked, onToggle = onToggleLike)
|
||||
TransportButton(icon = Lucide.SkipBack, description = "Previous", onClick = onPrev)
|
||||
TransportButton(
|
||||
icon = if (isPlaying) Lucide.Pause else Lucide.Play,
|
||||
description = if (isPlaying) "Pause" else "Play",
|
||||
MiniPlayPauseButton(
|
||||
isPlaying = isPlaying,
|
||||
isUpnpLoading = isUpnpLoading,
|
||||
onClick = onPlayPause,
|
||||
)
|
||||
TransportButton(icon = Lucide.SkipForward, description = "Next", onClick = onNext)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MiniPlayPauseButton(
|
||||
isPlaying: Boolean,
|
||||
isUpnpLoading: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
IconButton(onClick = onClick, enabled = !isUpnpLoading) {
|
||||
if (isUpnpLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(MINI_PLAY_PAUSE_SPINNER_DP.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val MINI_PLAY_PAUSE_SPINNER_DP = 24
|
||||
|
||||
@Composable
|
||||
private fun TransportButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -25,6 +26,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -50,10 +52,19 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -74,11 +85,13 @@ import com.composables.icons.lucide.Shuffle
|
||||
import com.composables.icons.lucide.SkipBack
|
||||
import com.composables.icons.lucide.SkipForward
|
||||
import com.fabledsword.minstrel.player.RepeatMode
|
||||
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
||||
import com.fabledsword.minstrel.player.output.DeviceChip
|
||||
import com.fabledsword.minstrel.player.output.OutputPickerSheet
|
||||
import com.fabledsword.minstrel.player.output.OutputPickerViewModel
|
||||
import com.fabledsword.minstrel.player.output.OutputRoute
|
||||
import com.fabledsword.minstrel.player.output.RouteSnapshot
|
||||
import kotlinx.coroutines.launch
|
||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.nav.HERO_KEY_NOW_PLAYING_COVER
|
||||
@@ -129,27 +142,30 @@ fun NowPlayingScreen(
|
||||
snackbarHostState.showSnackbar(msg)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.dropEvents.collect { snackbarHostState.showSnackbar("Disconnected from $it") }
|
||||
}
|
||||
val track = state.currentTrack
|
||||
if (track == null) {
|
||||
// Session torn down (queue finished + auto-stop, or user cleared
|
||||
// the queue from elsewhere). Pop back to whichever shell screen
|
||||
// launched NowPlaying rather than stranding the user on an
|
||||
// EmptyState with no escape. A short delay swallows the
|
||||
// momentary null during MediaController IPC bind on cold-mount.
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(POP_GRACE_MS)
|
||||
if (viewModel.uiState.value.currentTrack == null) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
NowPlayingNullTrackGuard(navController, viewModel)
|
||||
return
|
||||
}
|
||||
val outputViewModel: OutputPickerViewModel = hiltViewModel()
|
||||
val activeUpnp by outputViewModel.activeUpnp.collectAsStateWithLifecycle()
|
||||
val onKeyEvent = rememberUpnpVolumeKeyHandler(activeUpnp)
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(activeUpnp) { if (activeUpnp != null) focusRequester.requestFocus() }
|
||||
val dominant = rememberDominantColor(track.coverUrl)
|
||||
val dismissConnection = rememberDragDismissConnection(
|
||||
onDismiss = { navController.popBackStack() },
|
||||
)
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize().nestedScroll(dismissConnection),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(dismissConnection)
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
.onKeyEvent(onKeyEvent),
|
||||
topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) },
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
containerColor = Color.Transparent,
|
||||
@@ -166,11 +182,32 @@ fun NowPlayingScreen(
|
||||
navController = navController,
|
||||
viewModel = viewModel,
|
||||
trackActionsViewModel = trackActionsViewModel,
|
||||
outputViewModel = outputViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Null-track guard extracted from [NowPlayingScreen] to keep that
|
||||
* function under detekt's LongMethod ceiling. Session torn down
|
||||
* (queue finished + auto-stop, or user cleared the queue from
|
||||
* elsewhere). Pops back after a short grace delay so a momentary
|
||||
* null during MediaController IPC bind on cold-mount doesn't flash.
|
||||
*/
|
||||
@Composable
|
||||
private fun NowPlayingNullTrackGuard(
|
||||
navController: NavHostController,
|
||||
viewModel: PlayerViewModel,
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(POP_GRACE_MS)
|
||||
if (viewModel.uiState.value.currentTrack == null) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun dominantGradient(top: Color): Brush {
|
||||
val base = MaterialTheme.colorScheme.background
|
||||
@@ -270,6 +307,7 @@ private fun NowPlayingTopBar(onClose: () -> Unit) {
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList") // Compose screen wiring — layout args, not logic
|
||||
@Composable
|
||||
private fun NowPlayingBody(
|
||||
inner: androidx.compose.foundation.layout.PaddingValues,
|
||||
@@ -278,10 +316,10 @@ private fun NowPlayingBody(
|
||||
navController: NavHostController,
|
||||
viewModel: PlayerViewModel,
|
||||
trackActionsViewModel: TrackActionsViewModel,
|
||||
outputViewModel: OutputPickerViewModel,
|
||||
) {
|
||||
val isLiked by trackActionsViewModel.isLikedFlow(track.id)
|
||||
.collectAsStateWithLifecycle(initialValue = false)
|
||||
val outputViewModel: OutputPickerViewModel = hiltViewModel()
|
||||
val routes by outputViewModel.routes.collectAsStateWithLifecycle()
|
||||
val sheetVisible by outputViewModel.sheetVisible.collectAsStateWithLifecycle()
|
||||
val permissionDenied = rememberBluetoothPermissionState(sheetVisible)
|
||||
@@ -387,6 +425,7 @@ private fun PlaybackControlsBlock(
|
||||
Spacer(Modifier.height(4.dp))
|
||||
TransportRow(
|
||||
isPlaying = state.isPlaying,
|
||||
isUpnpLoading = state.isUpnpLoading,
|
||||
onPrev = viewModel::skipToPrevious,
|
||||
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
|
||||
onNext = viewModel::skipToNext,
|
||||
@@ -667,6 +706,7 @@ private fun ScrubTrack(fraction: Float, accent: Color) {
|
||||
@Composable
|
||||
private fun TransportRow(
|
||||
isPlaying: Boolean,
|
||||
isUpnpLoading: Boolean,
|
||||
onPrev: () -> Unit,
|
||||
onPlayPause: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
@@ -685,13 +725,20 @@ private fun TransportRow(
|
||||
modifier = Modifier.size(TRANSPORT_ICON_DP.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onPlayPause) {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
tint = actionColors.primary,
|
||||
modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp),
|
||||
)
|
||||
IconButton(onClick = onPlayPause, enabled = !isUpnpLoading) {
|
||||
if (isUpnpLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp),
|
||||
strokeWidth = 3.dp,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
tint = actionColors.primary,
|
||||
modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onNext) {
|
||||
Icon(
|
||||
@@ -703,3 +750,40 @@ private fun TransportRow(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a key-event handler that intercepts volume-up/down when a UPnP
|
||||
* route is active and routes the step through [ActiveUpnp.rendering].
|
||||
* Volume is cached locally so rapid key presses don't each wait on a
|
||||
* getVolume() round-trip. Returns false (not consumed) for every event
|
||||
* when no UPnP route is active so the system handles volume normally.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberUpnpVolumeKeyHandler(activeUpnp: ActiveUpnp?): (KeyEvent) -> Boolean {
|
||||
val scope = rememberCoroutineScope()
|
||||
val cache = remember(activeUpnp?.routeId) { VolumeCache() }
|
||||
return remember(activeUpnp) {
|
||||
handler@{ event: KeyEvent ->
|
||||
val rc = activeUpnp?.rendering ?: return@handler false
|
||||
if (event.type != KeyEventType.KeyDown) return@handler false
|
||||
val delta = when (event.key) {
|
||||
Key.VolumeUp -> VOLUME_KEY_STEP
|
||||
Key.VolumeDown -> -VOLUME_KEY_STEP
|
||||
else -> return@handler false
|
||||
}
|
||||
scope.launch {
|
||||
val current = cache.value ?: runCatching { rc.getVolume() }.getOrNull() ?: 0
|
||||
val next = (current + delta).coerceIn(VOLUME_MIN_PERCENT, VOLUME_MAX_PERCENT)
|
||||
cache.value = next
|
||||
runCatching { rc.setVolume(next) }
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class VolumeCache(var value: Int? = null)
|
||||
|
||||
private const val VOLUME_KEY_STEP = 5
|
||||
private const val VOLUME_MIN_PERCENT = 0
|
||||
private const val VOLUME_MAX_PERCENT = 100
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
import com.fabledsword.minstrel.player.PlayerUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -24,6 +25,7 @@ class PlayerViewModel @Inject constructor(
|
||||
) : ViewModel() {
|
||||
|
||||
val uiState: StateFlow<PlayerUiState> = controller.uiState
|
||||
val dropEvents: SharedFlow<String> = controller.dropEvents
|
||||
|
||||
fun play() = controller.play()
|
||||
fun pause() = controller.pause()
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.fabledsword.minstrel.playlists.data
|
||||
|
||||
import com.fabledsword.minstrel.models.PlaylistRef
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
private const val PLAYLIST_FETCH_TIMEOUT_MS = 8_000L
|
||||
|
||||
/**
|
||||
* Fetch a playlist and hand it to the player as a shuffled queue.
|
||||
*
|
||||
* Behavior matches the tile play-button contract used on Home and the
|
||||
* Playlists list: refreshable system playlists go through systemShuffle
|
||||
* (server-side rotation-aware order; tagging with the variant advances
|
||||
* rotation), everything else uses refreshDetail. System playlists are
|
||||
* then client-side shuffled so the tile feels random rather than
|
||||
* "start at the rotation head". User playlists keep their authored
|
||||
* order.
|
||||
*
|
||||
* Errors and empty mixes surface through [onMessage] for the caller to
|
||||
* present as a snackbar / toast / etc. Returns when the player has
|
||||
* accepted the queue (or an error path bailed).
|
||||
*/
|
||||
suspend fun playPlaylistShuffled(
|
||||
playlist: PlaylistRef,
|
||||
repository: PlaylistsRepository,
|
||||
player: PlayerController,
|
||||
onMessage: (String) -> Unit,
|
||||
) {
|
||||
val detail = fetchPlaylistDetail(playlist, repository, onMessage) ?: return
|
||||
val tracks = detail.tracks.toPlayableTrackRefs()
|
||||
if (tracks.isEmpty()) {
|
||||
onMessage("Mix isn't ready yet - try again in a moment")
|
||||
return
|
||||
}
|
||||
// Drift #564: bare systemVariant string (not "playlist:<variant>") --
|
||||
// server's rotation matcher keys on the bare variant.
|
||||
val source = if (playlist.refreshable) playlist.systemVariant else null
|
||||
// System playlist tile play button == "pick a random song + shuffle
|
||||
// the rest" UX. Server's rotation-aware order still drives rotation
|
||||
// bookkeeping via `source`; the client shuffle just removes the
|
||||
// deterministic "start at rotation head" feel.
|
||||
val ordered = if (playlist.isSystem) tracks.shuffled() else tracks
|
||||
player.setQueue(ordered, initialIndex = 0, source = source)
|
||||
}
|
||||
|
||||
private suspend fun fetchPlaylistDetail(
|
||||
playlist: PlaylistRef,
|
||||
repository: PlaylistsRepository,
|
||||
onMessage: (String) -> Unit,
|
||||
): PlaylistDetailRef? = try {
|
||||
withTimeout(PLAYLIST_FETCH_TIMEOUT_MS) {
|
||||
if (playlist.refreshable && playlist.systemVariant != null) {
|
||||
repository.systemShuffle(playlist.systemVariant)
|
||||
} else {
|
||||
repository.refreshDetail(playlist.id)
|
||||
}
|
||||
}
|
||||
} catch (
|
||||
@Suppress("SwallowedException") _: TimeoutCancellationException,
|
||||
) {
|
||||
onMessage("Couldn't load playlist - check your connection")
|
||||
null
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
onMessage("Couldn't load playlist: ${ErrorCopy.fromThrowable(e)}")
|
||||
null
|
||||
}
|
||||
+28
-2
@@ -7,6 +7,7 @@ import retrofit2.HttpException
|
||||
import java.net.HttpURLConnection
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.PlaylistCachedCount
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||
@@ -18,6 +19,7 @@ import com.fabledsword.minstrel.models.wire.PlaylistTrackWire
|
||||
import com.fabledsword.minstrel.models.wire.PlaylistWire
|
||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
@@ -51,9 +53,16 @@ class PlaylistsRepository @Inject constructor(
|
||||
|
||||
// ── Reads (Flow, cache-first; the cache is the source of truth) ──
|
||||
|
||||
/** Owned + public; UI splits by isSystem / userId comparison. */
|
||||
/**
|
||||
* Owned + public; UI splits by isSystem / userId comparison. Joined with
|
||||
* per-playlist cache counts so [PlaylistRef.fullyCached] drives the offline
|
||||
* greying without each consumer re-querying the cache index.
|
||||
*/
|
||||
fun observeAll(): Flow<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). */
|
||||
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) ──
|
||||
|
||||
/**
|
||||
* 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 =
|
||||
PlaylistRef(
|
||||
id = id,
|
||||
|
||||
+68
-2
@@ -13,9 +13,13 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@@ -23,11 +27,15 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.models.PlaylistRef
|
||||
import com.fabledsword.minstrel.nav.PlaylistDetail
|
||||
import com.fabledsword.minstrel.nav.Playlists
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
||||
import com.fabledsword.minstrel.playlists.data.playPlaylistShuffled
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
||||
@@ -37,12 +45,19 @@ import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────
|
||||
|
||||
// ─── ViewModel ───────────────────────────────────────────────────────
|
||||
@@ -50,9 +65,25 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class PlaylistsListViewModel @Inject constructor(
|
||||
private val repository: PlaylistsRepository,
|
||||
private val player: PlayerController,
|
||||
private val eventsStream: EventsStream,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val poolMessages = Channel<String>(Channel.BUFFERED)
|
||||
|
||||
/** Transient snackbar messages from playlist tile play taps. */
|
||||
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
||||
|
||||
/** Cache-only: no link OR server unreachable (Unstable stays calm). Greys tiles. */
|
||||
val offline: StateFlow<Boolean> = networkStatus.state
|
||||
.map { it == ServerHealth.Offline || it == ServerHealth.ServerDown }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// Live updates: a playlist created/updated/deleted from another
|
||||
@@ -69,6 +100,15 @@ class PlaylistsListViewModel @Inject constructor(
|
||||
runCatching { repository.refreshList() }
|
||||
}
|
||||
|
||||
/** Tile play button: shuffle the playlist's tracks and start at index 0. */
|
||||
suspend fun playPlaylist(playlist: PlaylistRef) {
|
||||
viewModelScope.launch {
|
||||
playPlaylistShuffled(playlist, repository, player) {
|
||||
poolMessages.trySend(it)
|
||||
}
|
||||
}.join()
|
||||
}
|
||||
|
||||
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
|
||||
repository.observeAll()
|
||||
.map { list ->
|
||||
@@ -88,6 +128,10 @@ fun PlaylistsListScreen(
|
||||
navController: NavHostController,
|
||||
viewModel: PlaylistsListViewModel = hiltViewModel(),
|
||||
) {
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.transientMessages.collect { snackbar.showSnackbar(it) }
|
||||
}
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
@@ -97,8 +141,10 @@ fun PlaylistsListScreen(
|
||||
currentRouteName = Playlists::class.qualifiedName,
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbar) },
|
||||
) { inner ->
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
PullToRefreshScaffold(
|
||||
onRefresh = { viewModel.refresh().join() },
|
||||
modifier = Modifier.fillMaxSize().padding(inner),
|
||||
@@ -117,7 +163,9 @@ fun PlaylistsListScreen(
|
||||
)
|
||||
is UiState.Success -> PlaylistsGrid(
|
||||
playlists = s.data,
|
||||
offline = offline,
|
||||
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
|
||||
onPlay = viewModel::playPlaylist,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -127,7 +175,9 @@ fun PlaylistsListScreen(
|
||||
@Composable
|
||||
private fun PlaylistsGrid(
|
||||
playlists: List<PlaylistRef>,
|
||||
offline: Boolean,
|
||||
onPlaylistClick: (String) -> Unit,
|
||||
onPlay: suspend (PlaylistRef) -> Unit,
|
||||
) {
|
||||
val systemPlaylists = playlists.filter { it.isSystem }
|
||||
val userPlaylists = playlists.filter { !it.isSystem }
|
||||
@@ -142,7 +192,16 @@ private fun PlaylistsGrid(
|
||||
SectionHeader("System playlists")
|
||||
}
|
||||
items(items = systemPlaylists, key = { it.id }) { playlist ->
|
||||
PlaylistCard(playlist = playlist, onClick = { onPlaylistClick(playlist.id) })
|
||||
// Greyed offline when it needs the live server or isn't fully
|
||||
// cached — dimmed but still tappable into the detail.
|
||||
val greyed = offline && playlist.unavailableOffline
|
||||
PlaylistCard(
|
||||
playlist = playlist,
|
||||
onClick = { onPlaylistClick(playlist.id) },
|
||||
onPlay = { onPlay(playlist) },
|
||||
playEnabled = playlist.trackCount > 0 && !greyed,
|
||||
greyed = greyed,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (userPlaylists.isNotEmpty()) {
|
||||
@@ -150,7 +209,14 @@ private fun PlaylistsGrid(
|
||||
SectionHeader("Your playlists")
|
||||
}
|
||||
items(items = userPlaylists, key = { it.id }) { playlist ->
|
||||
PlaylistCard(playlist = playlist, onClick = { onPlaylistClick(playlist.id) })
|
||||
val greyed = offline && playlist.unavailableOffline
|
||||
PlaylistCard(
|
||||
playlist = playlist,
|
||||
onClick = { onPlaylistClick(playlist.id) },
|
||||
onPlay = { onPlay(playlist) },
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -40,6 +41,10 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
||||
* [playEnabled] disables the overlay (50% alpha + ignore taps) — Home
|
||||
* uses it for system playlists in offline mode (their server-side
|
||||
* shuffle endpoint is unreachable) and for empty playlists.
|
||||
*
|
||||
* [greyed] dims the whole tile (it can't be reliably played offline) while
|
||||
* keeping it tappable — the detail screen is the escape hatch for shuffling
|
||||
* whatever subset is cached. Greyed always disables the play overlay too.
|
||||
*/
|
||||
@Composable
|
||||
fun PlaylistCard(
|
||||
@@ -48,11 +53,13 @@ fun PlaylistCard(
|
||||
modifier: Modifier = Modifier,
|
||||
onPlay: (suspend () -> Unit)? = null,
|
||||
playEnabled: Boolean = true,
|
||||
greyed: Boolean = false,
|
||||
) {
|
||||
val seedCache = LocalDetailSeedCache.current
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.width(176.dp)
|
||||
.alpha(if (greyed) GREYED_ALPHA else 1f) // dimmed, still clickable
|
||||
.clickable {
|
||||
seedCache.stashPlaylist(playlist)
|
||||
onClick()
|
||||
@@ -63,7 +70,7 @@ fun PlaylistCard(
|
||||
PlaylistCardCover(
|
||||
playlist = playlist,
|
||||
onPlay = onPlay,
|
||||
playEnabled = playEnabled,
|
||||
playEnabled = playEnabled && !greyed,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
@@ -148,6 +155,7 @@ private fun VariantPill(label: String, modifier: Modifier = Modifier) {
|
||||
}
|
||||
|
||||
private const val PILL_BG_ALPHA = 0.85f
|
||||
private const val GREYED_ALPHA = 0.45f
|
||||
|
||||
private fun subtitleFor(playlist: PlaylistRef): String = when {
|
||||
playlist.trackCount > 0 -> "${playlist.trackCount} tracks"
|
||||
|
||||
+47
-1
@@ -5,11 +5,13 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.models.RequestRef
|
||||
import com.fabledsword.minstrel.models.RequestStatus
|
||||
import com.fabledsword.minstrel.requests.data.CancelOutcome
|
||||
import com.fabledsword.minstrel.requests.data.RequestsRepository
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -20,6 +22,10 @@ import javax.inject.Inject
|
||||
|
||||
private val RELEVANT_EVENT_KINDS = setOf("request.status_changed")
|
||||
|
||||
// Cadence for in-flight request polling — matches the web client (#369):
|
||||
// fast enough to feel live, slow enough not to hammer the server.
|
||||
private const val POLL_INTERVAL_MS = 12_000L
|
||||
|
||||
@HiltViewModel
|
||||
class RequestsViewModel @Inject constructor(
|
||||
private val repository: RequestsRepository,
|
||||
@@ -31,11 +37,14 @@ class RequestsViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// SSE push: reconciler completions arrive here; reload silently so the
|
||||
// row updates in place rather than flashing the loading spinner.
|
||||
viewModelScope.launch {
|
||||
eventsStream.events
|
||||
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
||||
.collect { refresh() }
|
||||
.collect { silentReload() }
|
||||
}
|
||||
viewModelScope.launch { pollWhileInFlight() }
|
||||
}
|
||||
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
@@ -54,6 +63,43 @@ class RequestsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-poll while any request is mid-ingest (status APPROVED), matching the
|
||||
* web client (#369). Reloads silently every [POLL_INTERVAL_MS] and pauses
|
||||
* when nothing is in-flight — complements the SSE push so progress stays
|
||||
* live even if the event stream drops.
|
||||
*/
|
||||
private suspend fun pollWhileInFlight() {
|
||||
while (true) {
|
||||
if (hasInFlightRequest()) {
|
||||
silentReload()
|
||||
}
|
||||
delay(POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasInFlightRequest(): Boolean {
|
||||
val state = internal.value
|
||||
return state is UiState.Success &&
|
||||
state.data.any { it.status == RequestStatus.APPROVED }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload the list in place without flipping to Loading — used by the poll
|
||||
* loop and the SSE collector so visible rows update without a spinner
|
||||
* flash. A transient failure keeps the current view; the next reload wins.
|
||||
*/
|
||||
private suspend fun silentReload() {
|
||||
try {
|
||||
val rows = repository.listMine()
|
||||
internal.value = if (rows.isEmpty()) UiState.Empty else UiState.Success(rows)
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Keep the current view; the next poll / SSE / pull reconciles.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistically removes [id] from the success list so the row
|
||||
* disappears immediately; on server-side success the row is
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package com.fabledsword.minstrel.search.data
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.SearchApi
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.library.data.toDomain
|
||||
import com.fabledsword.minstrel.models.SearchResponseRef
|
||||
import retrofit2.Retrofit
|
||||
@@ -8,17 +13,35 @@ import retrofit2.create
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val LOCAL_SEARCH_LIMIT = 20
|
||||
|
||||
/**
|
||||
* Thin Retrofit wrapper around `/api/search`. Debouncing lives in
|
||||
* the ViewModel, not here, so the repository stays trivial.
|
||||
* Cache-first when offline. When [NetworkStatusController] reports Healthy or
|
||||
* Unstable the repository hits `/api/search` and returns the server's three-facet
|
||||
* paged response. When health is Offline or ServerDown it falls back to
|
||||
* Room LIKE queries against `cached_*` so the user can still find
|
||||
* something to play from what's already on the device; the outcome's
|
||||
* [SearchOutcome.localOnly] flag lets the screen draw an "offline
|
||||
* results" hint instead of pretending the server answered.
|
||||
*/
|
||||
@Singleton
|
||||
class SearchRepository @Inject constructor(
|
||||
retrofit: Retrofit,
|
||||
private val serverHealth: NetworkStatusController,
|
||||
private val trackDao: CachedTrackDao,
|
||||
private val albumDao: CachedAlbumDao,
|
||||
private val artistDao: CachedArtistDao,
|
||||
) {
|
||||
private val api: SearchApi = retrofit.create()
|
||||
|
||||
suspend fun search(query: String): SearchResponseRef {
|
||||
suspend fun search(query: String): SearchOutcome = when (serverHealth.state.value) {
|
||||
ServerHealth.Healthy, ServerHealth.Unstable ->
|
||||
SearchOutcome(remoteSearch(query), localOnly = false)
|
||||
ServerHealth.Offline, ServerHealth.ServerDown ->
|
||||
SearchOutcome(localSearch(query), localOnly = true)
|
||||
}
|
||||
|
||||
private suspend fun remoteSearch(query: String): SearchResponseRef {
|
||||
val wire = api.search(query)
|
||||
return SearchResponseRef(
|
||||
artists = wire.artists.items.map { it.toDomain() },
|
||||
@@ -26,4 +49,21 @@ class SearchRepository @Inject constructor(
|
||||
tracks = wire.tracks.items.map { it.toDomain() },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun localSearch(query: String): SearchResponseRef = SearchResponseRef(
|
||||
artists = artistDao.searchByName(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() },
|
||||
albums = albumDao.searchByTitle(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() },
|
||||
tracks = trackDao.searchByTitle(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the search response with the signal of whether the result came
|
||||
* from the server or from the local cached entities. The screen renders
|
||||
* the same SearchResponseRef either way; the flag drives the offline
|
||||
* banner copy.
|
||||
*/
|
||||
data class SearchOutcome(
|
||||
val response: SearchResponseRef,
|
||||
val localOnly: Boolean,
|
||||
)
|
||||
|
||||
@@ -158,17 +158,28 @@ private fun ResultsPane(
|
||||
is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}")
|
||||
is SearchResultsState.Loaded -> {
|
||||
if (state.response.isEmpty) {
|
||||
CenteredHint("No matches for that query.")
|
||||
} else {
|
||||
ResultsList(
|
||||
response = state.response,
|
||||
playingTrackId = playingTrackId,
|
||||
onArtistClick = onArtistClick,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onTrackPlay = onTrackPlay,
|
||||
onNavigateToAlbum = onNavigateToAlbum,
|
||||
onNavigateToArtist = onNavigateToArtist,
|
||||
CenteredHint(
|
||||
if (state.localOnly) {
|
||||
"No matches in your on-device library."
|
||||
} else {
|
||||
"No matches for that query."
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
if (state.localOnly) {
|
||||
OfflineResultsHint()
|
||||
}
|
||||
ResultsList(
|
||||
response = state.response,
|
||||
playingTrackId = playingTrackId,
|
||||
onArtistClick = onArtistClick,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onTrackPlay = onTrackPlay,
|
||||
onNavigateToAlbum = onNavigateToAlbum,
|
||||
onNavigateToArtist = onNavigateToArtist,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,6 +319,18 @@ private fun SectionHeader(label: String, count: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OfflineResultsHint() {
|
||||
Text(
|
||||
text = "Showing on-device matches only — the server is unreachable.",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredHint(text: String) {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
|
||||
@@ -26,7 +26,10 @@ sealed interface SearchResultsState {
|
||||
/** Empty query — screen shows "type to search" hint. */
|
||||
data object Idle : SearchResultsState
|
||||
data object Loading : SearchResultsState
|
||||
data class Loaded(val response: SearchResponseRef) : SearchResultsState
|
||||
data class Loaded(
|
||||
val response: SearchResponseRef,
|
||||
val localOnly: Boolean = false,
|
||||
) : SearchResultsState
|
||||
data class Error(val message: String) : SearchResultsState
|
||||
}
|
||||
|
||||
@@ -98,8 +101,15 @@ class SearchViewModel @Inject constructor(
|
||||
private suspend fun runSearch(q: String) {
|
||||
internal.update { it.copy(results = SearchResultsState.Loading) }
|
||||
try {
|
||||
val response = repository.search(q)
|
||||
internal.update { it.copy(results = SearchResultsState.Loaded(response)) }
|
||||
val outcome = repository.search(q)
|
||||
internal.update {
|
||||
it.copy(
|
||||
results = SearchResultsState.Loaded(
|
||||
response = outcome.response,
|
||||
localOnly = outcome.localOnly,
|
||||
),
|
||||
)
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
|
||||
+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.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Wraps [content] in a Material3 PullToRefreshBox so the user can
|
||||
* swipe-down to trigger [onRefresh]. The wrapper manages the
|
||||
* `isRefreshing` indicator while [onRefresh] is in flight.
|
||||
* `isRefreshing` indicator while [onRefresh] is in flight. Every pull also
|
||||
* fires a /healthz recheck (via [PullRefreshNetworkViewModel]) so a stale
|
||||
* connection banner clears promptly on a deliberate user refresh.
|
||||
*
|
||||
* [onRefresh] is suspend: pass `{ viewModel.refresh().join() }` so the
|
||||
* indicator hides exactly when the underlying coroutine completes,
|
||||
@@ -32,6 +35,7 @@ import kotlinx.coroutines.launch
|
||||
fun PullToRefreshScaffold(
|
||||
onRefresh: suspend () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
netVm: PullRefreshNetworkViewModel = hiltViewModel(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
@@ -42,6 +46,7 @@ fun PullToRefreshScaffold(
|
||||
scope.launch {
|
||||
isRefreshing = true
|
||||
try {
|
||||
netVm.recheck() // deliberate pull → re-probe the server now
|
||||
onRefresh()
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.fabledsword.minstrel.cache.mutations.OfflineWriteHintViewModel
|
||||
import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner
|
||||
import com.fabledsword.minstrel.player.ui.MiniPlayer
|
||||
import com.fabledsword.minstrel.player.ui.PlaybackErrorViewModel
|
||||
@@ -48,6 +49,7 @@ fun ShellScaffold(
|
||||
modifier: Modifier = Modifier,
|
||||
trackActionsViewModel: TrackActionsViewModel = hiltViewModel(),
|
||||
playbackErrorViewModel: PlaybackErrorViewModel = hiltViewModel(),
|
||||
offlineWriteHintViewModel: OfflineWriteHintViewModel = hiltViewModel(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
@@ -61,6 +63,11 @@ fun ShellScaffold(
|
||||
snackbarHostState.showSnackbar(msg)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
offlineWriteHintViewModel.messages.collect { msg ->
|
||||
snackbarHostState.showSnackbar(msg)
|
||||
}
|
||||
}
|
||||
// Consume the status-bar inset once here so the banner stack sits
|
||||
// below the status bar (mirrors Flutter's SafeArea(bottom:false)).
|
||||
// statusBarsPadding consumes the inset for descendants, so the in-
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.fabledsword.minstrel.shared.widgets
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -12,8 +13,14 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.minstrel.connectivity.LocalServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
|
||||
private const val OFFLINE_UNAVAILABLE_ALPHA = 0.4f
|
||||
private const val OFFLINE_TAP_MESSAGE = "Not downloaded — connect to play"
|
||||
|
||||
/**
|
||||
* Shared track-list row. Replaces the 5 per-screen `TrackRow`s — every
|
||||
@@ -30,6 +37,14 @@ import androidx.compose.ui.unit.dp
|
||||
* for applying its own alpha if it should match (the row doesn't
|
||||
* cascade because the trailing slot's content is the caller's, not
|
||||
* ours).
|
||||
*
|
||||
* Reads [LocalServerHealth] + [LocalCachedTrackIds] and intercepts taps
|
||||
* on tracks that aren't downloaded when the server is unreachable —
|
||||
* fires a Toast instead of attempting playback. The text dims so the
|
||||
* user can see at a glance which rows in a long list will work offline.
|
||||
* The trailing slot stays interactive so the kebab / like / playlist-
|
||||
* add affordances can still queue mutations for offline replay (Phase
|
||||
* 5 of #618 gates those at the action level).
|
||||
*/
|
||||
@Composable
|
||||
fun TrackRow(
|
||||
@@ -45,15 +60,31 @@ fun TrackRow(
|
||||
leading: @Composable () -> Unit = {},
|
||||
trailing: @Composable RowScope.() -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val health = LocalServerHealth.current
|
||||
// Unstable is non-gating — only a real gating state dims uncached rows.
|
||||
val offlineUnavailable =
|
||||
(health == ServerHealth.Offline || health == ServerHealth.ServerDown) &&
|
||||
trackId !in LocalCachedTrackIds.current
|
||||
val titleColor = if (nowPlaying) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
val effectiveAlpha = if (offlineUnavailable) {
|
||||
minOf(contentAlpha, OFFLINE_UNAVAILABLE_ALPHA)
|
||||
} else {
|
||||
contentAlpha
|
||||
}
|
||||
val effectiveOnClick: () -> Unit = if (offlineUnavailable) {
|
||||
{ Toast.makeText(context, OFFLINE_TAP_MESSAGE, Toast.LENGTH_SHORT).show() }
|
||||
} else {
|
||||
onClick
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.clickable(enabled = enabled, onClick = effectiveOnClick)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = horizontalArrangement,
|
||||
@@ -63,7 +94,7 @@ fun TrackRow(
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = titleColor.copy(alpha = contentAlpha),
|
||||
color = titleColor.copy(alpha = effectiveAlpha),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
@@ -71,7 +102,7 @@ fun TrackRow(
|
||||
Text(
|
||||
text = artist,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = contentAlpha),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = effectiveAlpha),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
@@ -7,8 +7,8 @@ import retrofit2.http.GET
|
||||
/**
|
||||
* Retrofit interface for `GET /healthz` — unauthenticated health probe
|
||||
* returning the server's running version + the minimum client version
|
||||
* it'll talk to. Used by [com.fabledsword.minstrel.update.data.VersionCheckController]
|
||||
* to surface the VersionTooOld banner.
|
||||
* it'll talk to. Polled by [com.fabledsword.minstrel.connectivity.NetworkStatusController]
|
||||
* for both reachability and the VersionTooOld banner.
|
||||
*/
|
||||
interface HealthzApi {
|
||||
@GET("healthz")
|
||||
|
||||
-67
@@ -1,67 +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 javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val POLL_INTERVAL_MS = 5 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* 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()
|
||||
|
||||
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 response = runCatching { api.check() }.getOrNull() ?: return
|
||||
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.ViewModel
|
||||
import com.fabledsword.minstrel.update.data.VersionCheckController
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.update.data.VersionResult
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Tiny VM that exposes the [VersionCheckController]'s current
|
||||
* Tiny VM that exposes the [NetworkStatusController]'s current
|
||||
* [VersionResult] plus its recheck trigger for the banner button.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class VersionTooOldViewModel @Inject constructor(
|
||||
private val controller: VersionCheckController,
|
||||
private val controller: NetworkStatusController,
|
||||
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
val result: StateFlow<VersionResult> = controller.result
|
||||
val result: StateFlow<VersionResult> = controller.versionResult
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class RemotePlayerStateTest {
|
||||
|
||||
@Test
|
||||
fun `starts in idle state`() {
|
||||
val state = RemotePlayerState()
|
||||
assertFalse(state.isPlaying)
|
||||
assertEquals(0L, state.positionMs)
|
||||
assertEquals(0L, state.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyPositionInfo updates position, duration, and trackNumber`() {
|
||||
val state = RemotePlayerState()
|
||||
state.applyPositionInfo(
|
||||
positionMs = 65_000L, durationMs = 210_000L, trackUri = "x", trackNumber = 3,
|
||||
)
|
||||
assertEquals(65_000L, state.positionMs)
|
||||
assertEquals(210_000L, state.durationMs)
|
||||
assertEquals("x", state.currentTrackUri)
|
||||
assertEquals(3, state.trackNumber)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyTransportPlaying flips isPlaying true`() {
|
||||
val state = RemotePlayerState()
|
||||
state.applyTransportPlaying()
|
||||
assertTrue(state.isPlaying)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyTransportPaused flips isPlaying false`() {
|
||||
val state = RemotePlayerState().apply { applyTransportPlaying() }
|
||||
state.applyTransportPaused()
|
||||
assertFalse(state.isPlaying)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyError resets to idle and records error`() {
|
||||
val state = RemotePlayerState().apply { applyTransportPlaying() }
|
||||
val ex = RuntimeException("disconnected")
|
||||
state.applyError(ex)
|
||||
assertFalse(state.isPlaying)
|
||||
assertEquals(ex, state.lastError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recordPollFailure trips after threshold`() {
|
||||
val state = RemotePlayerState()
|
||||
// Threshold is 30 -- tolerate ~30s of screen-off WiFi sleep before
|
||||
// declaring the remote dropped. Pre-threshold calls all return false.
|
||||
repeat(DROP_THRESHOLD - 1) {
|
||||
assertFalse(state.recordPollFailure())
|
||||
}
|
||||
assertTrue(state.recordPollFailure())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recordPollSuccess clears the failure counter`() {
|
||||
val state = RemotePlayerState()
|
||||
repeat(DROP_THRESHOLD - 1) { state.recordPollFailure() }
|
||||
state.recordPollSuccess()
|
||||
assertFalse(state.recordPollFailure())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `play intent survives a SOAP error so a failed play can resume locally`() {
|
||||
val state = RemotePlayerState()
|
||||
state.setPlayIntent(true)
|
||||
state.applyError(RuntimeException("connect timeout"))
|
||||
// isPlaying is cleared by the error, but the operator's intent persists.
|
||||
assertFalse(state.isPlaying)
|
||||
assertTrue(state.lastPlayIntent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pausing clears play intent`() {
|
||||
val state = RemotePlayerState()
|
||||
state.setPlayIntent(true)
|
||||
state.setPlayIntent(false)
|
||||
assertFalse(state.lastPlayIntent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset clears play intent`() {
|
||||
val state = RemotePlayerState()
|
||||
state.setPlayIntent(true)
|
||||
state.reset()
|
||||
assertFalse(state.lastPlayIntent)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DROP_THRESHOLD = 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.IOException
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
/**
|
||||
* Locks in the transport-command retry semantics behind the locked-phone
|
||||
* Sonos drop fix: a transient IO stall (WiFi power-save) must be retried, a
|
||||
* SOAP fault from a responding renderer must NOT be retried, and an
|
||||
* exhausted retry must rethrow rather than loop forever. Without this, a
|
||||
* single failed command falsely reverted Sonos playback to the phone.
|
||||
*/
|
||||
class RetryTransientIoTest {
|
||||
|
||||
@Test
|
||||
fun `returns immediately on first success`() = runTest {
|
||||
var calls = 0
|
||||
val result = retryTransientIo(attempts = 3, backoffMs = 0L) {
|
||||
calls += 1
|
||||
"ok"
|
||||
}
|
||||
assertEquals("ok", result)
|
||||
assertEquals(1, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retries transient IO failure then succeeds`() = runTest {
|
||||
var calls = 0
|
||||
val result = retryTransientIo(attempts = 3, backoffMs = 0L) {
|
||||
calls += 1
|
||||
if (calls < 2) throw IOException("connect timeout")
|
||||
"ok"
|
||||
}
|
||||
assertEquals("ok", result)
|
||||
assertEquals(2, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rethrows IO failure after exhausting attempts`() = runTest {
|
||||
var calls = 0
|
||||
assertFailsWith<IOException> {
|
||||
retryTransientIo(attempts = 3, backoffMs = 0L) {
|
||||
calls += 1
|
||||
throw IOException("still asleep")
|
||||
}
|
||||
}
|
||||
assertEquals(3, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not retry a SOAP fault from a responding renderer`() = runTest {
|
||||
var calls = 0
|
||||
assertFailsWith<SoapFaultException> {
|
||||
retryTransientIo(attempts = 3, backoffMs = 0L) {
|
||||
calls += 1
|
||||
throw SoapFaultException("718", "Invalid InstanceID")
|
||||
}
|
||||
}
|
||||
assertEquals(1, calls)
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class IdleRevertDecisionTest {
|
||||
|
||||
@Test
|
||||
fun `engaged and paused and not armed arms the timer`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.ARM,
|
||||
idleRevertAction(upnpEngaged = true, isPlaying = false, armed = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `engaged and paused and already armed is ignored`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.IGNORE,
|
||||
idleRevertAction(upnpEngaged = true, isPlaying = false, armed = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resuming playback cancels an armed timer`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.CANCEL,
|
||||
idleRevertAction(upnpEngaged = true, isPlaying = true, armed = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `playing with no timer armed is ignored`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.IGNORE,
|
||||
idleRevertAction(upnpEngaged = true, isPlaying = true, armed = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disengaging UPnP cancels an armed timer`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.CANCEL,
|
||||
idleRevertAction(upnpEngaged = false, isPlaying = false, armed = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disengaged with no timer armed is ignored`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.IGNORE,
|
||||
idleRevertAction(upnpEngaged = false, isPlaying = true, armed = false),
|
||||
)
|
||||
}
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
|
||||
class AVTransportClientTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var client: AVTransportClient
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
val controlUrl = server.url("/MediaRenderer/AVTransport/Control")
|
||||
client = AVTransportClient(SoapClient(OkHttpClient()), controlUrl)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seek sends Target in HH MM SS format`() = runTest {
|
||||
server.enqueue(emptyResponse("Seek"))
|
||||
client.seek(positionMs = 65_000L)
|
||||
val body = server.takeRequest().body.readUtf8()
|
||||
assertTrue(body.contains("<Target>0:01:05</Target>")) { "body was $body" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPositionInfo parses Track, RelTime and TrackDuration`() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"""<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<u:GetPositionInfoResponse
|
||||
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||||
<Track>3</Track>
|
||||
<TrackDuration>0:03:30</TrackDuration>
|
||||
<TrackURI>http://x/y.mp3</TrackURI>
|
||||
<RelTime>0:01:05</RelTime>
|
||||
</u:GetPositionInfoResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>""".trimIndent(),
|
||||
),
|
||||
)
|
||||
val info = client.getPositionInfo()
|
||||
assertEquals(3, info.track)
|
||||
assertEquals(65_000L, info.relTimeMs)
|
||||
assertEquals(210_000L, info.trackDurationMs)
|
||||
assertEquals("http://x/y.mp3", info.trackUri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getTransportInfo maps PAUSED_PLAYBACK to PAUSED`() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"""<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<u:GetTransportInfoResponse
|
||||
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||||
<CurrentTransportState>PAUSED_PLAYBACK</CurrentTransportState>
|
||||
<CurrentTransportStatus>OK</CurrentTransportStatus>
|
||||
<CurrentSpeed>1</CurrentSpeed>
|
||||
</u:GetTransportInfoResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>""".trimIndent(),
|
||||
),
|
||||
)
|
||||
val info = client.getTransportInfo()
|
||||
assertEquals(TransportState.PAUSED, info.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getTransportInfo maps unknown state to UNKNOWN`() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"""<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<u:GetTransportInfoResponse
|
||||
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||||
<CurrentTransportState>BUFFERING_PLAYBACK</CurrentTransportState>
|
||||
<CurrentTransportStatus>OK</CurrentTransportStatus>
|
||||
<CurrentSpeed>1</CurrentSpeed>
|
||||
</u:GetTransportInfoResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>""".trimIndent(),
|
||||
),
|
||||
)
|
||||
val info = client.getTransportInfo()
|
||||
assertEquals(TransportState.UNKNOWN, info.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeAllTracksFromQueue sends correct SOAP action`() = runTest {
|
||||
server.enqueue(emptyResponse("RemoveAllTracksFromQueue"))
|
||||
client.removeAllTracksFromQueue()
|
||||
val request = server.takeRequest()
|
||||
assertTrue(request.getHeader("SOAPACTION").orEmpty().contains("RemoveAllTracksFromQueue")) {
|
||||
"SOAPACTION header missing action: ${request.getHeader("SOAPACTION")}"
|
||||
}
|
||||
val body = request.body.readUtf8()
|
||||
assertTrue(body.contains("RemoveAllTracksFromQueue")) { "body: $body" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addURIToQueue sends EnqueuedURI and DIDL-Lite metadata`() = runTest {
|
||||
server.enqueue(emptyResponse("AddURIToQueue"))
|
||||
client.addURIToQueue(
|
||||
uri = "http://x/y.mp3",
|
||||
mime = "audio/mpeg",
|
||||
title = "Song",
|
||||
enqueuedURIPosition = 2,
|
||||
)
|
||||
val body = server.takeRequest().body.readUtf8()
|
||||
assertTrue(body.contains("<EnqueuedURI>http://x/y.mp3</EnqueuedURI>")) {
|
||||
"body missing EnqueuedURI: $body"
|
||||
}
|
||||
val positionTag = "<DesiredFirstTrackNumberEnqueued>2</DesiredFirstTrackNumberEnqueued>"
|
||||
assertTrue(body.contains(positionTag)) { "body missing position: $body" }
|
||||
assertTrue(body.contains("<dc:title>Song</dc:title>")) {
|
||||
"title missing in DIDL: $body"
|
||||
}
|
||||
assertTrue(body.contains("audio/mpeg")) { "mime missing: $body" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next sends Next SOAP action`() = runTest {
|
||||
server.enqueue(emptyResponse("Next"))
|
||||
client.next()
|
||||
val request = server.takeRequest()
|
||||
assertTrue(request.getHeader("SOAPACTION").orEmpty().endsWith("#Next\"")) {
|
||||
"SOAPACTION: ${request.getHeader("SOAPACTION")}"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `previous sends Previous SOAP action`() = runTest {
|
||||
server.enqueue(emptyResponse("Previous"))
|
||||
client.previous()
|
||||
val request = server.takeRequest()
|
||||
assertTrue(request.getHeader("SOAPACTION").orEmpty().endsWith("#Previous\"")) {
|
||||
"SOAPACTION: ${request.getHeader("SOAPACTION")}"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seekToTrack sends TRACK_NR unit with 1-based target`() = runTest {
|
||||
server.enqueue(emptyResponse("Seek"))
|
||||
client.seekToTrack(trackNumber = 4)
|
||||
val body = server.takeRequest().body.readUtf8()
|
||||
assertTrue(body.contains("<Unit>TRACK_NR</Unit>")) { "body: $body" }
|
||||
assertTrue(body.contains("<Target>4</Target>")) { "body: $body" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setAVTransportURIWithMetadata sends plain https URI for music track`() = runTest {
|
||||
server.enqueue(emptyResponse("SetAVTransportURI"))
|
||||
client.setAVTransportURIWithMetadata(
|
||||
uri = "https://example.com/track.mp3",
|
||||
mime = "audio/mpeg",
|
||||
title = "Song",
|
||||
)
|
||||
val body = server.takeRequest().body.readUtf8()
|
||||
assertTrue(body.contains("<CurrentURI>https://example.com/track.mp3</CurrentURI>")) {
|
||||
"CurrentURI should be sent as plain https: $body"
|
||||
}
|
||||
assertFalse(body.contains("x-rincon-mp3radio")) {
|
||||
"x-rincon-mp3radio scheme should not appear: $body"
|
||||
}
|
||||
}
|
||||
|
||||
private fun emptyResponse(action: String): MockResponse = MockResponse().setBody(
|
||||
"""<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<u:${action}Response xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"/>
|
||||
</s:Body>
|
||||
</s:Envelope>""".trimIndent(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class BareUdnTest {
|
||||
|
||||
@Test
|
||||
fun `strips uuid prefix`() {
|
||||
assertEquals("RINCON_ABC", "uuid:RINCON_ABC".bareUdn())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `strips MR suffix`() {
|
||||
assertEquals("RINCON_ABC", "RINCON_ABC_MR".bareUdn())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `strips MS suffix`() {
|
||||
assertEquals("RINCON_ABC", "RINCON_ABC_MS".bareUdn())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `strips both uuid prefix and MR suffix`() {
|
||||
assertEquals("RINCON_ABC", "uuid:RINCON_ABC_MR".bareUdn())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `strips both uuid prefix and MS suffix`() {
|
||||
assertEquals("RINCON_ABC", "uuid:RINCON_ABC_MS".bareUdn())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leaves already bare UDN unchanged`() {
|
||||
assertEquals("RINCON_ABC", "RINCON_ABC".bareUdn())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MR suffix comparison crosses MediaRenderer vs ZGT response`() {
|
||||
val routeId = "uuid:RINCON_5CAAFD794B6401400_MR"
|
||||
val zgtMemberUdn = "RINCON_5CAAFD794B6401400"
|
||||
assertEquals(routeId.bareUdn(), zgtMemberUdn.bareUdn())
|
||||
}
|
||||
}
|
||||
+32
@@ -58,6 +58,7 @@ class DeviceDescriptionTest {
|
||||
"http://192.168.1.50:1400/MediaRenderer/RenderingControl/Control",
|
||||
desc.renderingControlUrl?.toString(),
|
||||
)
|
||||
assertNull(desc.zoneGroupTopologyControlUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -81,6 +82,37 @@ class DeviceDescriptionTest {
|
||||
assertNull(DeviceDescription.parse(xml, base))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses ZoneGroupTopology control URL when present`() {
|
||||
val xml = """
|
||||
<?xml version="1.0"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<device>
|
||||
<UDN>uuid:RINCON_XYZ</UDN>
|
||||
<friendlyName>Office</friendlyName>
|
||||
<manufacturer>Sonos, Inc.</manufacturer>
|
||||
<modelName>Sonos One</modelName>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
|
||||
<controlURL>/MediaRenderer/AVTransport/Control</controlURL>
|
||||
</service>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:ZoneGroupTopology:1</serviceType>
|
||||
<controlURL>/ZoneGroupTopology/Control</controlURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</root>
|
||||
""".trimIndent()
|
||||
val desc = DeviceDescription.parse(xml, base)
|
||||
assertNotNull(desc)
|
||||
assertEquals(
|
||||
"http://192.168.1.50:1400/ZoneGroupTopology/Control",
|
||||
desc.zoneGroupTopologyControlUrl?.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles missing optional fields`() {
|
||||
val xml = """
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
|
||||
class RenderingControlClientTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var client: RenderingControlClient
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
client = RenderingControlClient(SoapClient(OkHttpClient()), server.url("/RC"))
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() { server.shutdown() }
|
||||
|
||||
@Test
|
||||
fun `getVolume parses CurrentVolume`() = runTest {
|
||||
server.enqueue(MockResponse().setBody(
|
||||
"""<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<u:GetVolumeResponse xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1">
|
||||
<CurrentVolume>42</CurrentVolume>
|
||||
</u:GetVolumeResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>""".trimIndent()))
|
||||
assertEquals(42, client.getVolume())
|
||||
val request = server.takeRequest()
|
||||
val body = request.body.readUtf8()
|
||||
assertTrue(body.contains("<Channel>Master</Channel>")) { body }
|
||||
assertEquals(
|
||||
"\"urn:schemas-upnp-org:service:RenderingControl:1#GetVolume\"",
|
||||
request.getHeader("SOAPACTION"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setVolume clamps and sends DesiredVolume`() = runTest {
|
||||
server.enqueue(MockResponse().setBody(
|
||||
"""<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<u:SetVolumeResponse
|
||||
xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1"/>
|
||||
</s:Body>
|
||||
</s:Envelope>""".trimIndent()))
|
||||
client.setVolume(150)
|
||||
val body = server.takeRequest().body.readUtf8()
|
||||
assertTrue(body.contains("<DesiredVolume>100</DesiredVolume>")) { body }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setVolume clamps below VOLUME_MIN to zero`() = runTest {
|
||||
server.enqueue(MockResponse().setBody(
|
||||
"""<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<u:SetVolumeResponse
|
||||
xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1"/>
|
||||
</s:Body>
|
||||
</s:Envelope>""".trimIndent()))
|
||||
client.setVolume(-5)
|
||||
val body = server.takeRequest().body.readUtf8()
|
||||
assertTrue(body.contains("<DesiredVolume>0</DesiredVolume>")) { body }
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp.sonos
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
class SonosTopologyTest {
|
||||
|
||||
@Test
|
||||
fun `single zone group with one member`() {
|
||||
val xml = """
|
||||
<ZoneGroupState>
|
||||
<ZoneGroups>
|
||||
<ZoneGroup Coordinator="RINCON_A" ID="RINCON_A:1">
|
||||
<ZoneGroupMember UUID="RINCON_A" ZoneName="Kitchen"
|
||||
Location="http://192.168.1.10:1400/xml/device_description.xml"/>
|
||||
</ZoneGroup>
|
||||
</ZoneGroups>
|
||||
</ZoneGroupState>
|
||||
""".trimIndent()
|
||||
val groups = SonosTopology.parse(xml)
|
||||
assertEquals(1, groups.size)
|
||||
val g = groups.first()
|
||||
assertEquals("RINCON_A", g.coordinatorUdn)
|
||||
assertEquals("Kitchen", g.name)
|
||||
assertEquals(1, g.members.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stereo pair collapses to one group with two members`() {
|
||||
val xml = """
|
||||
<ZoneGroupState>
|
||||
<ZoneGroups>
|
||||
<ZoneGroup Coordinator="RINCON_L" ID="RINCON_L:2">
|
||||
<ZoneGroupMember UUID="RINCON_L" ZoneName="Living Room"
|
||||
Location="http://192.168.1.11:1400/xml/device_description.xml"
|
||||
ChannelMapSet="RINCON_L:LF,LF;RINCON_R:RF,RF"/>
|
||||
<ZoneGroupMember UUID="RINCON_R" ZoneName="Living Room"
|
||||
Location="http://192.168.1.12:1400/xml/device_description.xml"
|
||||
ChannelMapSet="RINCON_L:LF,LF;RINCON_R:RF,RF"/>
|
||||
</ZoneGroup>
|
||||
</ZoneGroups>
|
||||
</ZoneGroupState>
|
||||
""".trimIndent()
|
||||
val groups = SonosTopology.parse(xml)
|
||||
assertEquals(1, groups.size)
|
||||
val g = groups.first()
|
||||
assertEquals("RINCON_L", g.coordinatorUdn)
|
||||
assertEquals("Living Room", g.name)
|
||||
assertEquals(2, g.members.size)
|
||||
assertNotNull(g.members[0].channelMapSet)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multi-speaker group lists coordinator name`() {
|
||||
val xml = """
|
||||
<ZoneGroupState>
|
||||
<ZoneGroups>
|
||||
<ZoneGroup Coordinator="RINCON_X" ID="RINCON_X:3">
|
||||
<ZoneGroupMember UUID="RINCON_X" ZoneName="Office"/>
|
||||
<ZoneGroupMember UUID="RINCON_Y" ZoneName="Bedroom"/>
|
||||
</ZoneGroup>
|
||||
</ZoneGroups>
|
||||
</ZoneGroupState>
|
||||
""".trimIndent()
|
||||
val groups = SonosTopology.parse(xml)
|
||||
assertEquals("Office", groups.first().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed xml returns empty list`() {
|
||||
assertEquals(emptyList(), SonosTopology.parse("<garbage"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses inline non-escaped ZoneGroupState document`() {
|
||||
// Some Sonos firmware sends the topology as nested elements with
|
||||
// no escaping. After SoapClient.readUntilEndTag rebuilds a flat
|
||||
// string, parse should still find the groups.
|
||||
val xml = """
|
||||
<ZoneGroupState>
|
||||
<ZoneGroups>
|
||||
<ZoneGroup Coordinator="RINCON_A" ID="RINCON_A:1">
|
||||
<ZoneGroupMember UUID="RINCON_A" ZoneName="Kitchen"
|
||||
Location="http://192.168.1.10:1400/xml/device_description.xml"/>
|
||||
</ZoneGroup>
|
||||
</ZoneGroups>
|
||||
</ZoneGroupState>
|
||||
""".trimIndent()
|
||||
val groups = SonosTopology.parse(xml)
|
||||
assertEquals(1, groups.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses escaped ZoneGroupState wrapped in entities`() {
|
||||
val xml = """
|
||||
<ZoneGroupState>
|
||||
<ZoneGroups>
|
||||
<ZoneGroup Coordinator="RINCON_A" ID="RINCON_A:1">
|
||||
<ZoneGroupMember UUID="RINCON_A" ZoneName="Kitchen"
|
||||
Location="http://192.168.1.10:1400/xml/device_description.xml"/>
|
||||
</ZoneGroup>
|
||||
</ZoneGroups>
|
||||
</ZoneGroupState>
|
||||
""".trimIndent()
|
||||
val groups = SonosTopology.parse(xml)
|
||||
assertEquals(1, groups.size)
|
||||
}
|
||||
}
|
||||
+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)
|
||||
}
|
||||
}
|
||||
+48
-4
@@ -5,12 +5,15 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
@@ -53,6 +56,35 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// safetyNetScanInterval backstops the fsnotify watcher with a low-frequency
|
||||
// full delta walk, catching anything the watcher missed (inotify watch-limit
|
||||
// exhaustion, dropped events). Fixed — there is no operator configuration.
|
||||
const safetyNetScanInterval = 12 * time.Hour
|
||||
|
||||
// runSafetyNetScans ticks a delta RunScan at safetyNetScanInterval until ctx
|
||||
// is cancelled, deferring to TryStartScan's in-flight guard so it never
|
||||
// collides with a manual, startup, or watcher-driven scan.
|
||||
func runSafetyNetScans(ctx context.Context, pool *pgxpool.Pool, scanner *library.Scanner,
|
||||
enricher *coverart.Enricher, logger *slog.Logger, scanCfg library.RunScanConfig,
|
||||
) {
|
||||
ticker := time.NewTicker(safetyNetScanInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
started, existing, err := library.TryStartScan(ctx, pool, scanner, enricher, logger, scanCfg)
|
||||
if err != nil {
|
||||
logger.Error("safety-net scan: try start failed", "err", err)
|
||||
} else if !started && existing != nil {
|
||||
logger.Info("safety-net scan skipped — prior still in flight",
|
||||
"in_flight_id", existing.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
configPath := flag.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
|
||||
flag.Parse()
|
||||
@@ -237,12 +269,24 @@ func run() error {
|
||||
ArtistEnrichCap: -1,
|
||||
DataDir: cfg.Storage.DataDir,
|
||||
}
|
||||
scheduler := library.NewScheduler(pool, logger.With("component", "scheduler"),
|
||||
scanner, coverEnricher, scanCfg)
|
||||
scheduler.Start(ctx)
|
||||
// Filesystem watcher: near-instant pickup of new/changed files via
|
||||
// fsnotify, scanning just the affected paths + enriching their albums
|
||||
// inline. Replaces the removed configurable scan scheduler.
|
||||
watcher := library.NewWatcher(scanner, coverEnricher,
|
||||
logger.With("component", "watcher"), cfg.Library.ScanPaths)
|
||||
go func() {
|
||||
if werr := watcher.Run(ctx); werr != nil {
|
||||
logger.Warn("library watcher exited", "err", werr)
|
||||
}
|
||||
}()
|
||||
// Safety-net delta walk backstops the watcher (inotify limits / dropped
|
||||
// events) at a fixed low frequency. No operator config.
|
||||
go runSafetyNetScans(ctx, pool, scanner, coverEnricher,
|
||||
logger.With("component", "scan_safetynet"), scanCfg)
|
||||
|
||||
srv := server.New(logger, pool, scanner, subsonic.Config{
|
||||
AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword,
|
||||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg, scheduler)
|
||||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg)
|
||||
srv.Bus = bus
|
||||
srv.PlaylistScheduler = playlistScheduler
|
||||
srv.StreamSecret = cfg.StreamSecret
|
||||
|
||||
@@ -4,6 +4,7 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-co-op/gocron/v2 v2.21.2
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
@@ -25,5 +26,6 @@ require (
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
)
|
||||
|
||||
@@ -23,6 +23,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-co-op/gocron/v2 v2.21.2 h1:bD8/YwkojYHgXFr3iEulL148KBdTbKVxUZzFKpXcdbY=
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
)
|
||||
|
||||
type scanScheduleResp struct {
|
||||
Mode string `json:"mode"`
|
||||
IntervalHours *int `json:"interval_hours"`
|
||||
TimeOfDay *string `json:"time_of_day"`
|
||||
WeeklyDay *int `json:"weekly_day"`
|
||||
NextScheduledAt *string `json:"next_scheduled_at"`
|
||||
}
|
||||
|
||||
type scanScheduleReq struct {
|
||||
Mode string `json:"mode"`
|
||||
IntervalHours *int `json:"interval_hours"`
|
||||
TimeOfDay *string `json:"time_of_day"`
|
||||
WeeklyDay *int `json:"weekly_day"`
|
||||
}
|
||||
|
||||
func (h *handlers) handleGetScanSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
q := dbq.New(h.pool)
|
||||
row, err := q.GetScanSchedule(r.Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
h.logger.Error("admin: scan_schedule singleton row missing")
|
||||
writeErr(w, apierror.InternalMsg("schedule row missing", errors.New("schedule row missing")))
|
||||
return
|
||||
}
|
||||
writeErrWithLog(w, h.logger, "admin: get scan schedule", apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, scanScheduleRespFromRow(row, h.scheduler.NextFire()))
|
||||
}
|
||||
|
||||
func (h *handlers) handlePatchScanSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
var req scanScheduleReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateScheduleReq(&req); err != nil {
|
||||
writeErr(w, apierror.BadRequest("validation", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
q := dbq.New(h.pool)
|
||||
if err := q.UpdateScanSchedule(r.Context(), dbq.UpdateScanScheduleParams{
|
||||
Mode: req.Mode,
|
||||
IntervalHours: pgInt32Ptr(req.IntervalHours),
|
||||
TimeOfDay: req.TimeOfDay,
|
||||
WeeklyDay: pgInt32Ptr(req.WeeklyDay),
|
||||
}); err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin: update scan schedule", apierror.InternalMsg("update failed", err))
|
||||
return
|
||||
}
|
||||
|
||||
h.scheduler.Refresh()
|
||||
|
||||
row, err := q.GetScanSchedule(r.Context())
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin: re-read scan schedule", apierror.InternalMsg("re-read failed", err))
|
||||
return
|
||||
}
|
||||
cfg := library.ScheduleConfig{Mode: row.Mode}
|
||||
if row.IntervalHours != nil {
|
||||
cfg.IntervalHours = int(*row.IntervalHours)
|
||||
}
|
||||
if row.TimeOfDay != nil {
|
||||
cfg.TimeOfDay = *row.TimeOfDay
|
||||
}
|
||||
if row.WeeklyDay != nil {
|
||||
cfg.WeeklyDay = int(*row.WeeklyDay)
|
||||
}
|
||||
next := cfg.NextFire(time.Now())
|
||||
|
||||
writeJSON(w, http.StatusOK, scanScheduleRespFromRow(row, next))
|
||||
}
|
||||
|
||||
func validateScheduleReq(req *scanScheduleReq) error {
|
||||
switch req.Mode {
|
||||
case "off":
|
||||
req.IntervalHours = nil
|
||||
req.TimeOfDay = nil
|
||||
req.WeeklyDay = nil
|
||||
return nil
|
||||
case "interval":
|
||||
if req.IntervalHours == nil || *req.IntervalHours <= 0 {
|
||||
return fmt.Errorf("interval_hours required and > 0 when mode=interval")
|
||||
}
|
||||
req.TimeOfDay = nil
|
||||
req.WeeklyDay = nil
|
||||
return nil
|
||||
case "daily":
|
||||
if req.TimeOfDay == nil || *req.TimeOfDay == "" {
|
||||
return fmt.Errorf("time_of_day required when mode=daily")
|
||||
}
|
||||
req.IntervalHours = nil
|
||||
req.WeeklyDay = nil
|
||||
return nil
|
||||
case "weekly":
|
||||
if req.TimeOfDay == nil || *req.TimeOfDay == "" {
|
||||
return fmt.Errorf("time_of_day required when mode=weekly")
|
||||
}
|
||||
if req.WeeklyDay == nil || *req.WeeklyDay < 1 || *req.WeeklyDay > 7 {
|
||||
return fmt.Errorf("weekly_day required and in [1, 7] when mode=weekly")
|
||||
}
|
||||
req.IntervalHours = nil
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid mode %q (want off / interval / daily / weekly)", req.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func scanScheduleRespFromRow(row dbq.GetScanScheduleRow, nextFire time.Time) scanScheduleResp {
|
||||
resp := scanScheduleResp{Mode: row.Mode}
|
||||
if row.IntervalHours != nil {
|
||||
v := int(*row.IntervalHours)
|
||||
resp.IntervalHours = &v
|
||||
}
|
||||
if row.TimeOfDay != nil {
|
||||
v := *row.TimeOfDay
|
||||
resp.TimeOfDay = &v
|
||||
}
|
||||
if row.WeeklyDay != nil {
|
||||
v := int(*row.WeeklyDay)
|
||||
resp.WeeklyDay = &v
|
||||
}
|
||||
if !nextFire.IsZero() {
|
||||
s := nextFire.UTC().Format(time.RFC3339)
|
||||
resp.NextScheduledAt = &s
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func pgInt32Ptr(src *int) *int32 {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
v := int32(*src)
|
||||
return &v
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
)
|
||||
|
||||
func newAdminScheduleRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Route("/api/admin", func(admin chi.Router) {
|
||||
admin.Use(auth.RequireAdmin())
|
||||
admin.Get("/scan/schedule", h.handleGetScanSchedule)
|
||||
admin.Patch("/scan/schedule", h.handlePatchScanSchedule)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAdminScanSchedule_Get_DefaultIsOff(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
||||
h.scheduler = s
|
||||
admin := seedUser(t, pool, "schedget", "pw", true)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/scan/schedule", nil)
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp scanScheduleResp
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Mode != "off" {
|
||||
t.Errorf("Mode = %q, want off", resp.Mode)
|
||||
}
|
||||
if resp.NextScheduledAt != nil {
|
||||
t.Errorf("NextScheduledAt = %v, want nil for mode=off", resp.NextScheduledAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminScanSchedule_Patch_DailyMode(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
||||
h.scheduler = s
|
||||
admin := seedUser(t, pool, "schedpatch", "pw", true)
|
||||
|
||||
body := `{"mode":"daily","time_of_day":"03:00"}`
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
|
||||
bytes.NewReader([]byte(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp scanScheduleResp
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Mode != "daily" {
|
||||
t.Errorf("Mode = %q, want daily", resp.Mode)
|
||||
}
|
||||
if resp.TimeOfDay == nil || *resp.TimeOfDay != "03:00" {
|
||||
t.Errorf("TimeOfDay = %v, want '03:00'", resp.TimeOfDay)
|
||||
}
|
||||
if resp.NextScheduledAt == nil {
|
||||
t.Errorf("NextScheduledAt = nil, want a future ISO timestamp")
|
||||
} else {
|
||||
next, err := time.Parse(time.RFC3339, *resp.NextScheduledAt)
|
||||
if err != nil {
|
||||
t.Errorf("NextScheduledAt parse: %v", err)
|
||||
} else if local := next.Local(); local.Hour() != 3 || local.Minute() != 0 {
|
||||
// Handler computes next-fire from time.Now() in the host zone, so
|
||||
// the local-time hour is what's stable across CI runners regardless
|
||||
// of TZ; the wire timestamp is UTC but parses back to the same
|
||||
// instant either way.
|
||||
t.Errorf("NextScheduledAt local time = %v, want 03:00 in host zone", local)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminScanSchedule_Patch_WeeklyMissingDay_400(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
||||
h.scheduler = s
|
||||
admin := seedUser(t, pool, "schedweekly", "pw", true)
|
||||
|
||||
body := `{"mode":"weekly","time_of_day":"03:00"}`
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
|
||||
bytes.NewReader([]byte(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminScanSchedule_Patch_OffNormalizes(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
||||
h.scheduler = s
|
||||
admin := seedUser(t, pool, "schedoff", "pw", true)
|
||||
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
`UPDATE scan_schedule SET mode='daily', time_of_day='03:00' WHERE id=true`,
|
||||
); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
body := `{"mode":"off","time_of_day":"03:00"}`
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
|
||||
bytes.NewReader([]byte(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var timeOfDay *string
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
`SELECT time_of_day FROM scan_schedule WHERE id=true`,
|
||||
).Scan(&timeOfDay); err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if timeOfDay != nil {
|
||||
t.Errorf("time_of_day = %v, want nil after mode=off normalize", timeOfDay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminScanSchedule_NonAdmin_403(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
||||
h.scheduler = s
|
||||
user := seedUser(t, pool, "schednonadmin", "pw", false)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/scan/schedule", nil)
|
||||
req = withUser(req, user)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
+7
-5
@@ -28,7 +28,7 @@ import (
|
||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||
// RequireUser; everything else is gated by the middleware. The events writer
|
||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
|
||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||
h := &handlers{
|
||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||
@@ -42,7 +42,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
coverSettings: coverSettings,
|
||||
scanner: scanner,
|
||||
scanCfg: scanCfg,
|
||||
scheduler: scheduler,
|
||||
dataDir: dataDir,
|
||||
mailer: sender,
|
||||
eventbus: bus,
|
||||
@@ -65,6 +64,10 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
// design at
|
||||
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
||||
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream", h.handleGetStream)
|
||||
// Extension-bearing alias so Sonos's URL probe can identify the
|
||||
// audio format from the path. The {ext} param is consumed by chi
|
||||
// and ignored by the handler (which keys off {id}). See task #610.
|
||||
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream)
|
||||
|
||||
api.Group(func(authed chi.Router) {
|
||||
authed.Use(auth.RequireUser(pool))
|
||||
@@ -83,6 +86,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Get("/artists", h.handleListArtists)
|
||||
authed.Get("/artists/{id}", h.handleGetArtist)
|
||||
authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks)
|
||||
authed.Get("/artists/{id}/similar", h.handleGetSimilarArtists)
|
||||
authed.Get("/artists/{id}/top-tracks", h.handleGetArtistTopTracks)
|
||||
authed.Get("/albums/{id}", h.handleGetAlbum)
|
||||
authed.Get("/albums/{id}/cover", h.handleGetCover)
|
||||
authed.Get("/library/shuffle", h.handleLibraryShuffle)
|
||||
@@ -163,8 +168,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
|
||||
admin.Get("/scan/status", h.handleGetScanStatus)
|
||||
admin.Post("/scan/run", h.handleTriggerScan)
|
||||
admin.Get("/scan/schedule", h.handleGetScanSchedule)
|
||||
admin.Patch("/scan/schedule", h.handlePatchScanSchedule)
|
||||
|
||||
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
|
||||
|
||||
@@ -218,7 +221,6 @@ type handlers struct {
|
||||
coverSettings *coverart.SettingsService
|
||||
scanner *library.Scanner
|
||||
scanCfg library.RunScanConfig
|
||||
scheduler *library.Scheduler
|
||||
dataDir string
|
||||
mailer mailer.Sender
|
||||
eventbus *eventbus.Bus
|
||||
|
||||
@@ -3,9 +3,11 @@ package api
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -23,6 +25,53 @@ type castTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
Exp int64 `json:"exp"`
|
||||
URL string `json:"url"`
|
||||
// MIME and Title let the client build proper DIDL-Lite metadata for
|
||||
// SetAVTransportURI. Sonos rejects empty DIDL with vendor error 1023;
|
||||
// passing back the track's MIME + title here lets the client populate
|
||||
// `<res protocolInfo>` and `<dc:title>` without a follow-up round trip.
|
||||
MIME string `json:"mime"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// mimeForFormat returns the audio MIME type for a cast (Sonos/UPnP) URL.
|
||||
// Wraps the canonical audioContentType lookup in media.go and overrides
|
||||
// the unknown-format fallback to audio/mpeg, because Sonos rejects
|
||||
// DIDL-Lite with protocolInfo=application/octet-stream (the browser
|
||||
// fallback) -- most Sonos firmware probes the URL anyway and recovers
|
||||
// from a small MIME mismatch.
|
||||
func mimeForFormat(format string) string {
|
||||
mime := audioContentType(format)
|
||||
if mime == "application/octet-stream" {
|
||||
return "audio/mpeg"
|
||||
}
|
||||
return mime
|
||||
}
|
||||
|
||||
// extForFormat maps the tracks.file_format column to a path-safe file
|
||||
// extension. Sonos firmware gates duration probing on the URL path
|
||||
// extension (Content-Type header alone is insufficient) -- without a
|
||||
// recognizable extension, Sonos reports TrackDuration=0 and seeks
|
||||
// trigger auto-advance because every position past 0 looks past-the-
|
||||
// end. Defaults to "mp3" for unknown formats. See task #610.
|
||||
func extForFormat(format string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "mp3", "mpeg":
|
||||
return "mp3"
|
||||
case "flac":
|
||||
return "flac"
|
||||
case "aac":
|
||||
return "aac"
|
||||
case "m4a", "mp4":
|
||||
return "m4a"
|
||||
case "ogg", "vorbis":
|
||||
return "ogg"
|
||||
case "opus":
|
||||
return "opus"
|
||||
case "wav", "wave":
|
||||
return "wav"
|
||||
default:
|
||||
return "mp3"
|
||||
}
|
||||
}
|
||||
|
||||
// handleCastStreamToken issues a short-lived HMAC stream token for the
|
||||
@@ -52,6 +101,14 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request)
|
||||
writeErr(w, apierror.BadRequest("invalid_track_id", "trackId must be a UUID"))
|
||||
return
|
||||
}
|
||||
// Track lookup for the DIDL-Lite metadata the client builds for
|
||||
// SetAVTransportURI. A missing track is a 404 — there's nothing to
|
||||
// cast in that case.
|
||||
track, err := dbq.New(h.pool).GetTrackByID(r.Context(), trackUUID)
|
||||
if err != nil {
|
||||
writeErr(w, apierror.NotFound("track"))
|
||||
return
|
||||
}
|
||||
expSec := clampExpSeconds(req.ExpSeconds)
|
||||
exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix()
|
||||
token := SignStreamToken(h.streamSecret, req.TrackID, exp)
|
||||
@@ -74,10 +131,19 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request)
|
||||
if h := r.Header.Get("X-Forwarded-Host"); h != "" {
|
||||
host = h
|
||||
}
|
||||
url := scheme + "://" + host + "/api/tracks/" + req.TrackID +
|
||||
"/stream?token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
|
||||
// Include the file extension in the path so Sonos's URL probe sees a
|
||||
// recognizable audio file. Without it, Sonos reports TrackDuration=0
|
||||
// and seeks past 0s land "after the end" -> early track-skip.
|
||||
url := scheme + "://" + host + streamURLWithExt(trackUUID, extForFormat(track.FileFormat)) +
|
||||
"?token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
|
||||
|
||||
writeJSON(w, http.StatusOK, castTokenResponse{Token: token, Exp: exp, URL: url})
|
||||
writeJSON(w, http.StatusOK, castTokenResponse{
|
||||
Token: token,
|
||||
Exp: exp,
|
||||
URL: url,
|
||||
MIME: mimeForFormat(track.FileFormat),
|
||||
Title: track.Title,
|
||||
})
|
||||
}
|
||||
|
||||
// clampExpSeconds applies the [60, 86400] window with a 6h default for
|
||||
|
||||
@@ -10,15 +10,21 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const testTrackUUID = "11111111-1111-1111-1111-111111111111"
|
||||
// nonExistentTrackUUID is used by tests that exercise paths which don't
|
||||
// require the track to actually exist (auth/UUID-shape rejection).
|
||||
const nonExistentTrackUUID = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
func TestCastStreamToken_HappyPath(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "alice", "hunter2", false)
|
||||
artist := seedArtist(t, pool, "Artist")
|
||||
album := seedAlbum(t, pool, artist.ID, "Album", 0)
|
||||
track := seedTrack(t, pool, album.ID, artist.ID, "Song", 1, 180_000)
|
||||
trackID := uuidToString(track.ID)
|
||||
h.streamSecret = []byte("cast-token-test-secret")
|
||||
|
||||
body, err := json.Marshal(castTokenRequest{
|
||||
TrackID: testTrackUUID,
|
||||
TrackID: trackID,
|
||||
ExpSeconds: 3600,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -44,10 +50,16 @@ func TestCastStreamToken_HappyPath(t *testing.T) {
|
||||
if !strings.Contains(resp.URL, "token="+resp.Token) {
|
||||
t.Fatalf("URL missing token query: %s", resp.URL)
|
||||
}
|
||||
if !strings.Contains(resp.URL, "/api/tracks/"+testTrackUUID+"/stream") {
|
||||
if !strings.Contains(resp.URL, "/api/tracks/"+trackID+"/stream") {
|
||||
t.Fatalf("URL missing stream path: %s", resp.URL)
|
||||
}
|
||||
if !VerifyStreamToken(h.streamSecret, testTrackUUID, resp.Exp, resp.Token) {
|
||||
// Stream URL must carry a file extension so Sonos's URL probe can
|
||||
// identify the audio format (see task #610). Track seeded above is
|
||||
// .flac via seedTrack's default file_format.
|
||||
if !strings.Contains(resp.URL, "/stream.flac?") {
|
||||
t.Fatalf("URL missing file-extension segment: %s", resp.URL)
|
||||
}
|
||||
if !VerifyStreamToken(h.streamSecret, trackID, resp.Exp, resp.Token) {
|
||||
t.Fatal("returned token does not verify")
|
||||
}
|
||||
}
|
||||
@@ -77,7 +89,7 @@ func TestCastStreamToken_RejectsUnauthenticated(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
h.streamSecret = []byte("cast-token-test-secret")
|
||||
|
||||
body, err := json.Marshal(castTokenRequest{TrackID: testTrackUUID})
|
||||
body, err := json.Marshal(castTokenRequest{TrackID: nonExistentTrackUUID})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
@@ -96,11 +108,15 @@ func TestCastStreamToken_RejectsUnauthenticated(t *testing.T) {
|
||||
func TestCastStreamToken_ClampsExpSeconds(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "alice", "hunter2", false)
|
||||
artist := seedArtist(t, pool, "Artist")
|
||||
album := seedAlbum(t, pool, artist.ID, "Album", 0)
|
||||
track := seedTrack(t, pool, album.ID, artist.ID, "Song", 1, 180_000)
|
||||
trackID := uuidToString(track.ID)
|
||||
h.streamSecret = []byte("cast-token-test-secret")
|
||||
|
||||
// Request 1 second (below min 60), expect clamp to 60s.
|
||||
body, err := json.Marshal(castTokenRequest{
|
||||
TrackID: testTrackUUID,
|
||||
TrackID: trackID,
|
||||
ExpSeconds: 1,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -75,6 +75,15 @@ func streamURL(trackID pgtype.UUID) string {
|
||||
return "/api/tracks/" + uuidToString(trackID) + "/stream"
|
||||
}
|
||||
|
||||
// streamURLWithExt returns the extension-bearing stream URL used by UPnP
|
||||
// cast tokens. Sonos's URL probe gates duration detection on a recognizable
|
||||
// audio file extension; the bare `/stream` shape reports TrackDuration=0
|
||||
// and breaks seek/auto-advance. The bare /stream route stays mounted as an
|
||||
// alias for legacy / web / Subsonic clients. See task #610.
|
||||
func streamURLWithExt(trackID pgtype.UUID, ext string) string {
|
||||
return streamURL(trackID) + "." + ext
|
||||
}
|
||||
|
||||
// artistRefFrom projects a dbq.Artist into an ArtistRef without cover.
|
||||
// albumCount must be pre-computed by the caller. Used by code paths that
|
||||
// don't have a representative-album lookup at hand (artist detail, search,
|
||||
|
||||
@@ -159,6 +159,85 @@ func (h *handlers) handleGetArtistTracks(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// similarArtistsDefaultLimit caps the artist-detail "similar artists" strip;
|
||||
// artistTopTracksDefaultLimit caps the per-user "top tracks" panel.
|
||||
const (
|
||||
similarArtistsDefaultLimit = 12
|
||||
artistTopTracksDefaultLimit = 5
|
||||
)
|
||||
|
||||
// handleGetSimilarArtists implements GET /api/artists/{id}/similar. Returns
|
||||
// in-library artists similar to {id} (ranked by similarity score) as a flat
|
||||
// ArtistRef list with cover + album count. Empty when the similarity ingest
|
||||
// has no matches yet; 404 when the artist doesn't exist.
|
||||
func (h *handlers) handleGetSimilarArtists(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := requireURLUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
if _, err := q.GetArtistByID(r.Context(), id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, apierror.NotFound("artist"))
|
||||
return
|
||||
}
|
||||
h.logger.Error("api: get artist for similar", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
rows, err := q.ListSimilarArtistsForArtist(r.Context(), dbq.ListSimilarArtistsForArtistParams{
|
||||
SeedArtistID: id, ResultLimit: similarArtistsDefaultLimit,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("api: list similar artists", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
out := make([]ArtistRef, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleGetArtistTopTracks implements GET /api/artists/{id}/top-tracks. Returns
|
||||
// the current user's most-played tracks for {id} (skips excluded, quarantine
|
||||
// filtered). Empty when the user hasn't played this artist; 404 when the
|
||||
// artist doesn't exist.
|
||||
func (h *handlers) handleGetArtistTopTracks(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := requireURLUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
if _, err := q.GetArtistByID(r.Context(), id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, apierror.NotFound("artist"))
|
||||
return
|
||||
}
|
||||
h.logger.Error("api: get artist for top tracks", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
rows, err := q.ListMostPlayedTracksForArtist(r.Context(), dbq.ListMostPlayedTracksForArtistParams{
|
||||
ArtistID: id, UserID: user.ID, ResultLimit: artistTopTracksDefaultLimit,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("api: list artist top tracks", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
out := make([]TrackRef, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleLibraryShuffle implements GET /api/library/shuffle?limit=N —
|
||||
// the online source for the client's always-present "Shuffle all"
|
||||
// (#427 S4). N random tracks across the whole library, per-user
|
||||
|
||||
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
30*time.Minute, 0.5, 30000)
|
||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil, nil)
|
||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil)
|
||||
|
||||
paths := []string{
|
||||
"/api/artists",
|
||||
|
||||
+21
-25
@@ -22,46 +22,42 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// resolveAlbumCoverPath returns the filesystem path to the album's cover art.
|
||||
// It prefers an explicit cover_art_path (set by the scanner in a future
|
||||
// milestone) and falls back to a sidecar next to the first track in the
|
||||
// album's directory. "" means no art was found.
|
||||
// resolveAlbumCoverPath delegates to coverart.ResolveAlbumPath; kept as a
|
||||
// local alias so the call sites in this file read naturally.
|
||||
func resolveAlbumCoverPath(ctx context.Context, q *dbq.Queries, album dbq.Album) string {
|
||||
if album.CoverArtPath != nil && *album.CoverArtPath != "" {
|
||||
if _, err := os.Stat(*album.CoverArtPath); err == nil {
|
||||
return *album.CoverArtPath
|
||||
}
|
||||
}
|
||||
tracks, err := q.ListTracksByAlbum(ctx, dbq.ListTracksByAlbumParams{AlbumID: album.ID})
|
||||
if err != nil || len(tracks) == 0 {
|
||||
return ""
|
||||
}
|
||||
return coverart.FindSidecar(filepath.Dir(tracks[0].FilePath))
|
||||
return coverart.ResolveAlbumPath(ctx, q, album)
|
||||
}
|
||||
|
||||
// audioContentType maps the short file_format recorded on tracks (mp3, flac,
|
||||
// ogg, opus, m4a, aac, wav) to a MIME type for the Content-Type header.
|
||||
// Unknown formats fall back to octet-stream so the browser downloads them
|
||||
// rather than attempting to decode.
|
||||
// This is the canonical table; both the browser stream endpoint and the
|
||||
// UPnP cast token URL builder consult it. Unknown formats fall back to
|
||||
// octet-stream so the browser downloads them rather than attempting to
|
||||
// decode -- cast_token.go applies its own audio/mpeg fallback for Sonos.
|
||||
//
|
||||
// Aliases (mpeg/vorbis/wave) cover historical / alternate format spellings
|
||||
// that have shown up in track rows. The trim+lowercase normalization makes
|
||||
// the lookup permissive to whatever a scanner happened to write.
|
||||
//
|
||||
// Divergences from internal/subsonic/types.go's contentTypeForFormat are
|
||||
// intentional: opus→audio/ogg (library .opus files are Ogg-encapsulated, so
|
||||
// this matches real library contents), aac→audio/aac (raw AAC is ADTS, not
|
||||
// MP4, so audio/mp4 would mislead codec sniffers), and there is no "oga" case
|
||||
// (we don't record that format). Don't "fix" these to match subsonic.
|
||||
// intentional: opus/vorbis→audio/ogg (library .opus / .ogg files are
|
||||
// Ogg-encapsulated, so this matches real library contents), aac→audio/aac
|
||||
// (raw AAC is ADTS, not MP4, so audio/mp4 would mislead codec sniffers),
|
||||
// and there is no "oga" case (we don't record that format). Subsonic is a
|
||||
// frozen client contract -- don't "fix" these to match it.
|
||||
func audioContentType(format string) string {
|
||||
switch strings.ToLower(format) {
|
||||
case "mp3":
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "mp3", "mpeg":
|
||||
return "audio/mpeg"
|
||||
case "flac":
|
||||
return "audio/flac"
|
||||
case "ogg", "opus":
|
||||
case "ogg", "opus", "vorbis":
|
||||
return "audio/ogg"
|
||||
case "m4a":
|
||||
case "m4a", "mp4":
|
||||
return "audio/mp4"
|
||||
case "aac":
|
||||
return "audio/aac"
|
||||
case "wav":
|
||||
case "wav", "wave":
|
||||
return "audio/wav"
|
||||
}
|
||||
return "application/octet-stream"
|
||||
|
||||
@@ -476,8 +476,8 @@ func playlistDetailToView(d *playlists.PlaylistDetail) playlistDetailView {
|
||||
if t.TrackID != nil {
|
||||
s := uuidToString(*t.TrackID)
|
||||
v.TrackID = &s
|
||||
streamURL := "/api/tracks/" + s + "/stream"
|
||||
v.StreamURL = &streamURL
|
||||
url := streamURL(*t.TrackID)
|
||||
v.StreamURL = &url
|
||||
}
|
||||
if t.AlbumID != nil {
|
||||
s := uuidToString(*t.AlbumID)
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
package coverart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// SidecarNames is the lookup order for cover art living next to audio files.
|
||||
@@ -33,3 +36,24 @@ func FindSidecar(albumDir string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ResolveAlbumPath returns the on-disk path to an album's cover image,
|
||||
// preferring the explicit album.cover_art_path when set and the file
|
||||
// exists, falling back to a sidecar (cover.jpg / folder.jpg) next to the
|
||||
// first track in the album's directory. "" means no art was found.
|
||||
//
|
||||
// Shared by internal/api/media.go (browser endpoint) and
|
||||
// internal/subsonic/stream.go (Subsonic endpoint); they were byte-identical
|
||||
// duplicates before extraction.
|
||||
func ResolveAlbumPath(ctx context.Context, q *dbq.Queries, album dbq.Album) string {
|
||||
if album.CoverArtPath != nil && *album.CoverArtPath != "" {
|
||||
if _, err := os.Stat(*album.CoverArtPath); err == nil {
|
||||
return *album.CoverArtPath
|
||||
}
|
||||
}
|
||||
tracks, err := q.ListTracksByAlbum(ctx, dbq.ListTracksByAlbumParams{AlbumID: album.ID})
|
||||
if err != nil || len(tracks) == 0 {
|
||||
return ""
|
||||
}
|
||||
return FindSidecar(filepath.Dir(tracks[0].FilePath))
|
||||
}
|
||||
|
||||
@@ -439,15 +439,6 @@ type ScanRun struct {
|
||||
ArtistArtEnrich []byte
|
||||
}
|
||||
|
||||
type ScanSchedule struct {
|
||||
ID bool
|
||||
Mode string
|
||||
IntervalHours *int32
|
||||
TimeOfDay *string
|
||||
WeeklyDay *int32
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type ScrobbleQueue struct {
|
||||
ID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
|
||||
@@ -97,6 +97,83 @@ func (q *Queries) ListLastPlayedArtistsForUser(ctx context.Context, arg ListLast
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listMostPlayedTracksForArtist = `-- name: ListMostPlayedTracksForArtist :many
|
||||
WITH plays AS (
|
||||
SELECT track_id, count(*) AS cnt
|
||||
FROM play_events
|
||||
WHERE user_id = $2 AND was_skipped = false
|
||||
GROUP BY track_id
|
||||
)
|
||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
||||
albums.title AS album_title,
|
||||
artists.name AS artist_name
|
||||
FROM plays p
|
||||
JOIN tracks t ON t.id = p.track_id
|
||||
JOIN albums ON albums.id = t.album_id
|
||||
JOIN artists ON artists.id = t.artist_id
|
||||
WHERE t.artist_id = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $2 AND q.track_id = t.id
|
||||
)
|
||||
ORDER BY p.cnt DESC, t.id
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type ListMostPlayedTracksForArtistParams struct {
|
||||
ArtistID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
ResultLimit int32
|
||||
}
|
||||
|
||||
type ListMostPlayedTracksForArtistRow struct {
|
||||
Track Track
|
||||
AlbumTitle string
|
||||
ArtistName string
|
||||
}
|
||||
|
||||
// Top tracks for one artist by this user's completed-play count (skips
|
||||
// excluded, quarantine filtered). Same projection as
|
||||
// ListMostPlayedTracksForUser plus an artist_id filter, so the handler
|
||||
// reuses trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName).
|
||||
func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMostPlayedTracksForArtistParams) ([]ListMostPlayedTracksForArtistRow, error) {
|
||||
rows, err := q.db.Query(ctx, listMostPlayedTracksForArtist, arg.ArtistID, arg.UserID, arg.ResultLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListMostPlayedTracksForArtistRow
|
||||
for rows.Next() {
|
||||
var i ListMostPlayedTracksForArtistRow
|
||||
if err := rows.Scan(
|
||||
&i.Track.ID,
|
||||
&i.Track.Title,
|
||||
&i.Track.AlbumID,
|
||||
&i.Track.ArtistID,
|
||||
&i.Track.TrackNumber,
|
||||
&i.Track.DiscNumber,
|
||||
&i.Track.DurationMs,
|
||||
&i.Track.FilePath,
|
||||
&i.Track.FileSize,
|
||||
&i.Track.FileFormat,
|
||||
&i.Track.Bitrate,
|
||||
&i.Track.Mbid,
|
||||
&i.Track.Genre,
|
||||
&i.Track.AddedAt,
|
||||
&i.Track.UpdatedAt,
|
||||
&i.AlbumTitle,
|
||||
&i.ArtistName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listMostPlayedTracksForUser = `-- name: ListMostPlayedTracksForUser :many
|
||||
WITH plays AS (
|
||||
SELECT track_id, count(*) AS cnt
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: scan_schedule.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const getScanSchedule = `-- name: GetScanSchedule :one
|
||||
SELECT mode, interval_hours, time_of_day, weekly_day, updated_at
|
||||
FROM scan_schedule WHERE id = true
|
||||
`
|
||||
|
||||
type GetScanScheduleRow struct {
|
||||
Mode string
|
||||
IntervalHours *int32
|
||||
TimeOfDay *string
|
||||
WeeklyDay *int32
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
// M7 recurring-scans: returns the singleton schedule row. Used by the
|
||||
// scheduler goroutine on boot and config-refresh, and by the admin GET
|
||||
// handler.
|
||||
func (q *Queries) GetScanSchedule(ctx context.Context) (GetScanScheduleRow, error) {
|
||||
row := q.db.QueryRow(ctx, getScanSchedule)
|
||||
var i GetScanScheduleRow
|
||||
err := row.Scan(
|
||||
&i.Mode,
|
||||
&i.IntervalHours,
|
||||
&i.TimeOfDay,
|
||||
&i.WeeklyDay,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateScanSchedule = `-- name: UpdateScanSchedule :exec
|
||||
UPDATE scan_schedule
|
||||
SET mode = $1,
|
||||
interval_hours = $2,
|
||||
time_of_day = $3,
|
||||
weekly_day = $4,
|
||||
updated_at = now()
|
||||
WHERE id = true
|
||||
`
|
||||
|
||||
type UpdateScanScheduleParams struct {
|
||||
Mode string
|
||||
IntervalHours *int32
|
||||
TimeOfDay *string
|
||||
WeeklyDay *int32
|
||||
}
|
||||
|
||||
// Admin PATCH replaces the whole row; CHECK constraints enforce validity.
|
||||
// The application normalizes mode='off' to NULL the per-mode fields
|
||||
// before calling this.
|
||||
func (q *Queries) UpdateScanSchedule(ctx context.Context, arg UpdateScanScheduleParams) error {
|
||||
_, err := q.db.Exec(ctx, updateScanSchedule,
|
||||
arg.Mode,
|
||||
arg.IntervalHours,
|
||||
arg.TimeOfDay,
|
||||
arg.WeeklyDay,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -154,6 +154,77 @@ func (q *Queries) ListPlayedTracksNeedingSimilarity(ctx context.Context, limit i
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listSimilarArtistsForArtist = `-- name: ListSimilarArtistsForArtist :many
|
||||
SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, artists.artist_thumb_path, artists.artist_fanart_path, artists.artist_art_source, artists.artist_art_sources_version,
|
||||
cov.id AS cover_album_id,
|
||||
cnt.album_count::bigint AS album_count
|
||||
FROM (
|
||||
SELECT artist_b_id, max(score) AS sim_score
|
||||
FROM artist_similarity
|
||||
WHERE artist_a_id = $1
|
||||
GROUP BY artist_b_id
|
||||
) s
|
||||
JOIN artists ON artists.id = s.artist_b_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id FROM albums
|
||||
WHERE artist_id = artists.id AND cover_art_path IS NOT NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) cov ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*) AS album_count
|
||||
FROM albums WHERE artist_id = artists.id
|
||||
) cnt ON true
|
||||
ORDER BY s.sim_score DESC, artists.sort_name
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListSimilarArtistsForArtistParams struct {
|
||||
SeedArtistID pgtype.UUID
|
||||
ResultLimit int32
|
||||
}
|
||||
|
||||
type ListSimilarArtistsForArtistRow struct {
|
||||
Artist Artist
|
||||
CoverAlbumID pgtype.UUID
|
||||
AlbumCount int64
|
||||
}
|
||||
|
||||
// In-library artists similar to a seed artist, ranked by best similarity
|
||||
// score across sources (deduped per candidate). cover_album_id + album_count
|
||||
// mirror ListArtistsAlphaWithCovers so the strip renders identical cards.
|
||||
func (q *Queries) ListSimilarArtistsForArtist(ctx context.Context, arg ListSimilarArtistsForArtistParams) ([]ListSimilarArtistsForArtistRow, error) {
|
||||
rows, err := q.db.Query(ctx, listSimilarArtistsForArtist, arg.SeedArtistID, arg.ResultLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListSimilarArtistsForArtistRow
|
||||
for rows.Next() {
|
||||
var i ListSimilarArtistsForArtistRow
|
||||
if err := rows.Scan(
|
||||
&i.Artist.ID,
|
||||
&i.Artist.Name,
|
||||
&i.Artist.SortName,
|
||||
&i.Artist.Mbid,
|
||||
&i.Artist.CreatedAt,
|
||||
&i.Artist.UpdatedAt,
|
||||
&i.Artist.ArtistThumbPath,
|
||||
&i.Artist.ArtistFanartPath,
|
||||
&i.Artist.ArtistArtSource,
|
||||
&i.Artist.ArtistArtSourcesVersion,
|
||||
&i.CoverAlbumID,
|
||||
&i.AlbumCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const upsertArtistSimilarity = `-- name: UpsertArtistSimilarity :exec
|
||||
INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at)
|
||||
VALUES ($1, $2, $3, 'listenbrainz', now())
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Recreate the scan_schedule singleton (mirror of 0019) so the drop is
|
||||
-- reversible. The application no longer reads or writes this table.
|
||||
CREATE TABLE scan_schedule (
|
||||
id boolean PRIMARY KEY DEFAULT true,
|
||||
mode text NOT NULL DEFAULT 'off',
|
||||
interval_hours int,
|
||||
time_of_day text,
|
||||
weekly_day int,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT scan_schedule_singleton CHECK (id = true),
|
||||
CONSTRAINT scan_schedule_mode_check
|
||||
CHECK (mode IN ('off', 'interval', 'daily', 'weekly')),
|
||||
CONSTRAINT scan_schedule_interval_check
|
||||
CHECK (mode != 'interval' OR (interval_hours IS NOT NULL AND interval_hours > 0)),
|
||||
CONSTRAINT scan_schedule_daily_check
|
||||
CHECK (mode != 'daily' OR time_of_day IS NOT NULL),
|
||||
CONSTRAINT scan_schedule_weekly_check
|
||||
CHECK (mode != 'weekly' OR (time_of_day IS NOT NULL AND weekly_day IS NOT NULL AND weekly_day BETWEEN 1 AND 7))
|
||||
);
|
||||
|
||||
INSERT INTO scan_schedule (id) VALUES (true);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Retire the configurable scan scheduler. New-file pickup is now handled by
|
||||
-- the fsnotify watcher + a hardcoded safety-net delta walk (cmd/minstrel),
|
||||
-- so the operator-configurable schedule (and its admin UI) is gone.
|
||||
DROP TABLE IF EXISTS scan_schedule;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user