Compare commits
225 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b7bd0c3e8 | |||
| 26c368c35e | |||
| 36786defd1 | |||
| 8019537b02 | |||
| 3f240bc777 | |||
| e41d603c12 | |||
| 1cc9553d1f | |||
| a62f07bd3a | |||
| c2f9bbaff4 | |||
| abc225e12a | |||
| 3c646c6974 | |||
| aff346c731 | |||
| 13b3fca949 | |||
| 6e0d0e5723 | |||
| 752906b054 | |||
| fdd14ef04c | |||
| 9a57dc4bec | |||
| 4ecb1680bf | |||
| d2a22e49e3 | |||
| f167ddfbfb | |||
| 653ed95f14 | |||
| b2c6f6f0e9 | |||
| 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 | |||
| 3c4c27fb08 | |||
| 7d15f57e86 | |||
| a9edc12523 | |||
| 96f12d6aac | |||
| 036da9dea8 | |||
| a62a20b599 | |||
| 7c11cdc4d1 | |||
| c3614c6333 | |||
| 9e67088fdb | |||
| 6da6cb5c5a | |||
| 7473e98d91 | |||
| 3df5e5cb3c | |||
| 448c9f2e74 | |||
| 03cdff547d | |||
| f8c93e013d | |||
| 1f02813cc6 | |||
| dc5b8252bb | |||
| 5f3905f2c7 | |||
| e774097fd8 | |||
| 236637fcd3 | |||
| d7fe515940 | |||
| 8258b6c29f | |||
| d10113db54 | |||
| a319e3f66d | |||
| 692d9dab60 | |||
| 087486d253 | |||
| 0662c9d5cc | |||
| e69a5204db | |||
| 4be7e47584 | |||
| 551bbf83c2 | |||
| 43754d03c4 | |||
| 7807e31b22 | |||
| 9a7cfac7f8 | |||
| d37ef56bb1 | |||
| ad7e57fe66 | |||
| d9b2dd957c | |||
| 94b3b87785 | |||
| 46dcd38fd8 | |||
| cb2f9a2ea2 | |||
| 305d4780ac | |||
| dbcadf0f93 | |||
| 258bc1f75c | |||
| bda0896d82 | |||
| 413d729711 | |||
| b970b87343 | |||
| 5014f7548e | |||
| b19c621743 | |||
| fb3116d640 | |||
| 47d2f61161 | |||
| 7838038047 | |||
| 9f0af9c24b | |||
| 76e64fd022 | |||
| 337fce83a1 | |||
| de61305fde | |||
| 99e1df4920 | |||
| 15b59a214d | |||
| 6a54d074fd | |||
| d9c7aae268 | |||
| 5fd1a5724a | |||
| 516d22fc73 | |||
| 1fdd785ee5 | |||
| 9b3ec65476 | |||
| b83a6a4bdb | |||
| b64965b38d | |||
| 4cd38aa62f | |||
| 438e81a117 | |||
| faf2cac0c9 | |||
| 22dc343b39 | |||
| aec10ce787 | |||
| bf2f9f3811 | |||
| b9186937b3 | |||
| deb726a285 | |||
| 1004b61159 | |||
| 7ede83a586 | |||
| 6ac36dd334 | |||
| 6a7d9afdbc | |||
| 15a545a25c | |||
| b77a7121ca | |||
| 74bae74f9f | |||
| d4c6bb3f2d |
@@ -60,9 +60,35 @@ jobs:
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
|
||||
# Job outputs propagate the computed release version to image-release
|
||||
# so the bundled sidecar file matches what's baked into the APK —
|
||||
# otherwise the server would report a different version string than
|
||||
# the installed client and the update banner could thrash.
|
||||
outputs:
|
||||
version_name: ${{ steps.ver.outputs.name }}
|
||||
version_code: ${{ steps.ver.outputs.code }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# fetch-depth: 0 retrieves full history; default shallow clone
|
||||
# would return 1 for `git rev-list --count HEAD`, breaking the
|
||||
# iteration suffix.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute release version
|
||||
id: ver
|
||||
shell: bash
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/v}"
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
VERSION_NAME="${TAG}.${COMMIT_COUNT}"
|
||||
echo "name=${VERSION_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})"
|
||||
|
||||
- name: Cache Gradle dirs
|
||||
uses: actions/cache@v4
|
||||
@@ -91,7 +117,10 @@ jobs:
|
||||
echo "ANDROID_KEYSTORE_PATH=${KEYSTORE_PATH}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Build release APK
|
||||
run: ./gradlew assembleRelease
|
||||
run: |
|
||||
./gradlew assembleRelease \
|
||||
-PMINSTREL_VERSION_NAME=${{ steps.ver.outputs.name }} \
|
||||
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
|
||||
|
||||
- name: Upload APK as workflow artifact
|
||||
# @v3 because Gitea Actions emulates GHES and the v2 artifact
|
||||
@@ -204,14 +233,18 @@ jobs:
|
||||
- name: Stage bundled APK + version sidecar
|
||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
env:
|
||||
# Pulled from android-release.outputs.version_name so the
|
||||
# sidecar string the server hands clients matches the
|
||||
# versionName baked into the APK they're comparing against.
|
||||
APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
# The artifact lands as `app-release.apk` (the original Gradle
|
||||
# output name). The Dockerfile COPYs client/* into /app/client/
|
||||
# and the server reads minstrel.apk + minstrel.apk.version.
|
||||
mv client/app-release.apk client/minstrel.apk
|
||||
echo "${TAG}" > client/minstrel.apk.version
|
||||
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
|
||||
ls -lh client/
|
||||
|
||||
- name: Build and push
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -21,8 +21,19 @@ android {
|
||||
applicationId = "com.fabledsword.minstrel"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "0.1.0-native"
|
||||
// versionName / versionCode are released-build values injected by
|
||||
// CI from the git tag + commit count. Local / debug builds fall
|
||||
// back to "dev" so the About card reads honestly. Releases ship
|
||||
// versionName="YYYY.MM.DD.<commits>" (e.g. "2026.06.02.142") and
|
||||
// versionCode=<commits>, which is monotonic forever and lets the
|
||||
// shared isVersionNewer comparator distinguish two same-day
|
||||
// re-cuts (the iteration suffix differs).
|
||||
val versionNameOverride =
|
||||
(project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
|
||||
val versionCodeOverride =
|
||||
(project.findProperty("MINSTREL_VERSION_CODE") as String?)?.toIntOrNull()
|
||||
versionCode = versionCodeOverride ?: 1
|
||||
versionName = versionNameOverride ?: "dev"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables { useSupportLibrary = true }
|
||||
}
|
||||
@@ -162,6 +173,7 @@ dependencies {
|
||||
implementation(libs.media3.exoplayer)
|
||||
implementation(libs.media3.session)
|
||||
implementation(libs.media3.datasource.okhttp)
|
||||
implementation(libs.mediarouter)
|
||||
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
@@ -175,6 +187,12 @@ dependencies {
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
// kxml2 — provides an org.xmlpull.v1 impl on the JVM unit-test
|
||||
// classpath. Android's stock XmlPullParserFactory resolves to the
|
||||
// android.jar Stub on JVM tests; kxml2 is picked up via service-
|
||||
// provider lookup and makes XmlPullParserFactory.newInstance() work
|
||||
// unconditionally so DeviceDescriptionTest runs in CI.
|
||||
testImplementation(libs.kxml2)
|
||||
// kotlin.test for assertEquals/assertNull/etc. — version managed by
|
||||
// the applied Kotlin plugin so no explicit version pin needed.
|
||||
testImplementation(kotlin("test"))
|
||||
|
||||
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".MinstrelApplication"
|
||||
@@ -22,9 +24,14 @@
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="34">
|
||||
|
||||
<!-- Portrait-locked until a tablet/landscape layout exists.
|
||||
Current Compose screens are sized for phone-portrait;
|
||||
landscape just stretches the column awkwardly. Revisit
|
||||
this when a dedicated tablet layout lands. -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.Minstrel">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.fabledsword.minstrel
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -11,6 +12,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -19,24 +21,69 @@ 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
|
||||
import com.fabledsword.minstrel.nav.NowPlaying
|
||||
import com.fabledsword.minstrel.shared.widgets.LocalCachedTrackIds
|
||||
import com.fabledsword.minstrel.theme.MinstrelTheme
|
||||
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
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
|
||||
// composable observes this, navigates to NowPlaying once the
|
||||
// NavHost is ready, then calls back to reset the flag so the
|
||||
// navigation doesn't re-fire on the next recomposition.
|
||||
private val pendingOpenNowPlaying = MutableStateFlow(false)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent { App(seedCache = seedCache, cachedTrackIds = cachedTrackIds) }
|
||||
consumeOpenNowPlayingIntent(intent)
|
||||
setContent {
|
||||
App(
|
||||
seedCache = seedCache,
|
||||
cachedTrackIds = cachedTrackIds,
|
||||
serverHealth = serverHealth,
|
||||
pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
|
||||
onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
consumeOpenNowPlayingIntent(intent)
|
||||
}
|
||||
|
||||
private fun consumeOpenNowPlayingIntent(intent: Intent?) {
|
||||
if (intent?.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false) == true) {
|
||||
pendingOpenNowPlaying.value = true
|
||||
// Strip the extra so a subsequent config-change recreation
|
||||
// doesn't re-trigger the navigation.
|
||||
intent.removeExtra(EXTRA_OPEN_NOW_PLAYING)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** PendingIntent extra set by [com.fabledsword.minstrel.player.MinstrelPlayerService]
|
||||
* so a media-notification tap lands on the full NowPlaying screen
|
||||
* instead of whatever shell route MainActivity last rendered. */
|
||||
const val EXTRA_OPEN_NOW_PLAYING = "com.fabledsword.minstrel.action.OPEN_NOW_PLAYING"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,15 +91,21 @@ class MainActivity : ComponentActivity() {
|
||||
private fun App(
|
||||
seedCache: DetailSeedCache,
|
||||
cachedTrackIds: CachedTrackIds,
|
||||
serverHealth: NetworkStatusController,
|
||||
pendingOpenNowPlaying: StateFlow<Boolean>,
|
||||
onOpenedNowPlaying: () -> Unit,
|
||||
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
||||
gate: AuthGateViewModel = hiltViewModel(),
|
||||
) {
|
||||
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
|
||||
@@ -65,6 +118,18 @@ private fun App(
|
||||
// `ShellScaffold` wrap; full-screen routes (NowPlaying /
|
||||
// Queue / unauthenticated) bypass the shell entirely.
|
||||
val navController = rememberNavController()
|
||||
// Honour a pending notification-tap once the NavHost is
|
||||
// mounted. launchSingleTop avoids stacking copies of
|
||||
// NowPlaying if the user taps the notification while
|
||||
// already on it; the callback clears the flag so a later
|
||||
// recomposition (config change, theme switch) doesn't
|
||||
// re-navigate.
|
||||
LaunchedEffect(pending, navController) {
|
||||
if (pending) {
|
||||
navController.navigate(NowPlaying) { launchSingleTop = true }
|
||||
onOpenedNowPlaying()
|
||||
}
|
||||
}
|
||||
MinstrelNavGraph(
|
||||
navController = navController,
|
||||
startDestination = resolved,
|
||||
|
||||
@@ -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
|
||||
@@ -158,10 +159,43 @@ class MinstrelApplication :
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
|
||||
// Debug builds get the full DebugTree (verbose). Release builds
|
||||
// get a WARN+ tree so operator-driven diagnosis via `adb logcat`
|
||||
// still surfaces UPnP / cast failures, OkHttp errors, and our
|
||||
// own Timber.w / Timber.e calls — without the chatty DEBUG /
|
||||
// INFO traffic flooding the buffer in production.
|
||||
if (BuildConfig.DEBUG) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
} else {
|
||||
Timber.plant(ReleaseTree())
|
||||
}
|
||||
appScope.launch { resumeController.restore() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Release-build Timber tree: emits at WARN and above only.
|
||||
* `android.util.Log` with the canonical tag so `adb logcat` shows
|
||||
* the line under the standard tag column without falling through
|
||||
* to the package-stack-trace tag DebugTree produces.
|
||||
*/
|
||||
private class ReleaseTree : Timber.Tree() {
|
||||
override fun isLoggable(tag: String?, priority: Int): Boolean =
|
||||
priority >= android.util.Log.WARN
|
||||
|
||||
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
|
||||
val resolvedTag = tag ?: "Minstrel"
|
||||
if (t == null) {
|
||||
android.util.Log.println(priority, resolvedTag, message)
|
||||
} else {
|
||||
android.util.Log.println(
|
||||
priority,
|
||||
resolvedTag,
|
||||
message + '\n' + android.util.Log.getStackTraceString(t),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
|
||||
@@ -7,11 +7,23 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Attaches the session cookie from [AuthStore] to every request,
|
||||
* captures Set-Cookie from successful responses (login flow), and
|
||||
* clears the store on 401 so downstream code can react to logout.
|
||||
* Attaches the session cookie from [AuthStore] to Minstrel-server
|
||||
* requests, captures Set-Cookie from successful responses (login
|
||||
* flow), and clears the store on 401 so downstream code can react
|
||||
* to logout.
|
||||
*
|
||||
* Mirrors the Flutter Dio interceptor pattern.
|
||||
*
|
||||
* Scoped to the [BaseUrlInterceptor.PLACEHOLDER_HOST] sentinel host
|
||||
* the same way BaseUrlInterceptor is. Drift #568 / #569 caught two
|
||||
* leaks here: (1) the session cookie was being attached to every
|
||||
* external request the shared OkHttpClient services — including
|
||||
* Coil image fetches to artwork.musicbrainz.org / coverartarchive.org
|
||||
* / Lidarr's /MediaCover endpoints — exposing the session
|
||||
* identifier to third-party logging; (2) a 401 from any of those
|
||||
* external hosts silently wiped the user's Minstrel session.
|
||||
* Restricting both attach + clear to placeholder-host requests
|
||||
* closes both gaps.
|
||||
*/
|
||||
@Singleton
|
||||
class AuthCookieInterceptor @Inject constructor(
|
||||
@@ -19,8 +31,15 @@ class AuthCookieInterceptor @Inject constructor(
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val original = chain.request()
|
||||
if (original.url.host != BaseUrlInterceptor.PLACEHOLDER_HOST) {
|
||||
// External request — no Minstrel session cookie attached,
|
||||
// and a 401 from this host does NOT clear the user's
|
||||
// session. Pass through untouched.
|
||||
return chain.proceed(original)
|
||||
}
|
||||
val cookie = authStore.sessionCookie.value
|
||||
val request = chain.request().newBuilder().apply {
|
||||
val request = original.newBuilder().apply {
|
||||
if (!cookie.isNullOrEmpty()) header("Cookie", cookie)
|
||||
}.build()
|
||||
|
||||
|
||||
@@ -9,16 +9,23 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Rewrites every outgoing request's scheme/host/port to match the
|
||||
* Rewrites Minstrel-server requests' scheme/host/port to match the
|
||||
* current [AuthStore.baseUrl]. Retrofit's `.baseUrl(...)` is read
|
||||
* once at Retrofit creation, but AuthStore.baseUrl loads from Room
|
||||
* asynchronously — so at injection time the Retrofit instance is
|
||||
* frozen pointing at the [AuthStore.DEFAULT_BASE_URL] placeholder.
|
||||
*
|
||||
* This interceptor closes the gap: Retrofit can stay built with the
|
||||
* placeholder forever, and every actual request gets retargeted at
|
||||
* the live AuthStore value. Lets the user change server URL in
|
||||
* Settings without an app relaunch (Phase 11 wiring).
|
||||
* placeholder forever, and every actual Minstrel-bound request gets
|
||||
* retargeted at the live AuthStore value. Lets the user change
|
||||
* server URL in Settings without an app relaunch (Phase 11 wiring).
|
||||
*
|
||||
* Only requests whose host is the [PLACEHOLDER_HOST] sentinel are
|
||||
* rewritten. Absolute external URLs — Lidarr-surfaced artwork from
|
||||
* artwork.musicbrainz.org / coverartarchive.org, for example — must
|
||||
* reach their authored host unchanged; rewriting them to the
|
||||
* Minstrel host produced 404s the user saw as missing Discover
|
||||
* suggestion covers.
|
||||
*
|
||||
* No-op when the stored base URL is unparseable (falls through to
|
||||
* whatever Retrofit had) — that case shows up as a transport-level
|
||||
@@ -31,13 +38,26 @@ class BaseUrlInterceptor @Inject constructor(
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val original = chain.request()
|
||||
val baseUrl = authStore.baseUrl.value.toHttpUrlOrNull()
|
||||
?: return chain.proceed(original)
|
||||
val rewritten: HttpUrl = original.url.newBuilder()
|
||||
.scheme(baseUrl.scheme)
|
||||
.host(baseUrl.host)
|
||||
.port(baseUrl.port)
|
||||
.build()
|
||||
// Early-bail keeps the no-op path for external URLs cheap
|
||||
// (no AuthStore read, no URL parse).
|
||||
if (original.url.host != PLACEHOLDER_HOST) return chain.proceed(original)
|
||||
// Unparseable baseUrl folds into the same proceed path — keeps
|
||||
// detekt's ReturnCount happy by avoiding a second early return.
|
||||
val rewritten: HttpUrl = authStore.baseUrl.value.toHttpUrlOrNull()?.let { baseUrl ->
|
||||
original.url.newBuilder()
|
||||
.scheme(baseUrl.scheme)
|
||||
.host(baseUrl.host)
|
||||
.port(baseUrl.port)
|
||||
.build()
|
||||
} ?: original.url
|
||||
return chain.proceed(original.newBuilder().url(rewritten).build())
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Sentinel host used by Retrofit + every code path that builds
|
||||
* a Minstrel-server URL via the `http://placeholder.invalid/...`
|
||||
* form (see [com.fabledsword.minstrel.shared.resolveServerUrl],
|
||||
* CoverUrls.kt, AlbumRef/TrackRef cover getters). */
|
||||
const val PLACEHOLDER_HOST = "placeholder.invalid"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,14 +46,29 @@ object NetworkModule {
|
||||
fun provideOkHttp(
|
||||
baseUrl: BaseUrlInterceptor,
|
||||
auth: AuthCookieInterceptor,
|
||||
reachability: ReachabilityReportingInterceptor,
|
||||
logging: HttpLoggingInterceptor,
|
||||
): OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
// BaseUrlInterceptor first — it rewrites scheme/host/port
|
||||
// to match the live AuthStore.baseUrl on every request.
|
||||
// Auth then sees the final URL.
|
||||
.addInterceptor(baseUrl)
|
||||
// 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
|
||||
// (drift #568 / #569). If BaseUrlInterceptor runs first it
|
||||
// rewrites the host to the real server before auth sees the
|
||||
// request, auth's placeholder check fails, and the session
|
||||
// cookie is neither attached on outgoing requests nor
|
||||
// captured from Set-Cookie on login — fresh installs get
|
||||
// stuck at the Welcome screen. Auth first means it sees
|
||||
// placeholder.invalid, attaches/captures correctly, then
|
||||
// BaseUrlInterceptor retargets to the live AuthStore host
|
||||
// for transport.
|
||||
.addInterceptor(auth)
|
||||
.addInterceptor(baseUrl)
|
||||
.addInterceptor(logging)
|
||||
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.fabledsword.minstrel.api.endpoints
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* Retrofit interface for the cast-token endpoint. Used by the UPnP
|
||||
* selection path in `OutputPickerController` to obtain a signed stream
|
||||
* URL that a network speaker can fetch without the user's session
|
||||
* cookie — those devices cannot carry the session, so the signed
|
||||
* query string is the only way they can fetch the bytes.
|
||||
*
|
||||
* Endpoint: `POST /api/cast/stream-token`
|
||||
* Auth: standard session cookie (`AuthCookieInterceptor` handles it).
|
||||
*
|
||||
* Server contract lives at `internal/api/cast_token.go`
|
||||
* (commit e774097f). Field names here mirror the server's `json:`
|
||||
* tags verbatim — `trackId` / `expSeconds` — so no `@SerialName` is
|
||||
* needed on the request, and `token` / `exp` / `url` map straight
|
||||
* through on the response.
|
||||
*/
|
||||
interface CastApi {
|
||||
@POST("api/cast/stream-token")
|
||||
suspend fun streamToken(@Body req: StreamTokenRequest): StreamTokenResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body. [expSeconds] is clamped server-side to [60, 86400];
|
||||
* the 21_600 default (6h) is long enough to play through any typical
|
||||
* track without re-minting mid-playback.
|
||||
*/
|
||||
@Serializable
|
||||
data class StreamTokenRequest(
|
||||
val trackId: String,
|
||||
val expSeconds: Int = 21_600,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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`. [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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.fabledsword.minstrel.api.endpoints
|
||||
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* Retrofit interface for `POST /api/playback-errors`. Reports a
|
||||
* client-detected playback failure (zero-duration, decode error, ...)
|
||||
* so the admin inbox surfaces it.
|
||||
*
|
||||
* Failures during dispatch are reported via the MutationQueue path
|
||||
* for offline replay (see [com.fabledsword.minstrel.cache.mutations.MutationQueue.enqueuePlaybackErrorReport]).
|
||||
*/
|
||||
interface PlaybackErrorsApi {
|
||||
@POST("api/playback-errors")
|
||||
suspend fun report(@Body body: PlaybackErrorReportRequest)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST body for `/api/playback-errors`. `kind` is one of
|
||||
* "zero_duration" / "load_failed" / "stalled"; server validates against
|
||||
* the CHECK constraint enum. `detail` is optional free-text (Media3
|
||||
* error message, etc.). `clientId` reuses the existing playback
|
||||
* client identifier the device already sends with `/api/plays/...`
|
||||
* so support can correlate reports across surfaces.
|
||||
*/
|
||||
@kotlinx.serialization.Serializable
|
||||
data class PlaybackErrorReportRequest(
|
||||
@kotlinx.serialization.SerialName("track_id") val trackId: String,
|
||||
val kind: String,
|
||||
val detail: String? = null,
|
||||
@kotlinx.serialization.SerialName("client_id") val clientId: String,
|
||||
)
|
||||
+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>
|
||||
|
||||
|
||||
+18
@@ -4,6 +4,7 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@@ -36,4 +37,21 @@ interface CachedLikeDao {
|
||||
"WHERE userId = :userId AND entityType = :entityType AND entityId = :entityId",
|
||||
)
|
||||
suspend fun delete(userId: String, entityType: String, entityId: String)
|
||||
|
||||
@Query("DELETE FROM cached_likes WHERE userId = :userId")
|
||||
suspend fun clearForUser(userId: String)
|
||||
|
||||
/**
|
||||
* Atomically replaces the user's entire cached_likes set with
|
||||
* [rows]. Used by [com.fabledsword.minstrel.likes.data.LikesRepository.refreshIds]
|
||||
* so cross-device unlikes (a row that the server no longer
|
||||
* surfaces) get removed from the local cache — drift #570
|
||||
* caught the missing delete pass that left stale Liked tab
|
||||
* entries pointing at tracks the user had unliked elsewhere.
|
||||
*/
|
||||
@Transaction
|
||||
suspend fun replaceAllForUser(userId: String, rows: List<CachedLikeEntity>) {
|
||||
clearForUser(userId)
|
||||
upsertAll(rows)
|
||||
}
|
||||
}
|
||||
|
||||
+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,
|
||||
)
|
||||
Vendored
+5
@@ -14,6 +14,11 @@ import kotlinx.datetime.Instant
|
||||
* - "rediscover_artists"
|
||||
* - "most_played_tracks"
|
||||
* - "last_played_artists"
|
||||
* - "you_might_like_albums"
|
||||
* - "you_might_like_artists"
|
||||
*
|
||||
* New `section` values need no schema change — the column stores the
|
||||
* string verbatim and the DAO keys on it.
|
||||
*
|
||||
* `entityType` is "album" | "artist" | "track" — dispatches hydration
|
||||
* to the right per-entity endpoint when a tile is rendered.
|
||||
|
||||
+115
-39
@@ -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
|
||||
@@ -21,6 +27,14 @@ object MutationKind {
|
||||
const val QUARANTINE_FLAG: String = "quarantine_flag"
|
||||
const val PLAY_OFFLINE: String = "play_offline"
|
||||
const val REQUEST_CANCEL: String = "request_cancel"
|
||||
const val PLAYBACK_ERROR_REPORT: String = "playback_error_report"
|
||||
|
||||
// Durable close-by-id for a play whose server row is already open
|
||||
// (play_started succeeded). Distinct from PLAY_OFFLINE, which records
|
||||
// a *whole* play when no server row exists. Using close-by-id on
|
||||
// background avoids the duplicate + orphan row the old offline-on-stop
|
||||
// path produced (see 2026-06-11 contract audit).
|
||||
const val PLAY_ENDED: String = "play_ended"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,47 +84,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),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -118,13 +144,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),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -135,15 +167,29 @@ class MutationQueue @Inject constructor(
|
||||
),
|
||||
)
|
||||
|
||||
suspend fun enqueueRequestCancel(requestId: String): Long = dao.insert(
|
||||
suspend fun enqueuePlaybackErrorReport(payload: PlaybackErrorReportPayload): Long = dao.insert(
|
||||
CachedMutationEntity(
|
||||
kind = MutationKind.REQUEST_CANCEL,
|
||||
payload = json.encodeToString(
|
||||
RequestCancelPayload.serializer(),
|
||||
RequestCancelPayload(requestId),
|
||||
),
|
||||
kind = MutationKind.PLAYBACK_ERROR_REPORT,
|
||||
payload = json.encodeToString(PlaybackErrorReportPayload.serializer(), payload),
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Durable close-by-id for an already-open server play row. Background
|
||||
* path only (no user hint) — same as [enqueuePlayOffline].
|
||||
*/
|
||||
suspend fun enqueuePlayEnded(payload: PlayEndedPayload): Long = dao.insert(
|
||||
CachedMutationEntity(
|
||||
kind = MutationKind.PLAY_ENDED,
|
||||
payload = json.encodeToString(PlayEndedPayload.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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,6 +241,19 @@ data class PlayOfflinePayload(
|
||||
val source: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Persisted payload for `MutationKind.PLAY_ENDED`. The replayer re-fires
|
||||
* `POST /api/events` with type=play_ended, closing the already-open server
|
||||
* row [playEventId] at [durationPlayedMs]. Server-side close-by-id is
|
||||
* idempotent (re-ending updates the same row and skips a duplicate
|
||||
* skip_events insert), so a lost-response retry can't duplicate history.
|
||||
*/
|
||||
@Serializable
|
||||
data class PlayEndedPayload(
|
||||
val playEventId: String,
|
||||
val durationPlayedMs: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* Persisted payload for `MutationKind.REQUEST_CANCEL` — the
|
||||
* `POST /api/requests/{id}/cancel` call lost during a connectivity
|
||||
@@ -203,3 +262,20 @@ data class PlayOfflinePayload(
|
||||
*/
|
||||
@Serializable
|
||||
data class RequestCancelPayload(val requestId: String)
|
||||
|
||||
/**
|
||||
* Persisted payload for `MutationKind.PLAYBACK_ERROR_REPORT` — the
|
||||
* `POST /api/playback-errors` call lost during a connectivity hiccup.
|
||||
* The replayer re-fires the POST with this body. Server is naturally
|
||||
* idempotent enough — multiple reports of the same (track, user,
|
||||
* kind) become multiple rows in the admin inbox, which is acceptable
|
||||
* (the admin can resolve them all with one action). `clientId` is
|
||||
* the existing playback client identifier from AuthStore.
|
||||
*/
|
||||
@Serializable
|
||||
data class PlaybackErrorReportPayload(
|
||||
val trackId: String,
|
||||
val kind: String,
|
||||
val detail: String? = null,
|
||||
val clientId: String,
|
||||
)
|
||||
|
||||
+177
-140
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("TooManyFunctions") // one dispatcher per mutation kind + replay helpers
|
||||
|
||||
package com.fabledsword.minstrel.cache.mutations
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.AppendTracksRequest
|
||||
@@ -5,9 +7,14 @@ import com.fabledsword.minstrel.api.endpoints.DiscoverApi
|
||||
import com.fabledsword.minstrel.api.endpoints.EventsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.FlagRequest
|
||||
import com.fabledsword.minstrel.api.endpoints.LikesApi
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.QuarantineApi
|
||||
import com.fabledsword.minstrel.api.endpoints.RequestsApi
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
||||
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
@@ -17,12 +24,16 @@ import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import javax.inject.Inject
|
||||
@@ -32,25 +43,24 @@ import javax.inject.Singleton
|
||||
* Drains the offline MutationQueue. Reads pending rows in FIFO order,
|
||||
* re-fires each call against the raw Retrofit API (NOT the high-level
|
||||
* Repository, which would re-enqueue on failure → infinite loop), and
|
||||
* deletes the row on 2xx success.
|
||||
* resolves each to an [Outcome]:
|
||||
* - SENT — server accepted (2xx) → delete the row.
|
||||
* - DROP — permanently un-sendable (4xx, unknown kind/variant, corrupt
|
||||
* payload) → delete the row so it can't wedge the queue forever.
|
||||
* - RETRY — transient (transport, 5xx, 408, 429) → leave queued, bump
|
||||
* attempts, retry on the next trigger.
|
||||
*
|
||||
* Triggered by:
|
||||
* - app start (MinstrelApplication @Inject forces the singleton's
|
||||
* init block to subscribe)
|
||||
* - every time AuthStore.sessionCookie transitions to a non-null
|
||||
* value (sign-in, app launch with persisted cookie)
|
||||
* - app start (MinstrelApplication @Inject forces the singleton's init)
|
||||
* - AuthStore.sessionCookie transitioning to non-null (sign-in / cold
|
||||
* start with a persisted cookie)
|
||||
* - NetworkStatusController recovering to Healthy (server reachable again
|
||||
* mid-session — covers offline→online without needing a cold start)
|
||||
*
|
||||
* Single in-flight via the `mutex` — back-to-back triggers collapse
|
||||
* into one drain pass. A proper connectivity-listener / WorkManager
|
||||
* job that re-tries on Wi-Fi-came-back is a follow-up; for MVP the
|
||||
* sign-in + app-open triggers cover the common offline → online
|
||||
* transition (operator unlocks phone, opens app).
|
||||
*
|
||||
* Failed rows stay in the queue with their `attempts` and
|
||||
* `lastAttemptAt` updated. They get another chance on the next
|
||||
* trigger; no exponential backoff or max-attempts yet — the rows are
|
||||
* small (low single-digit count for typical use) so retry-forever is
|
||||
* cheap.
|
||||
* Single in-flight via the [mutex]; back-to-back triggers collapse into one
|
||||
* drain pass. No exponential backoff / max-attempts: the rows are few for
|
||||
* typical use, and permanent failures are now dropped rather than retried
|
||||
* forever.
|
||||
*/
|
||||
@Singleton
|
||||
class MutationReplayer @Inject constructor(
|
||||
@@ -58,6 +68,7 @@ class MutationReplayer @Inject constructor(
|
||||
private val authStore: AuthStore,
|
||||
private val json: Json,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
networkStatus: NetworkStatusController,
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val likesApi: LikesApi = retrofit.create()
|
||||
@@ -66,9 +77,12 @@ class MutationReplayer @Inject constructor(
|
||||
private val playlistsApi: PlaylistsApi = retrofit.create()
|
||||
private val eventsApi: EventsApi = retrofit.create()
|
||||
private val requestsApi: RequestsApi = retrofit.create()
|
||||
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
private enum class Outcome { SENT, DROP, RETRY }
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
authStore.sessionCookie
|
||||
@@ -76,6 +90,16 @@ class MutationReplayer @Inject constructor(
|
||||
.distinctUntilChanged()
|
||||
.collect { drainSafe() }
|
||||
}
|
||||
// Server-reachable-again: replay queued writes the moment the
|
||||
// health signal recovers, so an offline→online transition mid-
|
||||
// session doesn't wait for the next cold start / re-auth.
|
||||
scope.launch {
|
||||
networkStatus.state
|
||||
.map { it == ServerHealth.Healthy }
|
||||
.distinctUntilChanged()
|
||||
.filter { it }
|
||||
.collect { drainSafe() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Public entry-point; coalesces concurrent triggers via a Mutex. */
|
||||
@@ -90,172 +114,185 @@ class MutationReplayer @Inject constructor(
|
||||
|
||||
private suspend fun drain() {
|
||||
val rows = dao.getAll()
|
||||
// Collapse superseded like-toggles: only the latest desired state per
|
||||
// (entity) is replayed; older toggles for the same entity are dropped
|
||||
// unsent. Without this, partial-failure + differential retry could
|
||||
// replay an older toggle last and invert the final like state.
|
||||
val superseded = supersededLikeToggleIds(rows)
|
||||
for (row in rows) {
|
||||
val sent = runCatching { dispatch(row) }.getOrDefault(false)
|
||||
if (sent) {
|
||||
if (row.id in superseded) {
|
||||
dao.delete(row.id)
|
||||
} else {
|
||||
dao.recordAttempt(row.id, Clock.System.now())
|
||||
continue
|
||||
}
|
||||
when (outcomeFor(row)) {
|
||||
Outcome.SENT, Outcome.DROP -> dao.delete(row.id)
|
||||
Outcome.RETRY -> dao.recordAttempt(row.id, Clock.System.now())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Row ids of like-toggles superseded by a later toggle for the same entity. */
|
||||
private fun supersededLikeToggleIds(rows: List<CachedMutationEntity>): Set<Long> {
|
||||
val latestByEntity = HashMap<String, Long>()
|
||||
val superseded = HashSet<Long>()
|
||||
rows.asSequence()
|
||||
.filter { it.kind == MutationKind.LIKE_TOGGLE }
|
||||
.forEach { row ->
|
||||
val decoded = runCatching {
|
||||
json.decodeFromString(LikeTogglePayload.serializer(), row.payload)
|
||||
}.getOrNull()
|
||||
if (decoded != null) {
|
||||
val key = "${decoded.entityType}:${decoded.entityId}"
|
||||
// `rows` is ascending by id, so a prior entry is always older.
|
||||
latestByEntity.put(key, row.id)?.let(superseded::add)
|
||||
}
|
||||
}
|
||||
return superseded
|
||||
}
|
||||
|
||||
private suspend fun outcomeFor(row: CachedMutationEntity): Outcome = try {
|
||||
dispatch(row)
|
||||
} catch (e: HttpException) {
|
||||
if (isPermanent(e.code())) Outcome.DROP else Outcome.RETRY
|
||||
} catch (@Suppress("SwallowedException") e: SerializationException) {
|
||||
// Corrupt / schema-incompatible payload — permanent, drop it.
|
||||
Outcome.DROP
|
||||
} catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable) {
|
||||
// Transport / unknown — leave queued for the next pass.
|
||||
Outcome.RETRY
|
||||
}
|
||||
|
||||
/** 4xx are permanent except the two "retry me" statuses. */
|
||||
private fun isPermanent(code: Int): Boolean =
|
||||
code in HTTP_CLIENT_ERR_MIN..HTTP_CLIENT_ERR_MAX &&
|
||||
code != HTTP_TIMEOUT && code != HTTP_TOO_MANY
|
||||
|
||||
/**
|
||||
* Returns true if the server accepted the call, false on any
|
||||
* failure (transport, server error, decode). The replayer treats
|
||||
* false as "leave the row for next pass" — it doesn't try to
|
||||
* distinguish permanent rejections from transient ones; a row
|
||||
* that consistently fails will accumulate attempt count and
|
||||
* lastAttemptAt for diagnostics.
|
||||
* Returns the outcome of replaying one row. SENT on a 2xx; DROP for an
|
||||
* unknown kind/variant. Network / HTTP failures THROW and are classified
|
||||
* by [outcomeFor] — dispatchers no longer swallow.
|
||||
*/
|
||||
private suspend fun dispatch(row: CachedMutationEntity): Boolean = when (row.kind) {
|
||||
private suspend fun dispatch(row: CachedMutationEntity): Outcome = when (row.kind) {
|
||||
MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload)
|
||||
MutationKind.REQUEST_CREATE -> dispatchRequestCreate(row.payload)
|
||||
MutationKind.QUARANTINE_UNFLAG -> dispatchQuarantineUnflag(row.payload)
|
||||
MutationKind.PLAYLIST_APPEND -> dispatchPlaylistAppend(row.payload)
|
||||
MutationKind.QUARANTINE_FLAG -> dispatchQuarantineFlag(row.payload)
|
||||
MutationKind.PLAY_OFFLINE -> dispatchPlayOffline(row.payload)
|
||||
MutationKind.PLAY_ENDED -> dispatchPlayEnded(row.payload)
|
||||
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
||||
else -> {
|
||||
// Unknown kind — drop the row by claiming success so a
|
||||
// stale schema entry can't wedge the queue forever.
|
||||
true
|
||||
}
|
||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
||||
else -> Outcome.DROP
|
||||
}
|
||||
|
||||
private suspend fun dispatchLikeToggle(payload: String): Boolean {
|
||||
private suspend fun dispatchLikeToggle(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
|
||||
val kindPath = when (decoded.entityType) {
|
||||
LikesRepository.ENTITY_ARTIST -> "artists"
|
||||
LikesRepository.ENTITY_ALBUM -> "albums"
|
||||
LikesRepository.ENTITY_TRACK -> "tracks"
|
||||
else -> return true // unknown variant — drop.
|
||||
else -> return Outcome.DROP
|
||||
}
|
||||
return try {
|
||||
if (decoded.desiredState) {
|
||||
likesApi.like(kindPath, decoded.entityId)
|
||||
} else {
|
||||
likesApi.unlike(kindPath, decoded.entityId)
|
||||
}
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: the row stays in the queue with
|
||||
// attempts + lastAttemptAt incremented; next drain pass
|
||||
// retries. Per-attempt diagnostic logging lands when we
|
||||
// wire up Timber at the replayer level.
|
||||
false
|
||||
if (decoded.desiredState) {
|
||||
likesApi.like(kindPath, decoded.entityId)
|
||||
} else {
|
||||
likesApi.unlike(kindPath, decoded.entityId)
|
||||
}
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchRequestCreate(payload: String): Boolean {
|
||||
private suspend fun dispatchRequestCreate(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(RequestCreatePayload.serializer(), payload)
|
||||
val body = CreateRequestBody(
|
||||
kind = decoded.kind,
|
||||
artistMbid = decoded.artistMbid,
|
||||
artistName = decoded.artistName,
|
||||
albumMbid = decoded.albumMbid,
|
||||
albumTitle = decoded.albumTitle,
|
||||
trackMbid = decoded.trackMbid,
|
||||
trackTitle = decoded.trackTitle,
|
||||
discoverApi.createRequest(
|
||||
CreateRequestBody(
|
||||
kind = decoded.kind,
|
||||
artistMbid = decoded.artistMbid,
|
||||
artistName = decoded.artistName,
|
||||
albumMbid = decoded.albumMbid,
|
||||
albumTitle = decoded.albumTitle,
|
||||
trackMbid = decoded.trackMbid,
|
||||
trackTitle = decoded.trackTitle,
|
||||
),
|
||||
)
|
||||
return try {
|
||||
discoverApi.createRequest(body)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: the row stays in the queue with
|
||||
// attempts + lastAttemptAt incremented; next drain pass
|
||||
// retries. Per-attempt diagnostic logging lands when we
|
||||
// wire up Timber at the replayer level.
|
||||
false
|
||||
}
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchQuarantineUnflag(payload: String): Boolean {
|
||||
private suspend fun dispatchQuarantineUnflag(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(QuarantineUnflagPayload.serializer(), payload)
|
||||
return try {
|
||||
quarantineApi.unflag(decoded.trackId)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: the row stays in the queue with
|
||||
// attempts + lastAttemptAt incremented; next drain pass
|
||||
// retries. Per-attempt diagnostic logging lands when we
|
||||
// wire up Timber at the replayer level.
|
||||
false
|
||||
}
|
||||
quarantineApi.unflag(decoded.trackId)
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchPlaylistAppend(payload: String): Boolean {
|
||||
private suspend fun dispatchPlaylistAppend(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(PlaylistAppendPayload.serializer(), payload)
|
||||
return try {
|
||||
playlistsApi.appendTracks(
|
||||
playlistId = decoded.playlistId,
|
||||
body = AppendTracksRequest(trackIds = decoded.trackIds),
|
||||
)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: row stays queued; next drain pass retries.
|
||||
false
|
||||
}
|
||||
playlistsApi.appendTracks(
|
||||
playlistId = decoded.playlistId,
|
||||
body = AppendTracksRequest(trackIds = decoded.trackIds),
|
||||
)
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchQuarantineFlag(payload: String): Boolean {
|
||||
private suspend fun dispatchQuarantineFlag(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(QuarantineFlagPayload.serializer(), payload)
|
||||
return try {
|
||||
quarantineApi.flag(
|
||||
FlagRequest(
|
||||
trackId = decoded.trackId,
|
||||
reason = decoded.reason,
|
||||
notes = decoded.notes,
|
||||
),
|
||||
)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: row stays queued; next drain pass retries.
|
||||
false
|
||||
}
|
||||
quarantineApi.flag(
|
||||
FlagRequest(
|
||||
trackId = decoded.trackId,
|
||||
reason = decoded.reason,
|
||||
notes = decoded.notes,
|
||||
),
|
||||
)
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchPlayOffline(payload: String): Boolean {
|
||||
private suspend fun dispatchPlayOffline(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(PlayOfflinePayload.serializer(), payload)
|
||||
return try {
|
||||
eventsApi.playOffline(
|
||||
PlayOfflineRequest(
|
||||
trackId = decoded.trackId,
|
||||
clientId = decoded.clientId,
|
||||
at = decoded.atIso,
|
||||
durationPlayedMs = decoded.durationPlayedMs,
|
||||
source = decoded.source,
|
||||
),
|
||||
)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: row stays queued; next drain pass retries.
|
||||
false
|
||||
}
|
||||
eventsApi.playOffline(
|
||||
PlayOfflineRequest(
|
||||
trackId = decoded.trackId,
|
||||
clientId = decoded.clientId,
|
||||
at = decoded.atIso,
|
||||
durationPlayedMs = decoded.durationPlayedMs,
|
||||
source = decoded.source,
|
||||
),
|
||||
)
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchRequestCancel(payload: String): Boolean {
|
||||
private suspend fun dispatchPlayEnded(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(PlayEndedPayload.serializer(), payload)
|
||||
eventsApi.playEnded(
|
||||
PlayEndedRequest(
|
||||
playEventId = decoded.playEventId,
|
||||
durationPlayedMs = decoded.durationPlayedMs,
|
||||
),
|
||||
)
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchRequestCancel(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(RequestCancelPayload.serializer(), payload)
|
||||
return try {
|
||||
requestsApi.cancel(decoded.requestId)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: row stays queued; next drain pass retries.
|
||||
false
|
||||
}
|
||||
requestsApi.cancel(decoded.requestId)
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||
playbackErrorsApi.report(
|
||||
PlaybackErrorReportRequest(
|
||||
trackId = decoded.trackId,
|
||||
kind = decoded.kind,
|
||||
detail = decoded.detail,
|
||||
clientId = decoded.clientId,
|
||||
),
|
||||
)
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val HTTP_CLIENT_ERR_MIN = 400
|
||||
const val HTTP_CLIENT_ERR_MAX = 499
|
||||
const val HTTP_TIMEOUT = 408
|
||||
const val HTTP_TOO_MANY = 429
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
+25
-1
@@ -2,6 +2,8 @@ package com.fabledsword.minstrel.cache.sync
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.SyncApi
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||
@@ -16,8 +18,11 @@ import com.fabledsword.minstrel.models.wire.SyncArtistWire
|
||||
import com.fabledsword.minstrel.models.wire.SyncTrackWire
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.datetime.Clock
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
@@ -50,10 +55,15 @@ class SyncController @Inject constructor(
|
||||
private val trackDao: CachedTrackDao,
|
||||
private val authStore: AuthStore,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
networkStatus: NetworkStatusController,
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val api: SyncApi = retrofit.create()
|
||||
|
||||
// Single in-flight guard: the cookie + health-recovery triggers (and a
|
||||
// pull-to-refresh) can fire concurrently; coalesce them into one pass.
|
||||
private val mutex = Mutex()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
authStore.sessionCookie
|
||||
@@ -61,11 +71,25 @@ class SyncController @Inject constructor(
|
||||
.distinctUntilChanged()
|
||||
.collect { syncSafe() }
|
||||
}
|
||||
// Re-sync when the server becomes reachable again mid-session, so a
|
||||
// delta missed while offline lands without waiting for a cold start.
|
||||
scope.launch {
|
||||
networkStatus.state
|
||||
.map { it == ServerHealth.Healthy }
|
||||
.distinctUntilChanged()
|
||||
.filter { it }
|
||||
.collect { syncSafe() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Public entry-point for "Sync now" affordances. Swallows errors. */
|
||||
suspend fun syncSafe() {
|
||||
runCatching { sync() }
|
||||
if (!mutex.tryLock()) return
|
||||
try {
|
||||
runCatching { sync() }
|
||||
} finally {
|
||||
mutex.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sync() {
|
||||
|
||||
+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 -> ""
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ import javax.inject.Singleton
|
||||
* pre-warms the top artists via [HomeArtistPrewarmer].
|
||||
*/
|
||||
@Singleton
|
||||
// Per-section observe accessors (one per Home row) inflate the function
|
||||
// count past detekt's 11 default — accessor density, not complexity.
|
||||
@Suppress("TooManyFunctions")
|
||||
class HomeRepository @Inject constructor(
|
||||
private val homeIndexDao: CachedHomeIndexDao,
|
||||
private val metadataProvider: MetadataProvider,
|
||||
@@ -76,6 +79,12 @@ class HomeRepository @Inject constructor(
|
||||
fun observeLastPlayedArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
||||
observeArtistSection(SECTION_LAST_PLAYED_ARTISTS)
|
||||
|
||||
fun observeYouMightLikeAlbums(): Flow<List<HomeTile<AlbumRef>>> =
|
||||
observeAlbumSection(SECTION_YOU_MIGHT_LIKE_ALBUMS)
|
||||
|
||||
fun observeYouMightLikeArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
||||
observeArtistSection(SECTION_YOU_MIGHT_LIKE_ARTISTS)
|
||||
|
||||
/**
|
||||
* Pulls /api/home/index, replaces each cached_home_index section,
|
||||
* and pre-warms the top artists. The section Flows re-fire on the
|
||||
@@ -88,7 +97,11 @@ class HomeRepository @Inject constructor(
|
||||
replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists)
|
||||
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks)
|
||||
replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists)
|
||||
prewarmer.warm(wire.rediscoverArtists + wire.lastPlayedArtists)
|
||||
replaceSection(SECTION_YOU_MIGHT_LIKE_ALBUMS, "album", wire.youMightLikeAlbums)
|
||||
replaceSection(SECTION_YOU_MIGHT_LIKE_ARTISTS, "artist", wire.youMightLikeArtists)
|
||||
prewarmer.warm(
|
||||
wire.rediscoverArtists + wire.lastPlayedArtists + wire.youMightLikeArtists,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun replaceSection(section: String, entityType: String, ids: List<String>) {
|
||||
@@ -148,5 +161,7 @@ class HomeRepository @Inject constructor(
|
||||
const val SECTION_REDISCOVER_ARTISTS = "rediscover_artists"
|
||||
const val SECTION_MOST_PLAYED_TRACKS = "most_played_tracks"
|
||||
const val SECTION_LAST_PLAYED_ARTISTS = "last_played_artists"
|
||||
const val SECTION_YOU_MIGHT_LIKE_ALBUMS = "you_might_like_albums"
|
||||
const val SECTION_YOU_MIGHT_LIKE_ARTISTS = "you_might_like_artists"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -117,6 +116,8 @@ data class HomeSections(
|
||||
val rediscoverArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
||||
val mostPlayedTracks: List<HomeTile<TrackRef>> = emptyList(),
|
||||
val lastPlayedArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
||||
val youMightLikeAlbums: List<HomeTile<AlbumRef>> = emptyList(),
|
||||
val youMightLikeArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
||||
) {
|
||||
val isAllEmpty: Boolean
|
||||
get() = playlists.isEmpty() &&
|
||||
@@ -124,7 +125,9 @@ data class HomeSections(
|
||||
rediscoverAlbums.isEmpty() &&
|
||||
rediscoverArtists.isEmpty() &&
|
||||
mostPlayedTracks.isEmpty() &&
|
||||
lastPlayedArtists.isEmpty()
|
||||
lastPlayedArtists.isEmpty() &&
|
||||
youMightLikeAlbums.isEmpty() &&
|
||||
youMightLikeArtists.isEmpty()
|
||||
}
|
||||
|
||||
// ─── ViewModel ───────────────────────────────────────────────────────
|
||||
@@ -136,7 +139,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 +147,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,37 +266,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
|
||||
}
|
||||
val source = if (playlist.refreshable) "playlist:${playlist.systemVariant}" else null
|
||||
player.setQueue(tracks, initialIndex = 0, source = source)
|
||||
}.join()
|
||||
}
|
||||
|
||||
@@ -314,13 +293,14 @@ class HomeViewModel @Inject constructor(
|
||||
combineHomeFlows().asCacheFirstStateFlow(viewModelScope)
|
||||
|
||||
/**
|
||||
* Five home-section flows merged into the sections struct without
|
||||
* playlists; then merged with the playlists flow in [combineHomeFlows].
|
||||
* Splitting it like this keeps each combine call inside the
|
||||
* coroutines built-in arity limit (5) and avoids the typed-vararg
|
||||
* acrobatics needed for a 6-flow combine across heterogeneous types.
|
||||
* The core five section flows merged into the sections struct. Split
|
||||
* out so each combine call stays inside the coroutines built-in arity
|
||||
* limit (5) and avoids the typed-vararg acrobatics a wider combine
|
||||
* across heterogeneous types would need. [observeHomeSections] then
|
||||
* folds in the two you-might-like flows, and [combineHomeFlows] the
|
||||
* playlists flow.
|
||||
*/
|
||||
private fun observeHomeSections() = combine(
|
||||
private fun observeCoreSections() = combine(
|
||||
homeRepository.observeRecentlyAddedAlbums(),
|
||||
homeRepository.observeRediscoverAlbums(),
|
||||
homeRepository.observeRediscoverArtists(),
|
||||
@@ -336,6 +316,14 @@ class HomeViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private fun observeHomeSections() = combine(
|
||||
observeCoreSections(),
|
||||
homeRepository.observeYouMightLikeAlbums(),
|
||||
homeRepository.observeYouMightLikeArtists(),
|
||||
) { core, ymlAlbums, ymlArtists ->
|
||||
core.copy(youMightLikeAlbums = ymlAlbums, youMightLikeArtists = ymlArtists)
|
||||
}
|
||||
|
||||
private fun combineHomeFlows() =
|
||||
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
|
||||
val merged = sections.copy(playlists = playlists)
|
||||
@@ -488,6 +476,14 @@ private fun HomeSuccessContent(
|
||||
onPlayPlaylist = onPlayPlaylist,
|
||||
)
|
||||
}
|
||||
youMightLikeSection(
|
||||
albums = sections.youMightLikeAlbums,
|
||||
artists = sections.youMightLikeArtists,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
onPlayAlbum = onPlayAlbum,
|
||||
onPlayArtist = onPlayArtist,
|
||||
)
|
||||
recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick, onPlayAlbum)
|
||||
rediscoverSection(
|
||||
albums = sections.rediscoverAlbums,
|
||||
@@ -692,6 +688,61 @@ private fun RediscoverBlock(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "You might like" — in-library albums/artists predicted from the user's
|
||||
* taste that they don't actively spin (server section, daily-built, cold-
|
||||
* start gated). Sits directly under the system-playlists row. Same
|
||||
* albums-then-artists block shape as Rediscover; the artist sub-row drops
|
||||
* its title when an album sub-row precedes it so the two read as one section.
|
||||
*/
|
||||
private fun LazyListScope.youMightLikeSection(
|
||||
albums: List<HomeTile<AlbumRef>>,
|
||||
artists: List<HomeTile<ArtistRef>>,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
onArtistClick: (String) -> Unit,
|
||||
onPlayAlbum: suspend (String) -> Unit,
|
||||
onPlayArtist: suspend (String) -> Unit,
|
||||
) {
|
||||
item {
|
||||
if (albums.isNotEmpty() || artists.isNotEmpty()) {
|
||||
YouMightLikeBlock(
|
||||
albums = albums,
|
||||
artists = artists,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
onPlayAlbum = onPlayAlbum,
|
||||
onPlayArtist = onPlayArtist,
|
||||
)
|
||||
} else {
|
||||
EmptySection(
|
||||
title = "You might like",
|
||||
body = "We're still learning your taste — keep listening and " +
|
||||
"picks you might like will show up here.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun YouMightLikeBlock(
|
||||
albums: List<HomeTile<AlbumRef>>,
|
||||
artists: List<HomeTile<ArtistRef>>,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
onArtistClick: (String) -> Unit,
|
||||
onPlayAlbum: suspend (String) -> Unit,
|
||||
onPlayArtist: suspend (String) -> Unit,
|
||||
) {
|
||||
Column {
|
||||
if (albums.isNotEmpty()) {
|
||||
AlbumsRow("You might like", albums, onAlbumClick, onPlayAlbum)
|
||||
}
|
||||
if (artists.isNotEmpty()) {
|
||||
val title = if (albums.isEmpty()) "You might like" else ""
|
||||
ArtistsRow(title, artists, onArtistClick, onPlayArtist)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlbumsRow(
|
||||
title: String,
|
||||
@@ -731,18 +782,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,
|
||||
@@ -758,7 +810,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
|
||||
@@ -771,55 +823,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
|
||||
|
||||
@@ -109,8 +109,11 @@ private fun AlbumDetailStateContent(
|
||||
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
|
||||
navController: NavHostController,
|
||||
) {
|
||||
Crossfade(targetState = state, label = "album-detail") { s ->
|
||||
when (s) {
|
||||
// Crossfade keyed on `state::class` so a Success → fresh Success
|
||||
// refresh (same kind, new detail) doesn't re-fade the whole body;
|
||||
// only Loading ↔ Success ↔ Error transitions animate.
|
||||
Crossfade(targetState = state::class, label = "album-detail") { _ ->
|
||||
when (val s = state) {
|
||||
is AlbumDetailUiState.Loading ->
|
||||
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
|
||||
is AlbumDetailUiState.Error -> EmptyState(
|
||||
|
||||
+1
-3
@@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
@@ -56,8 +55,7 @@ class AlbumDetailViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
val likedTrackIds: StateFlow<Set<String>> =
|
||||
likes.observeLikedTracks()
|
||||
.map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } }
|
||||
likes.observeLikedTrackIds()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
|
||||
+121
-15
@@ -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() }
|
||||
@@ -87,8 +96,11 @@ fun ArtistDetailScreen(
|
||||
onRefresh = { viewModel.refresh().join() },
|
||||
modifier = Modifier.fillMaxSize().padding(inner),
|
||||
) {
|
||||
Crossfade(targetState = state, label = "artist-detail") { s ->
|
||||
when (s) {
|
||||
// Crossfade keyed on `state::class` so a Success → fresh
|
||||
// Success refresh (same kind, new detail) doesn't re-fade
|
||||
// the body; only Loading ↔ Success ↔ Error animate.
|
||||
Crossfade(targetState = state::class, label = "artist-detail") { _ ->
|
||||
when (val s = state) {
|
||||
is ArtistDetailUiState.Loading ->
|
||||
if (s.seed != null) {
|
||||
SeededArtistLoading(s.seed)
|
||||
@@ -99,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"
|
||||
@@ -124,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(),
|
||||
@@ -146,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()
|
||||
@@ -158,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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ package com.fabledsword.minstrel.library.ui
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -12,6 +11,8 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -22,11 +23,10 @@ import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
@@ -69,8 +69,12 @@ fun LibraryScreen(
|
||||
navController: NavHostController,
|
||||
viewModel: LibraryViewModel = hiltViewModel(),
|
||||
) {
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
val tabs = LIBRARY_TABS
|
||||
// HorizontalPager owns the active-tab state — the TabRow reads
|
||||
// pagerState.currentPage and animateScrollToPage drives it on tap,
|
||||
// so swipe and tap stay in sync.
|
||||
val pagerState = rememberPagerState(pageCount = { tabs.size })
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -91,13 +95,13 @@ fun LibraryScreen(
|
||||
},
|
||||
)
|
||||
PrimaryScrollableTabRow(
|
||||
selectedTabIndex = selectedTab,
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
edgePadding = 16.dp,
|
||||
) {
|
||||
tabs.forEachIndexed { index, label ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
selected = pagerState.currentPage == index,
|
||||
onClick = { scope.launch { pagerState.animateScrollToPage(index) } },
|
||||
text = { Text(label) },
|
||||
)
|
||||
}
|
||||
@@ -105,8 +109,11 @@ fun LibraryScreen(
|
||||
}
|
||||
},
|
||||
) { inner ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
|
||||
when (selectedTab) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize().padding(inner),
|
||||
) { page ->
|
||||
when (page) {
|
||||
TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController)
|
||||
TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController)
|
||||
TAB_HISTORY -> HistoryTab(
|
||||
@@ -135,8 +142,13 @@ private fun ArtistsTab(
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
|
||||
Crossfade(targetState = state, label = "library-artists") { s ->
|
||||
when (s) {
|
||||
// Crossfade is keyed on `state::class` (not the whole state)
|
||||
// so a refresh that emits a fresh UiState.Success — same kind,
|
||||
// new data — doesn't re-fade the entire grid. Only Loading ↔
|
||||
// Success ↔ Error ↔ Empty transitions animate; row-level diff
|
||||
// is owned by LazyVerticalGrid via its item keys.
|
||||
Crossfade(targetState = state::class, label = "library-artists") { _ ->
|
||||
when (val s = state) {
|
||||
UiState.Loading -> SkeletonArtistsGrid()
|
||||
UiState.Empty -> EmptyState(
|
||||
title = "No artists yet",
|
||||
@@ -163,8 +175,8 @@ private fun AlbumsTab(
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
PullToRefreshScaffold(onRefresh = { viewModel.refresh().join() }) {
|
||||
Crossfade(targetState = state, label = "library-albums") { s ->
|
||||
when (s) {
|
||||
Crossfade(targetState = state::class, label = "library-albums") { _ ->
|
||||
when (val s = state) {
|
||||
UiState.Loading -> SkeletonAlbumsGrid()
|
||||
UiState.Empty -> EmptyState(
|
||||
title = "No albums yet",
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
package com.fabledsword.minstrel.likes.data
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.LikesApi
|
||||
import com.fabledsword.minstrel.auth.AuthController
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.library.data.toDomain
|
||||
import com.fabledsword.minstrel.models.AlbumRef
|
||||
import com.fabledsword.minstrel.models.ArtistRef
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import javax.inject.Inject
|
||||
@@ -24,11 +28,14 @@ import javax.inject.Singleton
|
||||
* Flutter `likesControllerProvider` + `cached_likes`-driven Liked tab
|
||||
* pattern.
|
||||
*
|
||||
* Local `userId` discriminator is a constant for now (`LOCAL_USER_ID`)
|
||||
* because the AuthController hookup lands with Phase 11. Single-user
|
||||
* device is the only shape we target; if multi-tenant on one device
|
||||
* ever shows up, swap this constant for `AuthStore.userId.value`
|
||||
* everywhere and migrate the cached_likes rows.
|
||||
* Local `userId` discriminator is the server-side user UUID
|
||||
* (resolved via [AuthController.currentUser]); falls back to
|
||||
* [ANONYMOUS_USER_ID] when no user is signed in so existing local
|
||||
* cache rows from pre-#576 builds remain queryable until the first
|
||||
* authenticated [refreshIds] call overwrites them. On user-switch
|
||||
* (sign-out / sign-in as different user) the OUTGOING user's
|
||||
* cached_likes rows are wiped so they don't leak into the new
|
||||
* session — drift #576 audit caught the leak.
|
||||
*
|
||||
* Write path follows `feedback_offline_first_for_server_writes` —
|
||||
* never fire-and-forget:
|
||||
@@ -48,33 +55,78 @@ class LikesRepository @Inject constructor(
|
||||
private val artistDao: CachedArtistDao,
|
||||
private val trackDao: CachedTrackDao,
|
||||
private val mutationQueue: MutationQueue,
|
||||
private val authController: AuthController,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val api: LikesApi = retrofit.create()
|
||||
|
||||
init {
|
||||
// Drift #576: clear the outgoing user's cached_likes rows
|
||||
// when the signed-in user changes. Mostly a hygiene fix —
|
||||
// discriminator-based queries already prevent the wrong
|
||||
// user from SEEING leaked rows, but without this the rows
|
||||
// pile up forever as different accounts sign in/out on the
|
||||
// same device.
|
||||
scope.launch {
|
||||
var previousId: String? = null
|
||||
authController.currentUser.collect { user ->
|
||||
val newId = user?.id
|
||||
val prev = previousId
|
||||
if (prev != null && prev != newId) {
|
||||
likeDao.clearForUser(prev)
|
||||
}
|
||||
previousId = newId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-user-uuid for the current session, or [ANONYMOUS_USER_ID]
|
||||
* fallback when there's no signed-in user. Used as the
|
||||
* `cached_likes.userId` discriminator so each account's likes are
|
||||
* stored under its own bucket.
|
||||
*/
|
||||
private fun currentUserId(): String =
|
||||
authController.currentUser.value?.id ?: ANONYMOUS_USER_ID
|
||||
|
||||
// ── Reads ─────────────────────────────────────────────────────────
|
||||
|
||||
fun observeLikedArtists(): Flow<List<ArtistRef>> =
|
||||
likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_ARTIST).map { ids ->
|
||||
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_ARTIST).map { ids ->
|
||||
ids.mapNotNull { artistDao.getById(it)?.toDomain() }
|
||||
}
|
||||
|
||||
fun observeLikedAlbums(): Flow<List<AlbumRef>> =
|
||||
likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_ALBUM).map { ids ->
|
||||
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_ALBUM).map { ids ->
|
||||
ids.mapNotNull { albumDao.getById(it)?.toDomain() }
|
||||
}
|
||||
|
||||
fun observeLikedTracks(): Flow<List<TrackRef>> =
|
||||
likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_TRACK).map { ids ->
|
||||
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).map { ids ->
|
||||
ids.mapNotNull { trackDao.getById(it)?.toDomain() }
|
||||
}
|
||||
|
||||
fun observeIsLiked(entityType: String, entityId: String): Flow<Boolean> =
|
||||
likeDao.observeIsLiked(LOCAL_USER_ID, entityType, entityId)
|
||||
likeDao.observeIsLiked(currentUserId(), entityType, entityId)
|
||||
|
||||
/**
|
||||
* Reactive set of liked track ids. Use this when the caller only
|
||||
* needs "is X in liked set", NOT when it needs to render the liked
|
||||
* tracks themselves — [observeLikedTracks] does a `mapNotNull` join
|
||||
* against `trackDao` and drops any liked id whose track isn't in
|
||||
* local cache yet, so an "is liked" UI built on `observeLikedTracks`
|
||||
* misses cross-device likes whose track row hasn't cached.
|
||||
*
|
||||
* Used by playlist/album detail screens to color a row's like
|
||||
* button independent of whether the track is in the local library.
|
||||
*/
|
||||
fun observeLikedTrackIds(): Flow<Set<String>> =
|
||||
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).map { it.toSet() }
|
||||
|
||||
/** One-shot snapshot of the liked track-id set — for the offline pool filter. */
|
||||
suspend fun likedTrackIds(): Set<String> =
|
||||
likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_TRACK).first().toSet()
|
||||
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).first().toSet()
|
||||
|
||||
// ── Writes (optimistic local → best-effort REST → enqueue on fail) ──
|
||||
|
||||
@@ -85,13 +137,15 @@ class LikesRepository @Inject constructor(
|
||||
* if it got enqueued for later replay.
|
||||
*/
|
||||
suspend fun toggleLike(entityType: String, entityId: String, desiredState: Boolean): Boolean {
|
||||
// 1. Optimistic Room mutation.
|
||||
// 1. Optimistic Room mutation — scoped to the signed-in user
|
||||
// (or the legacy "local" fallback for pre-#576 rows).
|
||||
val uid = currentUserId()
|
||||
if (desiredState) {
|
||||
likeDao.upsertAll(
|
||||
listOf(CachedLikeEntity(LOCAL_USER_ID, entityType, entityId)),
|
||||
listOf(CachedLikeEntity(uid, entityType, entityId)),
|
||||
)
|
||||
} else {
|
||||
likeDao.delete(LOCAL_USER_ID, entityType, entityId)
|
||||
likeDao.delete(uid, entityType, entityId)
|
||||
}
|
||||
// 2. Best-effort REST call; 3. enqueue on failure.
|
||||
val kindPath = serverPathFor(entityType)
|
||||
@@ -111,24 +165,38 @@ class LikesRepository @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls `GET /api/likes/ids` and reconciles `cached_likes`. Called
|
||||
* by the LikedTab ViewModel on init (and by the SyncController in
|
||||
* Phase 12). Adds rows for IDs the server has but we don't; the
|
||||
* delete-of-stale-rows pass lives in the SyncController where the
|
||||
* full reconciliation runs.
|
||||
* Pulls `GET /api/likes/ids` and atomically replaces the user's
|
||||
* cached_likes set with the server's canonical view. Called by
|
||||
* the LikedTab ViewModel on init (and by the SyncController in
|
||||
* Phase 12).
|
||||
*
|
||||
* Drift #570 fix: previously this called `upsertAll` only, which
|
||||
* added rows for IDs the server had but never removed rows the
|
||||
* server no longer surfaced — so a cross-device unlike (user
|
||||
* likes on web, then unlikes on web) left the Liked tab on
|
||||
* Android showing the now-unliked entry forever. The atomic
|
||||
* replaceAllForUser DAO method runs a transactional delete-then-
|
||||
* insert so the local set is exactly what the server reports.
|
||||
*/
|
||||
suspend fun refreshIds() {
|
||||
val uid = currentUserId()
|
||||
val wire = api.ids()
|
||||
val rows = mutableListOf<CachedLikeEntity>()
|
||||
rows += wire.artistIds.map { CachedLikeEntity(LOCAL_USER_ID, ENTITY_ARTIST, it) }
|
||||
rows += wire.albumIds.map { CachedLikeEntity(LOCAL_USER_ID, ENTITY_ALBUM, it) }
|
||||
rows += wire.trackIds.map { CachedLikeEntity(LOCAL_USER_ID, ENTITY_TRACK, it) }
|
||||
likeDao.upsertAll(rows)
|
||||
rows += wire.artistIds.map { CachedLikeEntity(uid, ENTITY_ARTIST, it) }
|
||||
rows += wire.albumIds.map { CachedLikeEntity(uid, ENTITY_ALBUM, it) }
|
||||
rows += wire.trackIds.map { CachedLikeEntity(uid, ENTITY_TRACK, it) }
|
||||
likeDao.replaceAllForUser(uid, rows)
|
||||
}
|
||||
|
||||
companion object {
|
||||
// TODO(Phase 11): swap for AuthStore.userId.value once auth lands.
|
||||
const val LOCAL_USER_ID: String = "local"
|
||||
// Pre-auth fallback discriminator. Drift #576: when no user is
|
||||
// signed in OR for cached_likes rows written by pre-#576
|
||||
// builds (which all tagged "local"), reads continue to query
|
||||
// this bucket so the Liked tab isn't suddenly empty after the
|
||||
// upgrade. The first authenticated refreshIds() call writes
|
||||
// rows under the real user UUID; reads then transparently
|
||||
// switch over via currentUserId().
|
||||
const val ANONYMOUS_USER_ID: String = "local"
|
||||
const val ENTITY_ARTIST: String = "artist"
|
||||
const val ENTITY_ALBUM: String = "album"
|
||||
const val ENTITY_TRACK: String = "track"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
@file:OptIn(ExperimentalSerializationApi::class)
|
||||
|
||||
package com.fabledsword.minstrel.models.wire
|
||||
|
||||
import kotlinx.serialization.EncodeDefault
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -15,7 +19,11 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PlayStartedRequest(
|
||||
val type: String = "play_started",
|
||||
// Discriminator MUST stay on the wire. The app's Json has
|
||||
// encodeDefaults=false, so without this a `type` left at its
|
||||
// default is omitted entirely and the server 400s the event as
|
||||
// "unknown event type" — silently dropping every native play.
|
||||
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_started",
|
||||
@SerialName("track_id") val trackId: String,
|
||||
@SerialName("client_id") val clientId: String,
|
||||
val source: String? = null,
|
||||
@@ -28,21 +36,21 @@ data class PlayStartedResponse(
|
||||
|
||||
@Serializable
|
||||
data class PlayEndedRequest(
|
||||
val type: String = "play_ended",
|
||||
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_ended",
|
||||
@SerialName("play_event_id") val playEventId: String,
|
||||
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlaySkippedRequest(
|
||||
val type: String = "play_skipped",
|
||||
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_skipped",
|
||||
@SerialName("play_event_id") val playEventId: String,
|
||||
@SerialName("position_ms") val positionMs: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlayOfflineRequest(
|
||||
val type: String = "play_offline",
|
||||
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_offline",
|
||||
@SerialName("track_id") val trackId: String,
|
||||
@SerialName("client_id") val clientId: String,
|
||||
val at: String,
|
||||
|
||||
@@ -10,13 +10,17 @@ import kotlinx.serialization.Serializable
|
||||
* `internal/api/types.go HomeIndexPayload`).
|
||||
*
|
||||
* Section name implies entity type; no per-entry type tag is needed:
|
||||
* - recentlyAddedAlbums → album
|
||||
* - rediscoverAlbums → album
|
||||
* - rediscoverArtists → artist
|
||||
* - mostPlayedTracks → track
|
||||
* - lastPlayedArtists → artist
|
||||
* - recentlyAddedAlbums → album
|
||||
* - rediscoverAlbums → album
|
||||
* - rediscoverArtists → artist
|
||||
* - mostPlayedTracks → track
|
||||
* - lastPlayedArtists → artist
|
||||
* - youMightLikeAlbums → album
|
||||
* - youMightLikeArtists → artist
|
||||
*
|
||||
* All slices default to empty so missing keys don't throw on decode.
|
||||
* All slices default to empty so missing keys don't throw on decode —
|
||||
* which also keeps the client safe against older servers that don't
|
||||
* emit the you-might-like sections yet.
|
||||
*/
|
||||
@Serializable
|
||||
data class HomeIndexWire(
|
||||
@@ -25,4 +29,6 @@ data class HomeIndexWire(
|
||||
@SerialName("rediscover_artists") val rediscoverArtists: List<String> = emptyList(),
|
||||
@SerialName("most_played_tracks") val mostPlayedTracks: List<String> = emptyList(),
|
||||
@SerialName("last_played_artists") val lastPlayedArtists: List<String> = emptyList(),
|
||||
@SerialName("you_might_like_albums") val youMightLikeAlbums: List<String> = emptyList(),
|
||||
@SerialName("you_might_like_artists") val youMightLikeArtists: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.SessionCommand
|
||||
import androidx.media3.session.SessionResult
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* MediaSession callback that adds the like/heart custom command to
|
||||
* every connecting controller (notification, lock screen, Pixel Watch,
|
||||
* Android Auto) and routes taps through [LikesRepository.toggleLike]
|
||||
* so they inherit the offline-resilient MutationQueue path.
|
||||
*
|
||||
* The [onConnect] grant is REQUIRED — without it the button is
|
||||
* silently invisible on some surfaces (Media3 issue #2679). See the
|
||||
* spec at docs/superpowers/specs/2026-06-02-android-media3-like-button-design.md.
|
||||
*
|
||||
* Suspend work in [onCustomCommand] is launched on [scope] (a
|
||||
* service-lifetime scope) so the callback returns synchronously and
|
||||
* the controller does not block on Room + REST. State updates flow
|
||||
* back through [LikesRepository.observeIsLiked] so the icon refreshes
|
||||
* via the like-state job in [MinstrelPlayerService].
|
||||
*/
|
||||
class LikeMediaCallback(
|
||||
private val likes: LikesRepository,
|
||||
private val scope: CoroutineScope,
|
||||
) : MediaSession.Callback {
|
||||
|
||||
override fun onConnect(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
): MediaSession.ConnectionResult {
|
||||
val grants = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS
|
||||
.buildUpon()
|
||||
.add(SessionCommand(CMD_TOGGLE_LIKE, Bundle.EMPTY))
|
||||
.build()
|
||||
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
|
||||
.setAvailableSessionCommands(grants)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onCustomCommand(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
customCommand: SessionCommand,
|
||||
args: Bundle,
|
||||
): ListenableFuture<SessionResult> {
|
||||
val code = if (customCommand.customAction == CMD_TOGGLE_LIKE) {
|
||||
launchToggleForCurrent(session)
|
||||
} else {
|
||||
SessionResult.RESULT_ERROR_NOT_SUPPORTED
|
||||
}
|
||||
return Futures.immediateFuture(SessionResult(code))
|
||||
}
|
||||
|
||||
private fun launchToggleForCurrent(session: MediaSession): Int {
|
||||
val mediaId = session.player.currentMediaItem?.mediaId
|
||||
?: return SessionResult.RESULT_INFO_SKIPPED
|
||||
scope.launch {
|
||||
val current = likes.observeIsLiked(ENTITY_TRACK, mediaId).first()
|
||||
likes.toggleLike(ENTITY_TRACK, mediaId, !current)
|
||||
}
|
||||
return SessionResult.RESULT_SUCCESS
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Custom-action key for the like/heart button. Namespaced so
|
||||
* future custom commands (output picker, etc.) don't collide. */
|
||||
const val CMD_TOGGLE_LIKE: String = "minstrel.toggle_like"
|
||||
}
|
||||
}
|
||||
+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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+148
-5
@@ -1,10 +1,30 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.CommandButton
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import androidx.media3.session.SessionCommand
|
||||
import com.fabledsword.minstrel.MainActivity
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK
|
||||
import com.google.common.collect.ImmutableList
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
@@ -18,35 +38,150 @@ import javax.inject.Inject
|
||||
* MediaButtonReceiver is registered automatically by the library, no
|
||||
* manual receiver class needed.
|
||||
*
|
||||
* The session advertises a single custom CommandButton — the like/heart
|
||||
* toggle. [LikeMediaCallback] grants the command in `onConnect` and
|
||||
* routes taps through [LikesRepository.toggleLike] so they inherit the
|
||||
* offline-resilient MutationQueue path. The icon mirrors server state:
|
||||
* a service-scoped coroutine collects `currentMediaItem × isLiked` and
|
||||
* rebuilds the preferences list on each emission so a cross-device
|
||||
* like (web tap) flips the notification heart automatically.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - onCreate: build the ExoPlayer + MediaSession once.
|
||||
* - onCreate: build the ExoPlayer + MediaSession once, attach the
|
||||
* callback, set initial preferences, launch the like-state job.
|
||||
* - onGetSession: return the live session to any binding controller
|
||||
* (system UI media card, Wear OS companion, MediaController3 clients).
|
||||
* - onTaskRemoved: if the user swipes the app away, keep playing when
|
||||
* audio is active (standard media-app behavior — music shouldn't
|
||||
* die because the app left recents); otherwise stop the service so
|
||||
* the lingering notification clears.
|
||||
* - onDestroy: release the session + player.
|
||||
* - onDestroy: cancel the service scope, then release the session +
|
||||
* player. Cancellation comes first so the like-state job doesn't
|
||||
* touch a released session.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class MinstrelPlayerService : MediaSessionService() {
|
||||
|
||||
@Inject lateinit var playerFactory: PlayerFactory
|
||||
|
||||
@Inject lateinit var likesRepository: LikesRepository
|
||||
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
private var mediaSession: MediaSession? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val player = playerFactory.build()
|
||||
mediaSession = MediaSession.Builder(this, player).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
|
||||
serviceScope.launch { observeLikeState(session, player) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Intent that the media notification / lock-screen card / Pixel
|
||||
* Watch tile launches when tapped. Carries an extra MainActivity
|
||||
* consumes to navigate the NavController straight to the full
|
||||
* NowPlaying screen — without it, Media3 defaults to the launcher
|
||||
* activity entry point, which lands on whatever shell route
|
||||
* MainActivity last rendered.
|
||||
*
|
||||
* FLAG_ACTIVITY_SINGLE_TOP keeps the existing MainActivity instance
|
||||
* alive when one is already foregrounded; onNewIntent fires and
|
||||
* picks up the fresh extra. FLAG_IMMUTABLE is required on
|
||||
* Android 12+; FLAG_UPDATE_CURRENT keeps the extra in sync if the
|
||||
* Builder rebuilds the PendingIntent.
|
||||
*/
|
||||
private fun buildNowPlayingPendingIntent(): PendingIntent {
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
putExtra(MainActivity.EXTRA_OPEN_NOW_PLAYING, true)
|
||||
}
|
||||
return PendingIntent.getActivity(
|
||||
this,
|
||||
REQUEST_OPEN_NOW_PLAYING,
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the like/heart CommandButton. Icon flips between
|
||||
* ICON_HEART_FILLED and ICON_HEART_UNFILLED to mirror server-side
|
||||
* like state. The session command is the same in both states so
|
||||
* the callback's onCustomCommand handles them identically (it
|
||||
* inverts the current observed state regardless of which icon was
|
||||
* tapped).
|
||||
*/
|
||||
private fun buildLikeButton(isLiked: Boolean): CommandButton {
|
||||
val icon = if (isLiked) {
|
||||
CommandButton.ICON_HEART_FILLED
|
||||
} else {
|
||||
CommandButton.ICON_HEART_UNFILLED
|
||||
}
|
||||
return CommandButton.Builder(icon)
|
||||
.setDisplayName(if (isLiked) "Unlike" else "Like")
|
||||
.setSessionCommand(SessionCommand(LikeMediaCallback.CMD_TOGGLE_LIKE, Bundle.EMPTY))
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect player.currentMediaItem changes (via a Player.Listener
|
||||
* lifted into a Flow) and, for each non-null mediaId, observe its
|
||||
* liked state. On every emission, rebuild the session's media
|
||||
* button preferences with the icon flipped accordingly.
|
||||
* flatMapLatest cancels the previous track's subscription so we
|
||||
* never leak Flows across track transitions.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private suspend fun observeLikeState(session: MediaSession, player: Player) {
|
||||
currentMediaIdFlow(player)
|
||||
.flatMapLatest { mediaId ->
|
||||
if (mediaId == null) {
|
||||
flowOf(false)
|
||||
} else {
|
||||
likesRepository.observeIsLiked(ENTITY_TRACK, mediaId)
|
||||
}
|
||||
}
|
||||
.collect { isLiked ->
|
||||
session.setMediaButtonPreferences(
|
||||
ImmutableList.of(buildLikeButton(isLiked = isLiked)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift Player.currentMediaItem changes into a Flow. Emits the
|
||||
* current mediaId on subscription so the initial icon state is
|
||||
* correct on the first frame the controller renders (no
|
||||
* unfilled-then-flips flicker).
|
||||
*/
|
||||
private fun currentMediaIdFlow(player: Player) = callbackFlow<String?> {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onMediaItemTransition(item: MediaItem?, reason: Int) {
|
||||
trySend(item?.mediaId)
|
||||
}
|
||||
}
|
||||
player.addListener(listener)
|
||||
awaitClose { player.removeListener(listener) }
|
||||
}.onStart { emit(player.currentMediaItem?.mediaId) }
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? =
|
||||
mediaSession
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -54,6 +189,7 @@ class MinstrelPlayerService : MediaSessionService() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
serviceScope.cancel()
|
||||
mediaSession?.run {
|
||||
player.release()
|
||||
release()
|
||||
@@ -61,4 +197,11 @@ class MinstrelPlayerService : MediaSessionService() {
|
||||
mediaSession = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// Stable request code for the session-activity PendingIntent.
|
||||
// Arbitrary but distinct from any other PendingIntent we ever
|
||||
// build against MainActivity.
|
||||
const val REQUEST_OPEN_NOW_PLAYING = 0x100
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.fabledsword.minstrel.api.endpoints.EventsApi
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||
import com.fabledsword.minstrel.cache.mutations.PlayEndedPayload
|
||||
import com.fabledsword.minstrel.cache.mutations.PlayOfflinePayload
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
||||
@@ -64,11 +65,24 @@ class PlayEventsReporter @Inject constructor(
|
||||
private var curLastPositionMs: Long = 0
|
||||
private var curDurationMs: Long = 0
|
||||
|
||||
// Whether the currently-tracked play is actively playing (vs paused).
|
||||
// onStop reads this: a backgrounded play that's still playing is left
|
||||
// to the live path (the foreground service keeps us alive), while a
|
||||
// paused one is durably closed so a kill can't orphan its open row.
|
||||
private var curPlaying: Boolean = false
|
||||
|
||||
// Server play_event_id when the live play_started succeeded;
|
||||
// null if start failed/offline — then the close is captured into
|
||||
// the offline mutation queue instead of a live ended/skipped.
|
||||
private var openPlayEventId: String? = null
|
||||
|
||||
// True from beginTrack until startLive's play_started resolves. When a
|
||||
// close races an in-flight start (id still null), the server row is
|
||||
// being created but we've lost its id — we must NOT also enqueue an
|
||||
// offline play (that would duplicate); the server auto-closes the
|
||||
// orphan on the next play_started instead.
|
||||
private var startInFlight: Boolean = false
|
||||
|
||||
private var prevTrackId: String? = null
|
||||
|
||||
init {
|
||||
@@ -95,7 +109,7 @@ class PlayEventsReporter @Inject constructor(
|
||||
) {
|
||||
// Track-change → close the prior tracked play.
|
||||
if (trackId != prevTrackId && curTrackId != null) {
|
||||
closeCurrent(viaOffline = false)
|
||||
closeCurrent()
|
||||
}
|
||||
|
||||
// Entered playing for a new track → begin tracking it.
|
||||
@@ -110,6 +124,7 @@ class PlayEventsReporter @Inject constructor(
|
||||
if (curTrackId != null && trackId == curTrackId) {
|
||||
curLastPositionMs = positionMs
|
||||
if (durationMs > 0) curDurationMs = durationMs
|
||||
curPlaying = playing
|
||||
}
|
||||
|
||||
prevTrackId = trackId
|
||||
@@ -121,12 +136,18 @@ class PlayEventsReporter @Inject constructor(
|
||||
curSource = source
|
||||
curLastPositionMs = 0
|
||||
curDurationMs = durationMs
|
||||
curPlaying = true
|
||||
openPlayEventId = null
|
||||
startInFlight = true
|
||||
startLive(trackId = trackId, source = source)
|
||||
}
|
||||
|
||||
private fun startLive(trackId: String, source: String?) {
|
||||
val cid = resolveClientId() ?: return
|
||||
val cid = resolveClientId()
|
||||
if (cid == null) {
|
||||
startInFlight = false
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
val response = api.playStarted(
|
||||
@@ -144,56 +165,72 @@ class PlayEventsReporter @Inject constructor(
|
||||
) {
|
||||
// Offline / flaky — openPlayEventId stays null; close
|
||||
// path will enqueue the completed play for replay.
|
||||
} finally {
|
||||
startInFlight = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeCurrent(viaOffline: Boolean) {
|
||||
/**
|
||||
* Closes the tracked play. Routes by whether the server already has an
|
||||
* open row, NOT by caller context — this is what keeps background /
|
||||
* track-change / failure paths from each minting a second row:
|
||||
* - open server row (id) → close it by id; on failure enqueue a durable
|
||||
* close-by-id (never an offline play).
|
||||
* - start in flight (no id yet) → leave it; the server auto-closes the
|
||||
* orphan on the next play_started. Enqueuing offline here would
|
||||
* duplicate the in-flight row.
|
||||
* - no server row at all → record the whole play offline.
|
||||
*
|
||||
* Always uses the actual position played; the server's skip rule
|
||||
* (50%/30s defaults) classifies was_skipped from it.
|
||||
*/
|
||||
private fun closeCurrent() {
|
||||
val trackId = curTrackId
|
||||
val startedAt = curStartedAt
|
||||
if (trackId == null || startedAt == null) {
|
||||
resetCurrent()
|
||||
return
|
||||
}
|
||||
// Always send the actual position played to; the server's
|
||||
// skip rule (50%/30s defaults, configurable) classifies
|
||||
// was_skipped from this. If the track ran to completion the
|
||||
// last observed position is ~= duration, which gives ratio ~1
|
||||
// and the server records a non-skip. The COMPLETION_TOLERANCE
|
||||
// rounding the prior shape did is no longer needed — the
|
||||
// server's threshold is the source of truth.
|
||||
val durationMs = curLastPositionMs
|
||||
val source = curSource
|
||||
val id = openPlayEventId
|
||||
|
||||
if (!viaOffline && id != null) {
|
||||
scope.launch {
|
||||
try {
|
||||
api.playEnded(
|
||||
PlayEndedRequest(
|
||||
playEventId = id,
|
||||
durationPlayedMs = durationMs,
|
||||
),
|
||||
)
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
enqueueOffline(trackId, startedAt, source, durationMs)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
enqueueOffline(trackId, startedAt, source, durationMs)
|
||||
val inFlight = startInFlight
|
||||
when {
|
||||
id != null -> closeById(id, durationMs)
|
||||
inFlight -> Unit
|
||||
else -> enqueueOffline(trackId, startedAt, source, durationMs)
|
||||
}
|
||||
resetCurrent()
|
||||
}
|
||||
|
||||
private fun closeById(id: String, durationMs: Long) {
|
||||
scope.launch {
|
||||
try {
|
||||
api.playEnded(
|
||||
PlayEndedRequest(playEventId = id, durationPlayedMs = durationMs),
|
||||
)
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Durable close-by-id (idempotent server-side) — survives a
|
||||
// process kill without orphaning the open row or duplicating.
|
||||
mutationQueue.enqueuePlayEnded(
|
||||
PlayEndedPayload(playEventId = id, durationPlayedMs = durationMs),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetCurrent() {
|
||||
curTrackId = null
|
||||
curStartedAt = null
|
||||
curSource = null
|
||||
curLastPositionMs = 0
|
||||
curDurationMs = 0
|
||||
curPlaying = false
|
||||
openPlayEventId = null
|
||||
startInFlight = false
|
||||
}
|
||||
|
||||
private fun enqueueOffline(
|
||||
@@ -232,8 +269,17 @@ class PlayEventsReporter @Inject constructor(
|
||||
// ── Lifecycle: durable-close on app background / detach ────────
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
if (curTrackId != null) {
|
||||
closeCurrent(viaOffline = true)
|
||||
// App backgrounded. If the tracked play is still PLAYING, leave it
|
||||
// alone: the foreground media service keeps the process — and this
|
||||
// app-scope uiState collector — alive, so the live state machine
|
||||
// closes the play accurately on the next real track change. Closing
|
||||
// here (the old always-offline behavior) raced the collector's
|
||||
// re-begin and minted a duplicate, skip-misclassified row on every
|
||||
// screen-lock. Only durably close a PAUSED play, where no further
|
||||
// progress will happen and a process kill would otherwise orphan the
|
||||
// open server row. See docs/superpowers/2026-06-11 contract audit.
|
||||
if (curTrackId != null && !curPlaying) {
|
||||
closeCurrent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+27
-13
@@ -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
|
||||
@@ -13,19 +14,23 @@ import javax.inject.Singleton
|
||||
private const val DEBOUNCE_MS = 2_000L
|
||||
|
||||
/**
|
||||
* Surfaces ExoPlayer track-load failures (404 / decoder failure /
|
||||
* premature EOS / network drop) as user-visible snackbar messages.
|
||||
* Surfaces playback failures (load errors + zero-duration tracks) as
|
||||
* user-visible snackbar messages AND admin-inbox reports.
|
||||
*
|
||||
* Without this the player just silently skips the dead track — the
|
||||
* worst kind of bug, because the user can't tell "this file is
|
||||
* broken" from "the app is flaky".
|
||||
* Without the snackbar the player just silently skips the dead track —
|
||||
* the worst kind of bug, because the user can't tell "this file is
|
||||
* broken" from "the app is flaky". Without the admin report the
|
||||
* operator never finds out the track is bad and the next user hits
|
||||
* the same wall.
|
||||
*
|
||||
* Pattern mirrors Flutter's `playback_error_reporter.dart`: collect
|
||||
* per-error events from [PlayerController.playbackErrorEvents],
|
||||
* debounce in a 2s window, and emit "Couldn't play 'X' — skipping"
|
||||
* for a single error or "Skipped N unplayable tracks" when a burst
|
||||
* lands inside the window. Stops the user from seeing a stack of N
|
||||
* toasts on a network blip that fails N tracks in a row.
|
||||
* The snackbar text mirrors Flutter's `playback_error_reporter.dart`:
|
||||
* collect [PlayerController.playbackErrorEvents], debounce in a 2s
|
||||
* window, emit "Couldn't play 'X' — skipping" for a single error or
|
||||
* "Skipped N unplayable tracks" when a burst lands inside the window.
|
||||
*
|
||||
* The server report fires per event (no debounce) via
|
||||
* [PlaybackErrorRepository], which handles the offline-first
|
||||
* MutationQueue fallback per the standing rule for server writes.
|
||||
*
|
||||
* ShellScaffold collects [messages] into its existing snackbar host
|
||||
* so the reporter doesn't need its own UI surface. Constructed at
|
||||
@@ -35,6 +40,8 @@ private const val DEBOUNCE_MS = 2_000L
|
||||
@Singleton
|
||||
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)
|
||||
@@ -46,8 +53,15 @@ class PlaybackErrorReporter @Inject constructor(
|
||||
scope.launch {
|
||||
val buffer = mutableListOf<String>()
|
||||
var debounceJob: kotlinx.coroutines.Job? = null
|
||||
playerController.playbackErrorEvents.collect { title ->
|
||||
buffer.add(title)
|
||||
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) }
|
||||
buffer.add(event.title)
|
||||
debounceJob?.cancel()
|
||||
debounceJob = scope.launch {
|
||||
delay(DEBOUNCE_MS)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||
import com.fabledsword.minstrel.cache.mutations.PlaybackErrorReportPayload
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Writes a [PlaybackErrorEvent] to the server. Follows the standing
|
||||
* offline-first rule for server writes: try the POST first, fall back
|
||||
* to the MutationQueue on transport failure so a tracked report is
|
||||
* never lost.
|
||||
*
|
||||
* Reused client_id comes from [AuthStore.clientId] — the same UUID-
|
||||
* per-install identifier the play-events reporter already sends, so
|
||||
* support can correlate playback errors with the surrounding plays.
|
||||
*/
|
||||
@Singleton
|
||||
class PlaybackErrorRepository @Inject constructor(
|
||||
private val authStore: AuthStore,
|
||||
private val mutationQueue: MutationQueue,
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val api: PlaybackErrorsApi = retrofit.create()
|
||||
|
||||
/**
|
||||
* Reports a playback failure. Returns [Outcome.ACCEPTED] on a 2xx,
|
||||
* [Outcome.QUEUED] when the call was buffered to the MutationQueue
|
||||
* for later replay. Never throws — callers don't need a try block.
|
||||
*/
|
||||
suspend fun report(event: PlaybackErrorEvent): Outcome {
|
||||
val clientId = resolveClientId()
|
||||
val payload = PlaybackErrorReportPayload(
|
||||
trackId = event.trackId,
|
||||
kind = event.kind,
|
||||
detail = event.detail,
|
||||
clientId = clientId,
|
||||
)
|
||||
return try {
|
||||
api.report(
|
||||
PlaybackErrorReportRequest(
|
||||
trackId = payload.trackId,
|
||||
kind = payload.kind,
|
||||
detail = payload.detail,
|
||||
clientId = payload.clientId,
|
||||
),
|
||||
)
|
||||
Outcome.ACCEPTED
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow — see LikesRepository.toggleLike for
|
||||
// the same offline-first rationale.
|
||||
mutationQueue.enqueuePlaybackErrorReport(payload)
|
||||
Outcome.QUEUED
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the persisted client id, generating + persisting one on
|
||||
* first read. Mirrors [PlayEventsReporter.resolveClientId] — the
|
||||
* value is shared across all client-id-carrying surfaces.
|
||||
*/
|
||||
private fun resolveClientId(): String {
|
||||
authStore.clientId.value?.let { return it }
|
||||
val fresh = UUID.randomUUID().toString()
|
||||
authStore.setClientId(fresh)
|
||||
return fresh
|
||||
}
|
||||
|
||||
enum class Outcome { ACCEPTED, QUEUED }
|
||||
}
|
||||
@@ -3,6 +3,10 @@ package com.fabledsword.minstrel.player
|
||||
import android.content.ComponentName
|
||||
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
|
||||
@@ -18,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
|
||||
@@ -57,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))
|
||||
|
||||
@@ -68,13 +84,32 @@ class PlayerController @Inject constructor(
|
||||
|
||||
/**
|
||||
* Per-error events for [com.fabledsword.minstrel.player.PlaybackErrorReporter].
|
||||
* Emits the failing track's title (or `"Track"` when unknown) each
|
||||
* time Media3's `onPlayerError` fires — not bound to the lifetime of
|
||||
* any UI subscriber so a torn-down screen doesn't drop events.
|
||||
* Conflated channel so backpressure can't stall the player loop.
|
||||
* Fires twice: once when Media3's `onPlayerError` surfaces a load
|
||||
* failure (decoder, transport, EOS), and once when the player
|
||||
* reaches STATE_READY with a duration of zero / TIME_UNSET — the
|
||||
* "track loaded but has no audio" case the user sees as the
|
||||
* player sitting frozen on a track.
|
||||
*
|
||||
* Drift #561: this was originally Channel.CONFLATED, which silently
|
||||
* dropped every emission except the latest each time the reporter
|
||||
* loop wasn't actively reading. A network blip that failed 5
|
||||
* tracks back-to-back would surface only the last failure to the
|
||||
* snackbar (the "Skipped 5 unplayable tracks" coalescing path was
|
||||
* dead code) AND only POST one playback_errors row to the admin
|
||||
* inbox instead of 5. Buffered so every burst event reaches the
|
||||
* reporter; default capacity is 64 which is well above any real
|
||||
* burst rate.
|
||||
*/
|
||||
private val playbackErrorEventsChannel = Channel<String>(Channel.CONFLATED)
|
||||
val playbackErrorEvents: Flow<String> = playbackErrorEventsChannel.receiveAsFlow()
|
||||
private val playbackErrorEventsChannel = Channel<PlaybackErrorEvent>(Channel.BUFFERED)
|
||||
val playbackErrorEvents: Flow<PlaybackErrorEvent> = playbackErrorEventsChannel.receiveAsFlow()
|
||||
|
||||
/**
|
||||
* Tracks which queue index has already been evaluated for the
|
||||
* zero-duration / load-failed checks so STATE_READY firing
|
||||
* repeatedly (after every seek, pause/resume) doesn't re-emit
|
||||
* the same error event. Reset on each onMediaItemTransition.
|
||||
*/
|
||||
private var lastEvaluatedItemIndex: Int = -1
|
||||
|
||||
/**
|
||||
* Stable queue snapshot kept in sync with the player's MediaItems —
|
||||
@@ -83,17 +118,50 @@ class PlayerController @Inject constructor(
|
||||
*/
|
||||
private var queueRefs: List<TrackRef> = emptyList()
|
||||
|
||||
/**
|
||||
* Completes when [mediaController] is non-null and the listener has
|
||||
* been attached. Used by [awaitReady] so cold-boot callers like
|
||||
* [ResumeController] can wait for the IPC bind before calling
|
||||
* transport methods that would otherwise no-op silently. Drift
|
||||
* #562 caught the race where a fast restore() landed before the
|
||||
* MediaSessionService connection was up, silently dropping the
|
||||
* restored queue.
|
||||
*/
|
||||
private val readyDeferred = kotlinx.coroutines.CompletableDeferred<Unit>()
|
||||
|
||||
init {
|
||||
scope.launch { connectAndObserve() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspends until the MediaController binding to
|
||||
* [MinstrelPlayerService] is up and a Player.Listener is attached,
|
||||
* so a subsequent transport call (setQueue, startRadio, playNext,
|
||||
* …) will actually reach the player rather than being silently
|
||||
* swallowed by the `mediaController ?: return` guards.
|
||||
*/
|
||||
suspend fun awaitReady(): Unit = readyDeferred.await()
|
||||
|
||||
// ── 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() {
|
||||
@@ -125,18 +193,49 @@ class PlayerController @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the queue with [tracks] and start playing from [initialIndex].
|
||||
* Replace the queue with [tracks] starting at [initialIndex].
|
||||
* [source] tags the queue with its origin (e.g. "for_you") so the
|
||||
* server-side rotation reporter can advance it; carried in
|
||||
* MediaItem extras.
|
||||
*
|
||||
* [autoplay] controls whether playback starts immediately. The
|
||||
* default is true to preserve the "user pressed play on a tile"
|
||||
* UX every existing caller relies on. Drift #560: cold-boot
|
||||
* resume passes autoplay = false so the persisted queue is
|
||||
* restored without auto-starting playback after the user has
|
||||
* been away from the app — starting audio on cold launch was
|
||||
* surprising for users who had paused mid-track before
|
||||
* backgrounding.
|
||||
*/
|
||||
fun setQueue(tracks: List<TrackRef>, initialIndex: Int = 0, source: String? = null) {
|
||||
fun setQueue(
|
||||
tracks: List<TrackRef>,
|
||||
initialIndex: Int = 0,
|
||||
source: String? = null,
|
||||
autoplay: Boolean = true,
|
||||
) {
|
||||
val controller = mediaController ?: return
|
||||
queueRefs = tracks
|
||||
val items = tracks.map { it.toMediaItem(source) }
|
||||
controller.setMediaItems(items, initialIndex, /* startPositionMs = */ 0L)
|
||||
controller.prepare()
|
||||
controller.play()
|
||||
// Drift #562 cold-boot resume calls this from a non-Main suspend
|
||||
// context after awaitReady() unblocks (ResumeController launches
|
||||
// on Dispatchers.Default by the time it reaches us). MediaController
|
||||
// enforces application-thread access and throws
|
||||
// IllegalStateException otherwise — post to its applicationLooper
|
||||
// if we're already there, run directly to avoid the re-dispatch
|
||||
// latency UI callers depend on.
|
||||
runOnControllerThread(controller) {
|
||||
controller.setMediaItems(items, initialIndex, /* startPositionMs = */ 0L)
|
||||
controller.prepare()
|
||||
if (autoplay) controller.play()
|
||||
}
|
||||
}
|
||||
|
||||
private fun runOnControllerThread(controller: MediaController, block: () -> Unit) {
|
||||
if (Looper.myLooper() == controller.applicationLooper) {
|
||||
block()
|
||||
} else {
|
||||
Handler(controller.applicationLooper).post(block)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,17 +265,92 @@ class PlayerController @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a fresh radio queue from [trackId] and start playback.
|
||||
* Replaces the existing queue. The `source` tag is "radio:<id>"
|
||||
* so the server-side rotation reporter can distinguish radio
|
||||
* plays from album / playlist plays. No-op on an empty server
|
||||
* response; throws on transport / non-2xx for the caller to
|
||||
* surface in a snackbar.
|
||||
* Seed a fresh radio queue from [trackId]. The `source` tag is
|
||||
* "radio:<id>" so the server-side rotation reporter can
|
||||
* distinguish radio plays from album / playlist plays.
|
||||
*
|
||||
* Two modes:
|
||||
* - Nothing in the queue: start the radio cold from track 0
|
||||
* (the seed plays first, recommendations follow).
|
||||
* - Queue already populated: do NOT interrupt — keep the current
|
||||
* track playing as-is, trim every upcoming track, and append
|
||||
* the radio results after the current item. If the seed is the
|
||||
* currently playing track (the common "tap Start Radio on the
|
||||
* song I'm listening to" case), drop it from the appended list
|
||||
* so it doesn't immediately repeat. This is an intentional
|
||||
* divergence from Flutter's `playerActions.startRadio`, which
|
||||
* always calls `playTracks` and restarts playback from 0.
|
||||
*
|
||||
* No-op on an empty server response; throws on transport / non-2xx
|
||||
* for the caller to surface in a snackbar.
|
||||
*/
|
||||
suspend fun startRadio(trackId: String) {
|
||||
val controller = mediaController ?: return
|
||||
val tracks = radio.seed(trackId)
|
||||
if (tracks.isEmpty()) return
|
||||
setQueue(tracks, initialIndex = 0, source = "radio:$trackId")
|
||||
val source = "radio:$trackId"
|
||||
if (controller.mediaItemCount == 0) {
|
||||
setQueue(tracks, initialIndex = 0, source = source)
|
||||
} else {
|
||||
appendRadioToQueue(controller, tracks, trackId, source)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mid-queue radio insert. The current track keeps playing; every
|
||||
* upcoming item is removed; the radio results are appended after.
|
||||
* Drops the seed from the appended list when it matches the
|
||||
* currently-playing track so it doesn't immediately repeat.
|
||||
*/
|
||||
private fun appendRadioToQueue(
|
||||
controller: MediaController,
|
||||
tracks: List<TrackRef>,
|
||||
seedTrackId: String,
|
||||
source: String,
|
||||
) {
|
||||
val currentIdx = controller.currentMediaItemIndex
|
||||
val currentTrack = queueRefs.getOrNull(currentIdx)
|
||||
val toAppend = if (currentTrack?.id == seedTrackId) tracks.drop(1) else tracks
|
||||
if (toAppend.isEmpty()) return
|
||||
val nextIdx = currentIdx + 1
|
||||
if (nextIdx < controller.mediaItemCount) {
|
||||
controller.removeMediaItems(nextIdx, controller.mediaItemCount)
|
||||
}
|
||||
queueRefs = queueRefs.take(nextIdx) + toAppend
|
||||
controller.addMediaItems(toAppend.map { it.toMediaItem(source) })
|
||||
}
|
||||
|
||||
/**
|
||||
* When a STATE_READY transition for a freshly-loaded item reports
|
||||
* a zero / TIME_UNSET duration, fire a `zero_duration` error event
|
||||
* and advance past the dead track. Otherwise no-op.
|
||||
*/
|
||||
private fun handleZeroDurationIfNeeded(controller: MediaController, idx: Int) {
|
||||
// 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
|
||||
playbackErrorEventsChannel.trySend(
|
||||
PlaybackErrorEvent(
|
||||
trackId = current.id,
|
||||
kind = "zero_duration",
|
||||
title = current.title.ifEmpty { "Track" },
|
||||
detail = "duration=$duration",
|
||||
),
|
||||
)
|
||||
if (controller.hasNextMediaItem()) {
|
||||
controller.seekToNextMediaItem()
|
||||
} else {
|
||||
controller.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal: async connect + Listener-driven UI state sync ──────────
|
||||
@@ -196,16 +370,61 @@ class PlayerController @Inject constructor(
|
||||
return
|
||||
}
|
||||
mediaController = controller
|
||||
// Drift #562: signal awaitReady() callers (ResumeController et al.)
|
||||
// that the controller is bound. Listener is attached below in
|
||||
// the same coroutine so by the time downstream code runs after
|
||||
// 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) {
|
||||
val title = queueRefs
|
||||
.getOrNull(controller.currentMediaItemIndex)
|
||||
?.title
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: "Track"
|
||||
playbackErrorEventsChannel.trySend(title)
|
||||
val current = queueRefs.getOrNull(controller.currentMediaItemIndex)
|
||||
val title = current?.title?.takeIf { it.isNotEmpty() } ?: "Track"
|
||||
val trackId = current?.id ?: return
|
||||
playbackErrorEventsChannel.trySend(
|
||||
PlaybackErrorEvent(
|
||||
trackId = trackId,
|
||||
kind = "load_failed",
|
||||
title = title,
|
||||
detail = error.message,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(
|
||||
mediaItem: androidx.media3.common.MediaItem?,
|
||||
reason: Int,
|
||||
) {
|
||||
// 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) {
|
||||
if (playbackState != Player.STATE_READY) return
|
||||
val idx = controller.currentMediaItemIndex
|
||||
if (idx == lastEvaluatedItemIndex) return
|
||||
lastEvaluatedItemIndex = idx
|
||||
handleZeroDurationIfNeeded(controller, idx)
|
||||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
@@ -215,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,
|
||||
@@ -233,6 +475,7 @@ class PlayerController @Inject constructor(
|
||||
Player.REPEAT_MODE_ONE -> RepeatMode.ONE
|
||||
else -> RepeatMode.OFF
|
||||
},
|
||||
isUpnpLoading = isUpnpLoading,
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -257,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`
|
||||
@@ -301,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);
|
||||
@@ -323,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,3 +748,19 @@ class PlayerController @Inject constructor(
|
||||
// surfaces are driven by Media3's MediaSession callbacks separately;
|
||||
// they don't need this poll.
|
||||
private const val POSITION_POLL_INTERVAL_MS = 500L
|
||||
|
||||
/**
|
||||
* Structured playback failure event for [PlaybackErrorReporter]. Drives
|
||||
* both the user-facing snackbar ("Couldn't play X — skipping") and the
|
||||
* /api/playback-errors admin inbox.
|
||||
*
|
||||
* `kind` is one of "zero_duration" / "load_failed" / "stalled" matching
|
||||
* the server's CHECK constraint. `detail` is free-text (the Media3
|
||||
* error message, the observed duration value, etc.).
|
||||
*/
|
||||
data class PlaybackErrorEvent(
|
||||
val trackId: String,
|
||||
val kind: String,
|
||||
val title: String,
|
||||
val detail: String? = null,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -54,12 +54,22 @@ class ResumeController @Inject constructor(
|
||||
.getOrNull()
|
||||
?.takeIf { it.tracks.isNotEmpty() }
|
||||
?: return
|
||||
// Drift #562: wait for the MediaController binding before
|
||||
// calling setQueue, otherwise restore() racing the IPC
|
||||
// handshake silently drops the persisted queue (PlayerController
|
||||
// setQueue early-returns when mediaController is null).
|
||||
playerController.awaitReady()
|
||||
playerController.setQueue(
|
||||
tracks = payload.tracks,
|
||||
initialIndex = payload.queueIndex
|
||||
.coerceAtLeast(0)
|
||||
.coerceAtMost(payload.tracks.lastIndex),
|
||||
source = payload.source,
|
||||
// Drift #560: cold-boot resume must NOT auto-start
|
||||
// playback. The user has been away from the app; starting
|
||||
// audio on cold launch is surprising. They tap play to
|
||||
// resume. Queue + position are restored; transport is idle.
|
||||
autoplay = false,
|
||||
)
|
||||
// PositionMs not seeked yet — Player connection may still be
|
||||
// establishing; the seek call would no-op. Acceptable to start
|
||||
|
||||
@@ -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,84 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.composables.icons.lucide.Bluetooth
|
||||
import com.composables.icons.lucide.Cast
|
||||
import com.composables.icons.lucide.ChevronDown
|
||||
import com.composables.icons.lucide.Headphones
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Smartphone
|
||||
import com.composables.icons.lucide.Speaker
|
||||
|
||||
/**
|
||||
* Spotify-style chip showing the current output route. Sits between
|
||||
* BottomActionsRow and ScrubberRow in NowPlayingScreen. Tap to open
|
||||
* the picker sheet.
|
||||
*
|
||||
* Visibility rule per the spec: hidden when the route list has
|
||||
* exactly one entry AND that entry is BuiltIn — no reason to surface
|
||||
* a picker for "the only thing available." Visibility logic owned
|
||||
* by the caller (NowPlayingScreen).
|
||||
*/
|
||||
@Composable
|
||||
fun DeviceChip(
|
||||
route: OutputRoute,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = iconFor(route.kind),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(CHIP_ICON_DP.dp),
|
||||
)
|
||||
Text(
|
||||
text = route.name,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Lucide.ChevronDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(CHIP_CHEVRON_DP.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun iconFor(kind: OutputRoute.Kind): ImageVector = when (kind) {
|
||||
OutputRoute.Kind.BuiltIn -> Lucide.Smartphone
|
||||
OutputRoute.Kind.Wired -> Lucide.Headphones
|
||||
OutputRoute.Kind.Bluetooth -> Lucide.Bluetooth
|
||||
OutputRoute.Kind.Cast -> Lucide.Cast
|
||||
OutputRoute.Kind.Other -> Lucide.Speaker
|
||||
}
|
||||
|
||||
private const val CHIP_ICON_DP = 16
|
||||
private const val CHIP_CHEVRON_DP = 14
|
||||
@@ -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
|
||||
}
|
||||
+711
@@ -0,0 +1,711 @@
|
||||
@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.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 kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
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 -- with the BuiltIn
|
||||
* "Phone speaker" pinned first, everything else alphabetical.
|
||||
*/
|
||||
data class RouteSnapshot(
|
||||
val current: OutputRoute,
|
||||
val available: List<OutputRoute>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Hilt-singleton facade over [MediaRouter] + [UpnpDiscoveryController].
|
||||
* Owns the callback lifecycle — default passive behavior at process
|
||||
* start (route list updates without forcing Bluetooth scans), upgrades
|
||||
* to active discovery while the picker sheet is open so newly-paired
|
||||
* devices appear promptly. The [routesState] StateFlow projects the
|
||||
* current route + sorted available list as a [RouteSnapshot]; the
|
||||
* picker ViewModel collects it.
|
||||
*
|
||||
* Selection branches on [OutputRoute.Protocol]:
|
||||
* - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
|
||||
* wired, Bluetooth)
|
||||
* - [OutputRoute.Protocol.UPNP] — mint a signed stream token via
|
||||
* [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] —
|
||||
* reserved for follow-up slices; ignored for now.
|
||||
*
|
||||
* Mirrors the OutputPickerController role described in
|
||||
* docs/superpowers/specs/2026-06-03-android-output-picker-bluetooth-design.md
|
||||
* and the UPnP extensions in
|
||||
* docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
||||
*/
|
||||
@Singleton
|
||||
class OutputPickerController @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
private val upnpDiscovery: UpnpDiscoveryController,
|
||||
private val playerController: PlayerController,
|
||||
private val playerFactory: PlayerFactory,
|
||||
private val streamTokens: StreamTokenProvider,
|
||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val okHttp: OkHttpClient,
|
||||
) {
|
||||
private val mediaRouter = MediaRouter.getInstance(context)
|
||||
|
||||
private val selector = MediaRouteSelector.Builder()
|
||||
.addControlCategory(MediaControlIntent.CATEGORY_LIVE_AUDIO)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Internal projection of the live MediaRouter snapshot. Refreshed
|
||||
* on every Callback event; combined downstream with UPnP routes
|
||||
* into the public [routesState].
|
||||
*/
|
||||
private val systemRoutesInternal = MutableStateFlow(snapshotFromRouter())
|
||||
|
||||
private val selectedUpnpRouteIdInternal = MutableStateFlow<String?>(null)
|
||||
private val selectUpnpMutex = Mutex()
|
||||
|
||||
val routesState: StateFlow<RouteSnapshot> = combine(
|
||||
systemRoutesInternal,
|
||||
upnpDiscovery.routes,
|
||||
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() {
|
||||
override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) =
|
||||
refresh()
|
||||
|
||||
override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) =
|
||||
refresh()
|
||||
|
||||
override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) =
|
||||
refresh()
|
||||
|
||||
override fun onRouteSelected(
|
||||
router: MediaRouter,
|
||||
route: MediaRouter.RouteInfo,
|
||||
reason: Int,
|
||||
) = refresh()
|
||||
|
||||
override fun onRouteUnselected(
|
||||
router: MediaRouter,
|
||||
route: MediaRouter.RouteInfo,
|
||||
reason: Int,
|
||||
) = 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:
|
||||
// route-list updates flow through onRouteAdded/Removed/Changed
|
||||
// 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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade callback registration to active discovery — call when
|
||||
* the picker sheet opens so newly-paired Bluetooth devices
|
||||
* surface within a few seconds, and fire an SSDP M-SEARCH burst
|
||||
* for UPnP renderers. Idempotent: re-registering with a
|
||||
* new flag set replaces the prior registration in MediaRouter.
|
||||
*/
|
||||
fun upgradeDiscovery() {
|
||||
mediaRouter.addCallback(selector, callback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY)
|
||||
upnpDiscovery.upgradeDiscovery()
|
||||
}
|
||||
|
||||
/**
|
||||
* Downgrade callback registration back to default passive behavior
|
||||
* — call when the picker sheet closes so we don't keep Bluetooth
|
||||
* scanning on for battery cost. Two-arg overload = no flag =
|
||||
* passive. Same idempotent re-registration semantics. The UPnP
|
||||
* side has no symmetric downgrade (passive SSDP NOTIFY listen is
|
||||
* always on); the call is preserved for API parity.
|
||||
*/
|
||||
fun downgradeDiscovery() {
|
||||
mediaRouter.addCallback(selector, callback)
|
||||
upnpDiscovery.downgradeDiscovery()
|
||||
}
|
||||
|
||||
/**
|
||||
* Select [route]. SYSTEM routes hand off to [MediaRouter]; UPNP
|
||||
* routes mint a signed stream token + drive AVTransport on the
|
||||
* discovered renderer + pause local playback. Other protocols
|
||||
* (CAST / SONOS) are reserved for follow-up slices and are
|
||||
* silently ignored — the picker shouldn't show them yet.
|
||||
*/
|
||||
fun select(route: OutputRoute) {
|
||||
when (route.protocol) {
|
||||
OutputRoute.Protocol.SYSTEM -> selectSystem(route)
|
||||
OutputRoute.Protocol.UPNP -> scope.launch { selectUpnp(route) }
|
||||
OutputRoute.Protocol.CAST, OutputRoute.Protocol.SONOS -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
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 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) = 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@withLock
|
||||
}
|
||||
// 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=${effectiveRoute.id} " +
|
||||
"(route disappeared or id mismatch with discovery list)",
|
||||
)
|
||||
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 {
|
||||
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 ${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()
|
||||
}
|
||||
|
||||
private fun snapshotFromRouter(): RouteSnapshot {
|
||||
val all = mediaRouter.routes
|
||||
.filter { it.matchesSelector(selector) }
|
||||
.map { OutputRoute.fromRouteInfo(it) }
|
||||
val current = OutputRoute.fromRouteInfo(mediaRouter.selectedRoute)
|
||||
return RouteSnapshot(current = current, available = sortRoutes(all))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(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
|
||||
}
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
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
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.composables.icons.lucide.Circle
|
||||
import com.composables.icons.lucide.CircleCheck
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Settings
|
||||
import com.composables.icons.lucide.WifiOff
|
||||
|
||||
/**
|
||||
* Material 3 ModalBottomSheet listing the available output routes.
|
||||
* Tap a row to select + dismiss. Selected route shows the accent
|
||||
* CircleCheck; others show an empty Circle. Long route names
|
||||
* truncate cleanly via maxLines = 2 + Ellipsis.
|
||||
*
|
||||
* Two footer hints, each driven by a flag the host screen owns:
|
||||
* - [permissionDenied] — NowPlayingScreen owns the BLUETOOTH_CONNECT
|
||||
* request flow and sets this true on denial so the user sees the
|
||||
* "pair in Settings" affordance.
|
||||
* - [noUpnpDiscovered] — set true when the picker has been open for
|
||||
* a few seconds and no UPnP renderers have arrived; surfaces the
|
||||
* "router may be blocking multicast" hint. Defaults to false so
|
||||
* existing call sites that haven't wired the discovery-timing
|
||||
* logic continue to render without the hint.
|
||||
*
|
||||
* Keeping the hint flags external preserves this composable's
|
||||
* focus-on-rendering shape.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OutputPickerSheet(
|
||||
snapshot: RouteSnapshot,
|
||||
permissionDenied: Boolean,
|
||||
onRouteSelected: (OutputRoute) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
noUpnpDiscovered: Boolean = false,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Output",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
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()
|
||||
}
|
||||
if (noUpnpDiscovered) {
|
||||
MulticastHintRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RouteRow(
|
||||
route: OutputRoute,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val tint = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = iconFor(route.kind),
|
||||
contentDescription = null,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(ROW_ICON_DP.dp),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = route.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val subtitle = route.description ?: defaultSubtitle(route)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (isSelected) Lucide.CircleCheck else Lucide.Circle,
|
||||
contentDescription = if (isSelected) "Selected" else "Not selected",
|
||||
tint = tint,
|
||||
modifier = Modifier.size(ROW_ICON_DP.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionHintRow() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.Settings,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(HINT_ICON_DP.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Pair a Bluetooth device in Settings to see it here.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MulticastHintRow() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.WifiOff,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(HINT_ICON_DP.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Your router may be blocking multicast discovery.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) {
|
||||
OutputRoute.Kind.BuiltIn -> "Phone speaker"
|
||||
OutputRoute.Kind.Wired -> "Wired"
|
||||
OutputRoute.Kind.Bluetooth -> if (route.isConnected) "Connected" else "Available"
|
||||
OutputRoute.Kind.Cast -> "Cast"
|
||||
OutputRoute.Kind.Other -> "Available"
|
||||
}
|
||||
|
||||
private const val ROW_ICON_DP = 24
|
||||
private const val HINT_ICON_DP = 20
|
||||
private const val ROUTE_LIST_MAX_HEIGHT_DP = 400
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* NowPlaying-scoped projection over [OutputPickerController]. The
|
||||
* controller is the long-lived Hilt singleton (its MediaRouter
|
||||
* callback must not churn with screen lifecycle); this ViewModel is
|
||||
* a thin lens over its [OutputPickerController.routesState] Flow
|
||||
* plus the sheet's visibility state.
|
||||
*
|
||||
* Sheet open/close are forwarded to the controller's discovery
|
||||
* toggle so active MediaRouter discovery only runs while the sheet
|
||||
* is actually visible.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class OutputPickerViewModel @Inject constructor(
|
||||
private val controller: OutputPickerController,
|
||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||
) : ViewModel() {
|
||||
|
||||
val routes: StateFlow<RouteSnapshot> = controller.routesState
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS),
|
||||
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()
|
||||
|
||||
fun onChipTapped() {
|
||||
sheetVisibleInternal.value = true
|
||||
controller.upgradeDiscovery()
|
||||
}
|
||||
|
||||
fun onSheetDismissed() {
|
||||
sheetVisibleInternal.value = false
|
||||
controller.downgradeDiscovery()
|
||||
}
|
||||
|
||||
fun onRouteSelected(route: OutputRoute) {
|
||||
controller.select(route)
|
||||
sheetVisibleInternal.value = false
|
||||
controller.downgradeDiscovery()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// SharingStarted timeout so quick screen-orientation changes
|
||||
// don't tear down + re-subscribe the controller's Flow.
|
||||
const val STOP_TIMEOUT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import androidx.mediarouter.media.MediaRouter
|
||||
import com.fabledsword.minstrel.player.output.upnp.UpnpRoute
|
||||
|
||||
/**
|
||||
* Narrow domain model for an audio output route. Independent of
|
||||
* [MediaRouter.RouteInfo] so the picker UI can preview without an
|
||||
* Android framework presence (RouteInfo can't be constructed in
|
||||
* JVM tests, mirroring the LikeMediaCallback constraint).
|
||||
*
|
||||
* The [protocol] field is a forward-compatibility hook for the
|
||||
* UPnP / DLNA / Sonos / Cast slice (scope captured in
|
||||
* docs/superpowers/specs/2026-06-03-android-output-picker-upnp-scope.md).
|
||||
* THIS slice always sets `protocol = SYSTEM` — the system-route
|
||||
* categories MediaRouter surfaces (built-in / wired / Bluetooth).
|
||||
*/
|
||||
data class OutputRoute(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val kind: Kind,
|
||||
val protocol: Protocol,
|
||||
val isConnected: Boolean,
|
||||
) {
|
||||
enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other }
|
||||
|
||||
enum class Protocol {
|
||||
/** System-managed routes — built-in speaker, wired, Bluetooth. */
|
||||
SYSTEM,
|
||||
|
||||
/** Generic UPnP / DLNA renderers — reserved for the next slice. */
|
||||
UPNP,
|
||||
|
||||
/** Chromecast via the Cast SDK — reserved for a later slice. */
|
||||
CAST,
|
||||
|
||||
/** Sonos via the Sonos extension on top of UPnP — reserved. */
|
||||
SONOS,
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Lift a MediaRouter [route] into the domain model. Kind is
|
||||
* inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown
|
||||
* device types fall through to [Kind.Other]. The
|
||||
* `connectionState` proxy is good enough for the chip's
|
||||
* "Connected"/"Available" subtitle.
|
||||
*/
|
||||
fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute {
|
||||
val kind = when (route.deviceType) {
|
||||
MediaRouter.RouteInfo.DEVICE_TYPE_BUILTIN_SPEAKER -> Kind.BuiltIn
|
||||
MediaRouter.RouteInfo.DEVICE_TYPE_WIRED_HEADSET,
|
||||
MediaRouter.RouteInfo.DEVICE_TYPE_WIRED_HEADPHONES,
|
||||
-> Kind.Wired
|
||||
MediaRouter.RouteInfo.DEVICE_TYPE_BLUETOOTH_A2DP -> Kind.Bluetooth
|
||||
MediaRouter.RouteInfo.DEVICE_TYPE_TV -> Kind.Other
|
||||
MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other
|
||||
else -> Kind.Other
|
||||
}
|
||||
val connected =
|
||||
route.connectionState == MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTED
|
||||
return OutputRoute(
|
||||
id = route.id,
|
||||
name = route.name,
|
||||
description = route.description,
|
||||
kind = kind,
|
||||
protocol = Protocol.SYSTEM,
|
||||
isConnected = connected,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift a discovered UPnP renderer into the picker's domain
|
||||
* model. Used by the UPnP discovery controller to merge
|
||||
* network speakers into the same `OutputPickerController`
|
||||
* routes stream the system routes come through.
|
||||
*
|
||||
* `isConnected = false` because UPnP devices have no
|
||||
* MediaRouter connection-state concept — they're always
|
||||
* "available" on the LAN, and the picker's selected-route
|
||||
* rendering handles the "currently playing" indicator.
|
||||
*
|
||||
* Subtitle is `manufacturer modelName` joined by a single
|
||||
* space, falling back to "Network speaker" when both fields
|
||||
* are blank.
|
||||
*/
|
||||
fun fromUpnpRoute(route: UpnpRoute): OutputRoute {
|
||||
val description = listOfNotNull(
|
||||
route.manufacturer.takeIf { it.isNotBlank() },
|
||||
route.modelName.takeIf { it.isNotBlank() },
|
||||
).joinToString(" ").ifBlank { "Network speaker" }
|
||||
return OutputRoute(
|
||||
id = route.id,
|
||||
name = route.name,
|
||||
description = description,
|
||||
kind = Kind.Other,
|
||||
protocol = Protocol.UPNP,
|
||||
isConnected = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/**
|
||||
* 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,
|
||||
) {
|
||||
suspend fun setAVTransportURI(uri: String, metadata: String = "") {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "SetAVTransportURI",
|
||||
args = mapOf(
|
||||
"InstanceID" to "0",
|
||||
"CurrentURI" to uri,
|
||||
"CurrentURIMetaData" to metadata,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "Play",
|
||||
args = mapOf("InstanceID" to "0", "Speed" to "1"),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun pause() {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "Pause",
|
||||
args = mapOf("InstanceID" to "0"),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun stop() {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "Stop",
|
||||
args = mapOf("InstanceID" to "0"),
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
|
||||
/**
|
||||
* Pull-parsed UPnP device description (the XML returned from a
|
||||
* discovered LOCATION URL). Carries the bits we actually need for
|
||||
* the picker: friendlyName / manufacturer / modelName for display,
|
||||
* AVTransport + RenderingControl control URLs for command dispatch.
|
||||
*
|
||||
* Filtered to MediaRenderer-capable devices — anything without an
|
||||
* AVTransport service control URL is dropped by [parse] returning
|
||||
* null (we can't make it play).
|
||||
*/
|
||||
data class DeviceDescription(
|
||||
val udn: String,
|
||||
val friendlyName: String,
|
||||
val manufacturer: String,
|
||||
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"
|
||||
private const val TAG_FRIENDLY_NAME = "friendlyName"
|
||||
private const val TAG_MANUFACTURER = "manufacturer"
|
||||
private const val TAG_MODEL_NAME = "modelName"
|
||||
private const val TAG_SERVICE_TYPE = "serviceType"
|
||||
private const val TAG_CONTROL_URL = "controlURL"
|
||||
|
||||
/**
|
||||
* Parse [xml] (the body fetched from the SSDP LOCATION URL),
|
||||
* resolving relative service control URLs against [base].
|
||||
* Returns null when AVTransport is missing — we have no way
|
||||
* to control the device without it.
|
||||
*/
|
||||
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()
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
handleEvent(parser, acc, base)
|
||||
parser.next()
|
||||
}
|
||||
val avt = acc.avtControlUrl ?: return null
|
||||
return DeviceDescription(
|
||||
udn = acc.udn,
|
||||
friendlyName = acc.friendlyName,
|
||||
manufacturer = acc.manufacturer,
|
||||
modelName = acc.modelName,
|
||||
avTransportControlUrl = avt,
|
||||
renderingControlUrl = acc.rcControlUrl,
|
||||
zoneGroupTopologyControlUrl = acc.zgtControlUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleEvent(parser: XmlPullParser, acc: ParseState, base: HttpUrl) {
|
||||
when (parser.eventType) {
|
||||
XmlPullParser.START_TAG -> handleStartTag(parser, acc)
|
||||
XmlPullParser.END_TAG -> handleEndTag(parser, acc, base)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleStartTag(parser: XmlPullParser, acc: ParseState) {
|
||||
when (parser.name) {
|
||||
TAG_SERVICE -> {
|
||||
acc.inService = true
|
||||
acc.serviceType = ""
|
||||
acc.serviceControlUrl = ""
|
||||
}
|
||||
TAG_UDN -> acc.udn = parser.nextTextSafe()
|
||||
TAG_FRIENDLY_NAME -> acc.friendlyName = parser.nextTextSafe()
|
||||
TAG_MANUFACTURER -> acc.manufacturer = parser.nextTextSafe()
|
||||
TAG_MODEL_NAME -> acc.modelName = parser.nextTextSafe()
|
||||
TAG_SERVICE_TYPE -> if (acc.inService) acc.serviceType = parser.nextTextSafe()
|
||||
TAG_CONTROL_URL -> if (acc.inService) acc.serviceControlUrl = parser.nextTextSafe()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleEndTag(parser: XmlPullParser, acc: ParseState, base: HttpUrl) {
|
||||
if (parser.name != TAG_SERVICE) return
|
||||
val resolved = resolveControlUrl(base, acc.serviceControlUrl)
|
||||
when (acc.serviceType) {
|
||||
AVT_SERVICE_TYPE -> acc.avtControlUrl = resolved
|
||||
RC_SERVICE_TYPE -> acc.rcControlUrl = resolved
|
||||
ZGT_SERVICE_TYPE -> acc.zgtControlUrl = resolved
|
||||
}
|
||||
acc.inService = false
|
||||
}
|
||||
|
||||
private fun resolveControlUrl(base: HttpUrl, path: String): HttpUrl? {
|
||||
if (path.isBlank()) return null
|
||||
return path.toHttpUrlOrNull() ?: base.resolve(path)
|
||||
}
|
||||
|
||||
private fun XmlPullParser.nextTextSafe(): String =
|
||||
runCatching { nextText() }.getOrDefault("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutable accumulator used during pull-parsing. Lives only for the
|
||||
* duration of one [parse] call.
|
||||
*/
|
||||
private class ParseState {
|
||||
var udn: String = ""
|
||||
var friendlyName: String = ""
|
||||
var manufacturer: String = ""
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
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
|
||||
* pulled in via jupnp; we control every line and integrate cleanly
|
||||
* with the app's OkHttpClient (shared connection pool, timeouts).
|
||||
*
|
||||
* Builds the standard SOAP 1.1 envelope, POSTs with the required
|
||||
* SOAPACTION + Content-Type headers, returns the parsed
|
||||
* `<action>Response` element as a Map<String, String> (UPnP responses
|
||||
* are flat string maps).
|
||||
*
|
||||
* Throws [SoapFaultException] on a `<s:Fault>` response (the UPnP
|
||||
* device's way of saying "I rejected your request"). Other transport
|
||||
* errors propagate as IOException.
|
||||
*/
|
||||
class SoapClient(
|
||||
private val okHttp: OkHttpClient,
|
||||
private val onRawResponse: ((action: String, body: String) -> Unit)? = null,
|
||||
) {
|
||||
suspend fun call(
|
||||
controlUrl: HttpUrl,
|
||||
serviceType: String,
|
||||
action: String,
|
||||
args: Map<String, String> = emptyMap(),
|
||||
): Map<String, String> = withContext(Dispatchers.IO) {
|
||||
val envelope = buildEnvelope(serviceType, action, args)
|
||||
val request = Request.Builder()
|
||||
.url(controlUrl)
|
||||
.post(envelope.toRequestBody(null))
|
||||
.header("Content-Type", SOAP_CONTENT_TYPE)
|
||||
.header("SOAPACTION", "\"$serviceType#$action\"")
|
||||
.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))
|
||||
}
|
||||
parseResponseArgs(body, action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildEnvelope(
|
||||
serviceType: String,
|
||||
action: String,
|
||||
args: Map<String, String>,
|
||||
): String = buildString {
|
||||
append("<?xml version=\"1.0\"?>")
|
||||
append("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"")
|
||||
append(" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">")
|
||||
append("<s:Body>")
|
||||
append("<u:").append(action)
|
||||
append(" xmlns:u=\"").append(serviceType).append("\">")
|
||||
args.forEach { (k, v) ->
|
||||
append("<").append(k).append(">")
|
||||
append(xmlEscape(v))
|
||||
append("</").append(k).append(">")
|
||||
}
|
||||
append("</u:").append(action).append(">")
|
||||
append("</s:Body>")
|
||||
append("</s:Envelope>")
|
||||
}
|
||||
|
||||
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"
|
||||
val args = mutableMapOf<String, String>()
|
||||
var inResponse = false
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
inResponse = handleParserEvent(parser, responseTag, inResponse, args)
|
||||
parser.next()
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
private fun handleParserEvent(
|
||||
parser: XmlPullParser,
|
||||
responseTag: String,
|
||||
inResponse: Boolean,
|
||||
args: MutableMap<String, String>,
|
||||
): Boolean = when (parser.eventType) {
|
||||
XmlPullParser.START_TAG -> {
|
||||
if (parser.name == responseTag) {
|
||||
true
|
||||
} else {
|
||||
if (inResponse) {
|
||||
val name = parser.name
|
||||
val text = readElementContent(parser, name)
|
||||
args[name] = text
|
||||
}
|
||||
inResponse
|
||||
}
|
||||
}
|
||||
XmlPullParser.END_TAG -> if (parser.name == responseTag) false else inResponse
|
||||
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"
|
||||
|
||||
private fun faultDescriptionOf(body: String): String =
|
||||
extractBetween(body, "<errorDescription>", "</errorDescription>").orEmpty()
|
||||
|
||||
private fun extractBetween(body: String, open: String, close: String): String? {
|
||||
val start = body.indexOf(open)
|
||||
if (start < 0) return null
|
||||
val contentStart = start + open.length
|
||||
val end = body.indexOf(close, contentStart)
|
||||
return if (end < 0) null else body.substring(contentStart, end)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SOAP_CONTENT_TYPE = "text/xml; charset=\"utf-8\""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a UPnP device responds with `<s:Fault>` — typically wraps
|
||||
* a UPnPError with `errorCode` + `errorDescription`. Code is preserved
|
||||
* as a string (UPnP codes are numeric in spec but we don't constrain).
|
||||
*/
|
||||
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("'", "'")
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import android.content.Context
|
||||
import android.net.wifi.WifiManager
|
||||
import androidx.core.content.getSystemService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.DatagramPacket
|
||||
import java.net.InetAddress
|
||||
import java.net.MulticastSocket
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
/**
|
||||
* UDP multicast SSDP listener + M-SEARCH sender. Emits each discovery
|
||||
* response's LOCATION URL on the [discoveries] SharedFlow; the
|
||||
* `UpnpDiscoveryController` follows up by fetching + parsing each
|
||||
* device description.
|
||||
*
|
||||
* Passive listen is always-on once [start] is called (NOTIFY packets
|
||||
* speakers send when they boot / refresh). Active discovery (an
|
||||
* explicit M-SEARCH request) is triggered by [requestActiveScan] —
|
||||
* called when the picker sheet opens so newly-paired devices appear
|
||||
* promptly.
|
||||
*
|
||||
* WifiManager.MulticastLock is held while the listener is running.
|
||||
* Released by [stop] / cancellation of the parent scope.
|
||||
*/
|
||||
class SsdpDiscovery(
|
||||
private val context: Context,
|
||||
) {
|
||||
private val discoveriesInternal =
|
||||
MutableSharedFlow<String>(extraBufferCapacity = DISCOVERY_BUFFER_CAPACITY)
|
||||
val discoveries: SharedFlow<String> = discoveriesInternal.asSharedFlow()
|
||||
|
||||
private var multicastLock: WifiManager.MulticastLock? = null
|
||||
private var socket: MulticastSocket? = null
|
||||
private var listenJob: Job? = null
|
||||
|
||||
fun start(scope: CoroutineScope) {
|
||||
if (listenJob != null) return
|
||||
val wifi = context.getSystemService<WifiManager>() ?: return
|
||||
multicastLock = wifi.createMulticastLock(MULTICAST_LOCK_TAG).apply {
|
||||
setReferenceCounted(false)
|
||||
acquire()
|
||||
}
|
||||
val sock = MulticastSocket(ANY_LOCAL_PORT).apply {
|
||||
joinGroup(InetAddress.getByName(SSDP_MULTICAST_ADDR))
|
||||
}
|
||||
socket = sock
|
||||
listenJob = scope.launch(Dispatchers.IO) {
|
||||
val buf = ByteArray(SOCKET_READ_BUFFER_BYTES)
|
||||
val packet = DatagramPacket(buf, buf.size)
|
||||
while (isActive) {
|
||||
runCatching { sock.receive(packet) }.onSuccess {
|
||||
val raw = String(packet.data, 0, packet.length, StandardCharsets.UTF_8)
|
||||
parseLocation(raw)?.let { discoveriesInternal.tryEmit(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
listenJob?.cancel()
|
||||
listenJob = null
|
||||
runCatching { socket?.close() }
|
||||
socket = null
|
||||
runCatching { multicastLock?.release() }
|
||||
multicastLock = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an M-SEARCH packet asking for MediaRenderer:1 devices.
|
||||
* Responses arrive on the listener socket and emit via
|
||||
* [discoveries] as their LOCATION URL.
|
||||
*/
|
||||
suspend fun requestActiveScan() = withContext(Dispatchers.IO) {
|
||||
val sock = socket ?: return@withContext
|
||||
val payload = buildMSearchPayload().toByteArray(StandardCharsets.UTF_8)
|
||||
val packet = DatagramPacket(
|
||||
payload,
|
||||
payload.size,
|
||||
InetAddress.getByName(SSDP_MULTICAST_ADDR),
|
||||
SSDP_PORT,
|
||||
)
|
||||
runCatching { sock.send(packet) }
|
||||
Unit
|
||||
}
|
||||
|
||||
private fun buildMSearchPayload(): String = buildString {
|
||||
append("M-SEARCH * HTTP/1.1\r\n")
|
||||
append("HOST: ").append(SSDP_MULTICAST_ADDR).append(":").append(SSDP_PORT).append("\r\n")
|
||||
append("MAN: \"ssdp:discover\"\r\n")
|
||||
append("MX: ").append(MSEARCH_MX_SECONDS).append("\r\n")
|
||||
append("ST: ").append(MEDIA_RENDERER_TARGET).append("\r\n")
|
||||
append("\r\n")
|
||||
}
|
||||
|
||||
private fun parseLocation(raw: String): String? {
|
||||
raw.lineSequence().forEach { line ->
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.startsWith(LOCATION_HEADER, ignoreCase = true)) {
|
||||
return trimmed.substring(LOCATION_HEADER.length).trim()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SSDP_MULTICAST_ADDR = "239.255.255.250"
|
||||
const val SSDP_PORT = 1900
|
||||
const val ANY_LOCAL_PORT = 0
|
||||
const val SOCKET_READ_BUFFER_BYTES = 4096
|
||||
const val DISCOVERY_BUFFER_CAPACITY = 32
|
||||
const val MSEARCH_MX_SECONDS = 2
|
||||
const val MULTICAST_LOCK_TAG = "minstrel.upnp.ssdp"
|
||||
const val MEDIA_RENDERER_TARGET = "urn:schemas-upnp-org:device:MediaRenderer:1"
|
||||
const val LOCATION_HEADER = "LOCATION:"
|
||||
}
|
||||
}
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
@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
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
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
|
||||
|
||||
/**
|
||||
* Owns the SSDP listener lifecycle, fetches each discovered LOCATION's
|
||||
* device description XML, and projects discovered MediaRenderers into a
|
||||
* `StateFlow<List<UpnpRoute>>` that [OutputPickerController] merges with
|
||||
* system routes.
|
||||
*
|
||||
* Passive listen runs from process start (via `init`). Active discovery
|
||||
* (an M-SEARCH burst) is triggered by [upgradeDiscovery] when the
|
||||
* picker sheet opens; SSDP has no symmetric "downgrade" — the passive
|
||||
* NOTIFY listener stays on for the process lifetime, so
|
||||
* [downgradeDiscovery] is a no-op preserved for API parity with the
|
||||
* MediaRouter-side controller.
|
||||
*
|
||||
* Discovered devices are de-duplicated by UDN: a repeated NOTIFY for
|
||||
* the same speaker replaces the prior entry rather than appending a
|
||||
* duplicate row to the picker.
|
||||
*/
|
||||
@Singleton
|
||||
class UpnpDiscoveryController @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
@ApplicationScope private val appScope: CoroutineScope,
|
||||
private val okHttp: OkHttpClient,
|
||||
) {
|
||||
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),
|
||||
// so the launched collector dies with the process — no explicit
|
||||
// cancellation needed.
|
||||
appScope.launch(Dispatchers.IO) {
|
||||
ssdp.discoveries.collect { locationUrl -> handleDiscovery(locationUrl) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire an M-SEARCH burst so newly-paired speakers appear within a
|
||||
* few seconds rather than waiting for the next NOTIFY beacon
|
||||
* (typical SSDP cadence: every ~30min — far too slow for a UI
|
||||
* that just opened).
|
||||
*/
|
||||
fun upgradeDiscovery() {
|
||||
appScope.launch { ssdp.requestActiveScan() }
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op for the SSDP socket — passive NOTIFY listen is always on
|
||||
* once [init] has run. Kept symmetric with the MediaRouter side's
|
||||
* `downgradeDiscovery` so the picker controller fans out to both
|
||||
* without conditional logic.
|
||||
*/
|
||||
fun downgradeDiscovery() {
|
||||
// intentionally empty: see kdoc
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an [AVTransportClient] bound to the previously-discovered
|
||||
* route's control URL. Returns null when the routeId isn't in the
|
||||
* current snapshot — the speaker disappeared between picker open
|
||||
* and tap, or the caller passed a non-UPnP routeId. Callers handle
|
||||
* the null by abandoning the selection (no crash, no fallback).
|
||||
*/
|
||||
fun transportFor(routeId: String): AVTransportClient? {
|
||||
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
|
||||
return AVTransportClient(
|
||||
loggingSoapClient(controlHttp, route.name),
|
||||
route.avTransportControlUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun handleDiscovery(locationUrl: String) {
|
||||
val route = fetchRoute(locationUrl) ?: return
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch + parse the device description at [locationUrl] into a
|
||||
* [UpnpRoute]. Returns null on URL parse failure, transport
|
||||
* failure, empty body, or non-renderer device. Single-return form
|
||||
* (chained `let`s + an early null guard) so detekt's default
|
||||
* ReturnCount cap is respected.
|
||||
*/
|
||||
private fun fetchRoute(locationUrl: String): UpnpRoute? {
|
||||
val url = locationUrl.toHttpUrlOrNull() ?: return null
|
||||
return fetchBody(url)
|
||||
?.let { DeviceDescription.parse(it, url) }
|
||||
?.let { desc ->
|
||||
UpnpRoute(
|
||||
id = desc.udn,
|
||||
name = displayName(desc.friendlyName, desc.manufacturer),
|
||||
manufacturer = desc.manufacturer,
|
||||
modelName = desc.modelName,
|
||||
avTransportControlUrl = desc.avTransportControlUrl,
|
||||
renderingControlUrl = desc.renderingControlUrl,
|
||||
zoneGroupTopologyControlUrl = desc.zoneGroupTopologyControlUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean the raw UPnP friendlyName for picker display. Sonos uses
|
||||
* the format `Room - Device Type - RINCON_<UDN>`; we strip
|
||||
* everything after the first " - " so the chip shows just "Living
|
||||
* Room" / "Kitchen" / etc. Generic UPnP devices (Yamaha, Samsung
|
||||
* TV, etc.) often append a "(192.168.x.x)" IP suffix; strip that
|
||||
* too. Empty / blank → "Network speaker" fallback.
|
||||
*
|
||||
* Device subtitle (manufacturer + model) is rendered separately
|
||||
* by the picker sheet, so room-only here doesn't lose information.
|
||||
*/
|
||||
private fun displayName(friendlyName: String, manufacturer: String): String {
|
||||
val trimmed = friendlyName.trim()
|
||||
if (trimmed.isBlank()) return "Network speaker"
|
||||
val byVendor = when {
|
||||
manufacturer.contains("Sonos", ignoreCase = true) -> {
|
||||
trimmed.substringBefore(" - ", trimmed)
|
||||
}
|
||||
else -> trimmed
|
||||
}
|
||||
val withoutIpSuffix = byVendor.replace(IP_SUFFIX_REGEX, "").trim()
|
||||
return withoutIpSuffix.ifBlank { "Network speaker" }
|
||||
}
|
||||
|
||||
private fun fetchBody(url: HttpUrl): String? {
|
||||
val body = runCatching {
|
||||
okHttp.newCall(Request.Builder().url(url).build()).execute().use {
|
||||
it.body?.string()
|
||||
}
|
||||
}.getOrNull().orEmpty()
|
||||
return body.ifEmpty { null }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// " (192.168.0.77)" trailing host suffix some generic UPnP
|
||||
// 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")
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/**
|
||||
* A discovered UPnP / DLNA MediaRenderer (Sonos, Yamaha MusicCast,
|
||||
* Bose SoundTouch, generic DLNA renderers). Lifted out of the SOAP /
|
||||
* SSDP details so the picker UI consumes a narrow domain shape.
|
||||
*
|
||||
* 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
|
||||
* fall back to "Network speaker" upstream when it's blank; this class
|
||||
* does not perform that substitution itself.
|
||||
*/
|
||||
data class UpnpRoute(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val manufacturer: String,
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -59,9 +59,14 @@ fun rememberDominantColor(coverUrl: String?): Color {
|
||||
val palette = withContext(Dispatchers.Default) {
|
||||
Palette.from(bitmap).generate()
|
||||
}
|
||||
val swatch = palette.vibrantSwatch
|
||||
?: palette.mutedSwatch
|
||||
?: palette.dominantSwatch
|
||||
// Always use the dominant (majority-by-pixel-count) swatch.
|
||||
// The cover art is the main feature of this view; a vibrant
|
||||
// accent on the cover should pop against the background, not
|
||||
// be matched by it. Previously we tried vibrant first, which
|
||||
// picked the highest-saturation swatch even when it covered
|
||||
// a tiny fraction of the cover — small bright accents made
|
||||
// the gradient feel disconnected from the actual image.
|
||||
val swatch = palette.dominantSwatch
|
||||
if (swatch != null) {
|
||||
extracted = Color(swatch.rgb)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
|
||||
package com.fabledsword.minstrel.player.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
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
|
||||
@@ -12,13 +17,16 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.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
|
||||
@@ -31,7 +39,6 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
@@ -43,13 +50,26 @@ 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.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
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
@@ -65,6 +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
|
||||
@@ -85,6 +112,10 @@ private const val COVER_MAX_WIDTH_DP = 320
|
||||
private const val TRANSPORT_ICON_DP = 36
|
||||
private const val PLAY_PAUSE_ICON_DP = 56
|
||||
private const val POP_GRACE_MS = 500L
|
||||
private val SCRUB_TRACK_HEIGHT_DP = 4.dp
|
||||
private val SCRUB_TRACK_CORNER_DP = 2.dp
|
||||
private val SCRUB_THUMB_WIDTH_DP = 4.dp
|
||||
private val SCRUB_THUMB_HEIGHT_DP = 18.dp
|
||||
|
||||
// Vertical drag-down threshold (in pixels) past which the gesture
|
||||
// pops the player. Matches Flutter's 80px threshold in spirit;
|
||||
@@ -111,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,
|
||||
@@ -148,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
|
||||
@@ -189,16 +244,32 @@ private fun rememberDragDismissConnection(
|
||||
object : NestedScrollConnection {
|
||||
private var accumulated = 0f
|
||||
|
||||
// One-shot latch. popBackStack is async — between the first
|
||||
// dismissal and the screen actually leaving the composition,
|
||||
// the user's finger is still down and more onPostScroll
|
||||
// frames arrive. Without this guard the accumulator rebuilds
|
||||
// and onDismiss() fires a second time, popping the screen
|
||||
// BENEATH NowPlaying. If that leaves the back stack empty
|
||||
// the NavHost renders nothing → black screen on resume.
|
||||
private var dismissed = false
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource,
|
||||
): Offset {
|
||||
if (source != NestedScrollSource.UserInput) return Offset.Zero
|
||||
return if (available.y > 0f) {
|
||||
// After dismissal, eat all remaining drag so the
|
||||
// scrollable doesn't paint over-scroll deltas during the
|
||||
// pop transition. Source guard folded into the `else if`
|
||||
// chain so the function stays under detekt's ReturnCount
|
||||
// ceiling of 2.
|
||||
if (dismissed) return available
|
||||
return if (source != NestedScrollSource.UserInput) {
|
||||
Offset.Zero
|
||||
} else if (available.y > 0f) {
|
||||
accumulated += available.y
|
||||
if (accumulated >= thresholdPx) {
|
||||
accumulated = 0f
|
||||
dismissed = true
|
||||
onDismiss()
|
||||
}
|
||||
Offset(0f, available.y)
|
||||
@@ -209,6 +280,7 @@ private fun rememberDragDismissConnection(
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||
if (dismissed) return available
|
||||
accumulated = 0f
|
||||
return Velocity.Zero
|
||||
}
|
||||
@@ -235,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,
|
||||
@@ -243,9 +316,47 @@ private fun NowPlayingBody(
|
||||
navController: NavHostController,
|
||||
viewModel: PlayerViewModel,
|
||||
trackActionsViewModel: TrackActionsViewModel,
|
||||
outputViewModel: OutputPickerViewModel,
|
||||
) {
|
||||
val isLiked by trackActionsViewModel.isLikedFlow(track.id)
|
||||
.collectAsStateWithLifecycle(initialValue = false)
|
||||
val routes by outputViewModel.routes.collectAsStateWithLifecycle()
|
||||
val sheetVisible by outputViewModel.sheetVisible.collectAsStateWithLifecycle()
|
||||
val permissionDenied = rememberBluetoothPermissionState(sheetVisible)
|
||||
NowPlayingContent(
|
||||
inner = inner,
|
||||
state = state,
|
||||
track = track,
|
||||
navController = navController,
|
||||
viewModel = viewModel,
|
||||
trackActionsViewModel = trackActionsViewModel,
|
||||
routes = routes,
|
||||
isLiked = isLiked,
|
||||
onChipTapped = outputViewModel::onChipTapped,
|
||||
)
|
||||
if (sheetVisible) {
|
||||
OutputPickerSheet(
|
||||
snapshot = routes,
|
||||
permissionDenied = permissionDenied,
|
||||
onRouteSelected = outputViewModel::onRouteSelected,
|
||||
onDismiss = outputViewModel::onSheetDismissed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Suppress("LongParameterList") // mirrors NowPlayingBody — pure layout wiring
|
||||
private fun NowPlayingContent(
|
||||
inner: androidx.compose.foundation.layout.PaddingValues,
|
||||
state: com.fabledsword.minstrel.player.PlayerUiState,
|
||||
track: com.fabledsword.minstrel.models.TrackRef,
|
||||
navController: NavHostController,
|
||||
viewModel: PlayerViewModel,
|
||||
trackActionsViewModel: TrackActionsViewModel,
|
||||
routes: RouteSnapshot,
|
||||
isLiked: Boolean,
|
||||
onChipTapped: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -273,27 +384,94 @@ private fun NowPlayingBody(
|
||||
onToggleShuffle = viewModel::toggleShuffle,
|
||||
onCycleRepeat = viewModel::cycleRepeat,
|
||||
)
|
||||
// Spec visibility rule: hide the chip when the only route is the
|
||||
// built-in speaker — no picker is useful with one option. Chip
|
||||
// reappears the moment a Bluetooth pair or wired plug arrives.
|
||||
if (shouldShowChip(routes)) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
DeviceChip(
|
||||
route = routes.current,
|
||||
onClick = onChipTapped,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val smoothPositionMs by rememberSmoothPositionMs(
|
||||
positionMs = state.positionMs,
|
||||
durationMs = state.durationMs,
|
||||
isPlaying = state.isPlaying,
|
||||
)
|
||||
ScrubberRow(
|
||||
positionMs = smoothPositionMs,
|
||||
durationMs = state.durationMs,
|
||||
onSeek = viewModel::seekTo,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
TransportRow(
|
||||
isPlaying = state.isPlaying,
|
||||
onPrev = viewModel::skipToPrevious,
|
||||
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
|
||||
onNext = viewModel::skipToNext,
|
||||
)
|
||||
PlaybackControlsBlock(state = state, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrubber + transport pair, sharing the smoothed playback position.
|
||||
* Extracted from [NowPlayingContent] to keep that body under detekt's
|
||||
* LongMethod ceiling — the two rows belong together (the scrubber's
|
||||
* smoothed position would otherwise need to be hoisted into the
|
||||
* caller just to thread it into the row below).
|
||||
*/
|
||||
@Composable
|
||||
private fun PlaybackControlsBlock(
|
||||
state: com.fabledsword.minstrel.player.PlayerUiState,
|
||||
viewModel: PlayerViewModel,
|
||||
) {
|
||||
val smoothPositionMs by rememberSmoothPositionMs(
|
||||
positionMs = state.positionMs,
|
||||
durationMs = state.durationMs,
|
||||
isPlaying = state.isPlaying,
|
||||
)
|
||||
ScrubberRow(
|
||||
positionMs = smoothPositionMs,
|
||||
durationMs = state.durationMs,
|
||||
onSeek = viewModel::seekTo,
|
||||
)
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot BLUETOOTH_CONNECT permission flow tied to picker-sheet
|
||||
* visibility. The launcher is remembered across recompositions; the
|
||||
* LaunchedEffect fires when the sheet opens and the permission is not
|
||||
* already granted. Subsequent opens are no-ops once the user has
|
||||
* answered — the system remembers their choice. Returns whether the
|
||||
* user explicitly denied, so the sheet can surface the rationale row.
|
||||
*
|
||||
* Extracted from [NowPlayingBody] to keep that body under detekt's
|
||||
* LongMethod ceiling — the permission plumbing is incidental to the
|
||||
* player layout.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberBluetoothPermissionState(sheetVisible: Boolean): Boolean {
|
||||
val context = LocalContext.current
|
||||
var permissionDenied by remember { mutableStateOf(false) }
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
permissionDenied = !granted
|
||||
}
|
||||
LaunchedEffect(sheetVisible) {
|
||||
if (sheetVisible &&
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
permissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
}
|
||||
}
|
||||
return permissionDenied
|
||||
}
|
||||
|
||||
private fun shouldShowChip(snapshot: RouteSnapshot): Boolean {
|
||||
val onlyBuiltIn = snapshot.available.size == 1 &&
|
||||
snapshot.available.first().kind == OutputRoute.Kind.BuiltIn
|
||||
return !onlyBuiltIn
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomActionsRow(
|
||||
navController: NavHostController,
|
||||
@@ -458,18 +636,28 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = sliderColors,
|
||||
interactionSource = interactionSource,
|
||||
// Slim 10dp thumb shrinks the scrubber's visual weight. M3
|
||||
// default is 20dp; halving keeps it tappable (Slider's own
|
||||
// 48dp hit slop is unchanged) but lets it recede into the UI.
|
||||
// Track left at default — the colour pin alone already
|
||||
// matches Flutter's slate look.
|
||||
// Thin vertical pill (4dp wide × 18dp tall, fully rounded).
|
||||
// A small circle on a thin horizontal bar reads as visually
|
||||
// off-center even when geometrically aligned — the eye
|
||||
// expects the bar to bisect the thumb but a 14dp circle's
|
||||
// mass extends above and below in equal amounts that the
|
||||
// brain perceives as offset. A vertical pill the same width
|
||||
// as the track removes the ambiguity: the bar passes through
|
||||
// the pill's horizontal axis cleanly. Also matches M3's
|
||||
// expressive-slider handle direction. State-layer halo is
|
||||
// still dropped for visual parity with the web scrubber.
|
||||
// Slider's 48dp hit slop still applies, so tapability is
|
||||
// unchanged.
|
||||
thumb = {
|
||||
SliderDefaults.Thumb(
|
||||
interactionSource = interactionSource,
|
||||
colors = sliderColors,
|
||||
thumbSize = DpSize(10.dp, 10.dp),
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = SCRUB_THUMB_WIDTH_DP, height = SCRUB_THUMB_HEIGHT_DP)
|
||||
.clip(CircleShape)
|
||||
.background(accent),
|
||||
)
|
||||
},
|
||||
// Custom 4dp rounded track — see ScrubTrack for rationale.
|
||||
track = { _ -> ScrubTrack(fraction = fraction, accent = accent) },
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -488,9 +676,37 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom 4dp rounded scrubber track. M3's default Track is 16dp tall
|
||||
* and reads as a heavy pill rather than a measurement line; the
|
||||
* pill-thumb-on-thin-track pairing restores the "handle on a string"
|
||||
* cue your eye reads as "tool, draggable." Also drops M3's stop
|
||||
* indicator dot, which the web scrubber doesn't have. Extracted so
|
||||
* [ScrubberRow] stays under detekt's LongMethod ceiling.
|
||||
*/
|
||||
@Composable
|
||||
private fun ScrubTrack(fraction: Float, accent: Color) {
|
||||
Box(modifier = Modifier.fillMaxWidth().height(SCRUB_TRACK_HEIGHT_DP)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.clip(RoundedCornerShape(SCRUB_TRACK_CORNER_DP))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(fraction)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(SCRUB_TRACK_CORNER_DP))
|
||||
.background(accent),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransportRow(
|
||||
isPlaying: Boolean,
|
||||
isUpnpLoading: Boolean,
|
||||
onPrev: () -> Unit,
|
||||
onPlayPause: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
@@ -509,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(
|
||||
@@ -527,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
|
||||
}
|
||||
+38
-4
@@ -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
|
||||
@@ -16,7 +17,9 @@ import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.models.wire.PlaylistDetailWire
|
||||
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
|
||||
@@ -50,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>> =
|
||||
@@ -232,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,
|
||||
@@ -241,7 +268,11 @@ private fun CachedPlaylistEntity.toDomain(): PlaylistRef =
|
||||
isPublic = isPublic,
|
||||
systemVariant = systemVariant,
|
||||
trackCount = trackCount,
|
||||
coverUrl = coverPath ?: "",
|
||||
// Server returns a relative cover path like "/api/playlists/<id>/cover";
|
||||
// wrap through the placeholder host so BaseUrlInterceptor + the shared
|
||||
// Coil/OkHttpClient resolve it against the live server. Without this
|
||||
// wrap AsyncImage silently fails on the relative URL.
|
||||
coverUrl = resolveServerUrl(coverPath) ?: "",
|
||||
)
|
||||
|
||||
private fun PlaylistWire.toEntity(): CachedPlaylistEntity =
|
||||
@@ -277,7 +308,10 @@ private fun PlaylistDetailWire.toPlaylistRef(): PlaylistRef =
|
||||
isPublic = isPublic,
|
||||
systemVariant = systemVariant,
|
||||
trackCount = trackCount,
|
||||
coverUrl = coverUrl,
|
||||
// Same relative-path wrap as CachedPlaylistEntity.toDomain — the wire
|
||||
// gives us /api/playlists/<id>/cover and Coil needs the placeholder
|
||||
// host for BaseUrlInterceptor to rewrite to the live server.
|
||||
coverUrl = resolveServerUrl(coverUrl) ?: "",
|
||||
ownerUsername = ownerUsername,
|
||||
)
|
||||
|
||||
|
||||
+15
-5
@@ -87,7 +87,6 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -141,8 +140,7 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()
|
||||
|
||||
val likedTrackIds: StateFlow<Set<String>> =
|
||||
likes.observeLikedTracks()
|
||||
.map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } }
|
||||
likes.observeLikedTrackIds()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
@@ -227,7 +225,16 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
val refs = tracks.toPlayableTrackRefs()
|
||||
if (refs.isEmpty()) return
|
||||
val startIndex = refs.indexOfFirst { it.id == startTrackId }.coerceAtLeast(0)
|
||||
player.setQueue(refs, initialIndex = startIndex, source = "playlist:$playlistId")
|
||||
// Drift #564: for refreshable system playlists, send the bare
|
||||
// systemVariant string so the rotation matcher
|
||||
// (systemPlaylistSources in playevents/writer.go) sees the
|
||||
// play. User playlists keep the "playlist:<id>" tag for
|
||||
// attribution but it doesn't trigger rotation (intentional —
|
||||
// rotation only applies to system mixes).
|
||||
val playlist = (internal.value as? PlaylistDetailUiState.Success)?.detail?.playlist
|
||||
val source = playlist?.systemVariant?.takeIf { playlist.refreshable }
|
||||
?: "playlist:$playlistId"
|
||||
player.setQueue(refs, initialIndex = startIndex, source = source)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +295,10 @@ private fun PlaylistDetailContent(
|
||||
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
|
||||
navController: NavHostController,
|
||||
) {
|
||||
Crossfade(targetState = state, label = "playlist-detail") { s -> when (s) {
|
||||
// Crossfade keyed on `state::class` so refreshes that emit a
|
||||
// fresh Success (same kind, new detail) don't re-fade the body;
|
||||
// only Loading ↔ Success ↔ Error transitions animate.
|
||||
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
|
||||
is PlaylistDetailUiState.Loading ->
|
||||
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
|
||||
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
|
||||
|
||||
+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"
|
||||
|
||||
+63
-8
@@ -5,10 +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
|
||||
@@ -19,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,
|
||||
@@ -30,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 {
|
||||
@@ -53,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
|
||||
@@ -71,7 +118,7 @@ class RequestsViewModel @Inject constructor(
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val (_, updated) = repository.cancel(id)
|
||||
val (outcome, updated) = repository.cancel(id)
|
||||
if (updated != null) {
|
||||
internal.update { state ->
|
||||
if (state !is UiState.Success) {
|
||||
@@ -87,12 +134,20 @@ class RequestsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Refresh fetches the canonical list whether the cancel
|
||||
// went through live (Synced) or got enqueued (Queued).
|
||||
// The Queued case shows the optimistic removal until the
|
||||
// replayer drains; the next list fetch surfaces the
|
||||
// server's canonical view.
|
||||
refresh()
|
||||
// Drift #577: only refetch on the Synced outcome. The
|
||||
// Queued path is offline by definition — the optimistic
|
||||
// removal at the top of cancel() is correct, and a
|
||||
// refresh() call here would either (a) get the
|
||||
// not-yet-delivered cancelled row back from the server
|
||||
// and snap it into the list (confusing), or (b) fail
|
||||
// with a transport error and flip the whole screen to
|
||||
// UiState.Error, making the user think the cancel
|
||||
// failed. The mutation queue will replay the cancel
|
||||
// when connectivity returns; the next on-screen refresh
|
||||
// (pull-to-refresh, navigation back) reconciles.
|
||||
if (outcome == CancelOutcome.Synced) {
|
||||
refresh()
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+51
-4
@@ -45,12 +45,26 @@ class AuthCookieInterceptorTest {
|
||||
}
|
||||
authStore = AuthStore(dao, TestScope(UnconfinedTestDispatcher()))
|
||||
|
||||
// BaseUrlInterceptor rewrites placeholder.invalid → mock server.
|
||||
// AuthCookieInterceptor scopes its attach + clear behavior to
|
||||
// the placeholder host so external Coil image fetches don't get
|
||||
// the Minstrel session cookie attached and don't trigger a
|
||||
// session-clear on 401. Tests issue requests to
|
||||
// http://placeholder.invalid/... so the auth interceptor sees
|
||||
// the in-scope host, and BaseUrlInterceptor (running AFTER, so
|
||||
// the auth interceptor sees the original host) rewrites the URL
|
||||
// before transport.
|
||||
authStore.setBaseUrl(server.url("/").toString())
|
||||
client =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(AuthCookieInterceptor(authStore))
|
||||
.addInterceptor(BaseUrlInterceptor(authStore))
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun placeholderUrl(path: String): String =
|
||||
"http://${BaseUrlInterceptor.PLACEHOLDER_HOST}$path"
|
||||
|
||||
@AfterEach
|
||||
fun teardown() {
|
||||
server.shutdown()
|
||||
@@ -61,7 +75,7 @@ class AuthCookieInterceptorTest {
|
||||
authStore.setSessionCookie("session=abc123")
|
||||
server.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
client.newCall(Request.Builder().url(server.url("/api/test")).build()).execute()
|
||||
client.newCall(Request.Builder().url(placeholderUrl("/api/test")).build()).execute()
|
||||
|
||||
val recorded = server.takeRequest()
|
||||
assertEquals("session=abc123", recorded.getHeader("Cookie"))
|
||||
@@ -72,7 +86,7 @@ class AuthCookieInterceptorTest {
|
||||
authStore.setSessionCookie("session=expired")
|
||||
server.enqueue(MockResponse().setResponseCode(401))
|
||||
|
||||
client.newCall(Request.Builder().url(server.url("/api/test")).build()).execute()
|
||||
client.newCall(Request.Builder().url(placeholderUrl("/api/test")).build()).execute()
|
||||
|
||||
assertNull(authStore.sessionCookie.value)
|
||||
}
|
||||
@@ -85,7 +99,7 @@ class AuthCookieInterceptorTest {
|
||||
.addHeader("Set-Cookie", "session=newvalue; Path=/; HttpOnly"),
|
||||
)
|
||||
|
||||
client.newCall(Request.Builder().url(server.url("/api/login")).build()).execute()
|
||||
client.newCall(Request.Builder().url(placeholderUrl("/api/login")).build()).execute()
|
||||
|
||||
assertEquals("session=newvalue", authStore.sessionCookie.value)
|
||||
}
|
||||
@@ -95,8 +109,41 @@ class AuthCookieInterceptorTest {
|
||||
authStore.setSessionCookie("session=existing")
|
||||
server.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
client.newCall(Request.Builder().url(server.url("/api/anything")).build()).execute()
|
||||
client.newCall(Request.Builder().url(placeholderUrl("/api/anything")).build()).execute()
|
||||
|
||||
assertEquals("session=existing", authStore.sessionCookie.value)
|
||||
}
|
||||
|
||||
// Drift #568 regression guard: requests to external hosts must NOT
|
||||
// carry the Minstrel session cookie even when the auth store has
|
||||
// one. The shared OkHttpClient is also used by Coil for image
|
||||
// fetches to artwork.musicbrainz.org and the like; leaking the
|
||||
// session cookie to those hosts is a posture violation.
|
||||
@Test
|
||||
fun `does NOT attach cookie to non-placeholder host`() {
|
||||
authStore.setSessionCookie("session=secret")
|
||||
server.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
// Request goes directly to the mock server's host:port — NOT
|
||||
// through the placeholder sentinel — so the auth interceptor
|
||||
// should pass it through untouched.
|
||||
client.newCall(Request.Builder().url(server.url("/external/image.jpg")).build()).execute()
|
||||
|
||||
val recorded = server.takeRequest()
|
||||
assertNull(recorded.getHeader("Cookie"))
|
||||
}
|
||||
|
||||
// Drift #569 regression guard: a 401 from an external host must
|
||||
// NOT wipe the user's Minstrel session. A misbehaving CDN or a
|
||||
// Lidarr that's 401-ing on /MediaCover URLs used to silently sign
|
||||
// the user out.
|
||||
@Test
|
||||
fun `does NOT clear cookie on 401 from non-placeholder host`() {
|
||||
authStore.setSessionCookie("session=keep")
|
||||
server.enqueue(MockResponse().setResponseCode(401))
|
||||
|
||||
client.newCall(Request.Builder().url(server.url("/external/image.jpg")).build()).execute()
|
||||
|
||||
assertEquals("session=keep", authStore.sessionCookie.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.fabledsword.minstrel.api
|
||||
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
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 kotlin.test.assertEquals
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class BaseUrlInterceptorTest {
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var authStore: AuthStore
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
server = MockWebServer().apply { start() }
|
||||
val dao =
|
||||
mockk<AuthSessionDao> {
|
||||
every { observe() } returns flowOf(null)
|
||||
coEvery { get() } returns null
|
||||
coEvery { upsert(any()) } returns Unit
|
||||
coEvery { setSessionCookie(any()) } returns Unit
|
||||
coEvery { setBaseUrl(any()) } returns Unit
|
||||
}
|
||||
authStore = AuthStore(dao, TestScope(UnconfinedTestDispatcher()))
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun teardown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rewrites placeholder host to live baseUrl host port and scheme`() {
|
||||
// baseUrl points at the mock server; outbound request targets the
|
||||
// placeholder sentinel — interceptor should rewrite to the mock.
|
||||
authStore.setBaseUrl(server.url("/").toString())
|
||||
server.enqueue(MockResponse().setResponseCode(200))
|
||||
val client = OkHttpClient.Builder().addInterceptor(BaseUrlInterceptor(authStore)).build()
|
||||
|
||||
client.newCall(
|
||||
Request.Builder().url("http://placeholder.invalid/api/test").build(),
|
||||
).execute()
|
||||
|
||||
val recorded = server.takeRequest()
|
||||
assertEquals("/api/test", recorded.path)
|
||||
// MockWebServer's recorded Host header is the rewritten host:port
|
||||
assertEquals("${server.hostName}:${server.port}", recorded.getHeader("Host"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `passes external absolute URL through unchanged`() {
|
||||
// Lidarr-surfaced artwork hosts (artwork.musicbrainz.org,
|
||||
// coverartarchive.org) are absolute external URLs. The
|
||||
// interceptor must NOT rewrite them to the Minstrel base —
|
||||
// doing so produces 404s the user saw as missing Discover
|
||||
// suggestion covers.
|
||||
authStore.setBaseUrl(server.url("/").toString())
|
||||
var seen: HttpUrl? = null
|
||||
val client =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(BaseUrlInterceptor(authStore))
|
||||
.addInterceptor { chain ->
|
||||
seen = chain.request().url
|
||||
Response.Builder()
|
||||
.request(chain.request())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(200)
|
||||
.message("OK")
|
||||
.body("".toResponseBody())
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
|
||||
client.newCall(
|
||||
Request.Builder()
|
||||
.url("https://artwork.musicbrainz.org/release-group/abc/front-250.jpg")
|
||||
.build(),
|
||||
).execute()
|
||||
|
||||
assertEquals("artwork.musicbrainz.org", seen?.host)
|
||||
assertEquals("https", seen?.scheme)
|
||||
assertEquals("/release-group/abc/front-250.jpg", seen?.encodedPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls through when stored baseUrl is unparseable`() {
|
||||
// Empty / malformed baseUrl can happen between cold start and
|
||||
// first AuthStore load; the interceptor should leave the
|
||||
// request alone rather than throw.
|
||||
authStore.setBaseUrl("not a url")
|
||||
var seen: HttpUrl? = null
|
||||
val client =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(BaseUrlInterceptor(authStore))
|
||||
.addInterceptor { chain ->
|
||||
seen = chain.request().url
|
||||
Response.Builder()
|
||||
.request(chain.request())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(200)
|
||||
.message("OK")
|
||||
.body("".toResponseBody())
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
|
||||
client.newCall(
|
||||
Request.Builder().url("http://placeholder.invalid/api/test").build(),
|
||||
).execute()
|
||||
|
||||
assertEquals("placeholder.invalid", seen?.host)
|
||||
}
|
||||
}
|
||||
+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
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user