docs: Android comments say what the app does, not how it came to be (#6277)
release / govulncheck (push) Successful in 23s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m42s
release / integration (push) Successful in 5m19s
release / android (push) Successful in 6m2s
release / Build signed APK (releases and dev) (push) Successful in 6m15s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 23s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m42s
release / integration (push) Successful in 5m19s
release / android (push) Successful in 6m2s
release / Build signed APK (releases and dev) (push) Successful in 6m15s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Every flagged comment block in the Android app, its tests and its XML/TOML config, rewritten with the cited issue in view: the constraint is kept, the story behind it dropped, and issue, milestone, rule, phase and drift ids, dates and operator quotes removed. "Mirrors the Flutter client's X" pointers are gone; that client is no longer in the repo. Comments that no longer matched the code, corrected: - SyncController: a 410 resets the cursor and re-pulls; it never dropped the three local caches. - PlayerFactory, PlayerModule, AudioCacheIndexEntity, CacheConfig, CacheSource: there is no eviction worker and no liked-track bucket; SimpleCache evicts by plain LRU over the rolling cap, and the liked cap is stored but unenforced. - CacheSettings, StorageCard: the prefetcher exists (AudioPrefetcher); pin-on-like still does not. - LibraryApi, LibraryRepository, BrowseViewModel: the cache now carries the missing-file mark; the browse indexes stay server-backed for one source of grouping. - OutputRoute: UPnP routes exist; only CAST and SONOS are reserved. - PlayerFactory, RemotePlayerState, OutputPickerController: a failed transport command does not drop the UPnP route. - LibraryScreen: History, Liked and Hidden are real tabs, not placeholders. - AVTransportClient: the extend loop lives in SonosQueueLoader. - "the operator" meaning the listener now says "the user". Verified with a Kotlin lexer comparing every file's non-comment tokens against HEAD: 394 files, 0 differences. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
// kotlin-android NOT applied: AGP 9 enables built-in Kotlin by default,
|
||||
// and KSP 2.3.x supports it (PR #2674, merged Oct 2025). serialization
|
||||
// + compose-compiler are language-level Kotlin compiler plugins and
|
||||
// still need explicit application.
|
||||
// and KSP 2.3.x supports it. serialization + compose-compiler are
|
||||
// language-level Kotlin compiler plugins and still need explicit
|
||||
// application.
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.ksp)
|
||||
@@ -31,14 +31,9 @@ android {
|
||||
//
|
||||
// versionCode is minutes since 2020-01-01 at BUILD time. It is the
|
||||
// value the platform decides installs by, so it must be monotonic by
|
||||
// construction.
|
||||
//
|
||||
// This comment used to say versionCode was a commit count and that it
|
||||
// was "monotonic forever". It was neither — a commit count runs ahead
|
||||
// on `dev`, so a dev build outranked the `main` release meant to
|
||||
// replace it and Android refused the install as a downgrade. Worth
|
||||
// knowing the claim was here, stated as a reassurance, while the bug
|
||||
// it denied was live.
|
||||
// construction. Not a commit count: that runs ahead on `dev`, so a dev
|
||||
// build would outrank the `main` release meant to replace it and
|
||||
// Android would refuse the install as a downgrade.
|
||||
val versionNameOverride =
|
||||
(project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
|
||||
val versionCodeOverride =
|
||||
@@ -73,9 +68,9 @@ android {
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
// Signed with the release key or not at all. Falling back to the
|
||||
// debug key made a missing secret into a published APK that no
|
||||
// install could ever update (family idea #5103, practice 2). An
|
||||
// unsigned build installs nowhere, so the gap shows at once.
|
||||
// debug key would turn a missing secret into a published APK that no
|
||||
// install could ever update. An unsigned build installs nowhere, so
|
||||
// the gap shows at once.
|
||||
signingConfig =
|
||||
if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) {
|
||||
null
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- Notifications when the app is closed (M489 #5347): the delivery
|
||||
<!-- Notifications when the app is closed: the delivery
|
||||
service, and starting it again after a reboot or an update. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
@@ -84,11 +84,8 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- The FileProvider that used to live here existed solely to expose the
|
||||
downloaded update APK as a content:// URI for the old ACTION_VIEW
|
||||
install intent. A PackageInstaller session takes a stream instead,
|
||||
so both the provider and res/xml/file_paths.xml are gone — nothing
|
||||
else in the app ever used that authority. -->
|
||||
<!-- No FileProvider: the self-update streams the downloaded APK into a
|
||||
PackageInstaller session, so nothing needs a content:// URI. -->
|
||||
|
||||
<!-- On-demand WorkManager initialization: MinstrelApplication
|
||||
implements Configuration.Provider and supplies the
|
||||
|
||||
@@ -106,7 +106,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Android 13+ asks before an app may post notifications (M489 #5347).
|
||||
* Android 13+ asks before an app may post notifications.
|
||||
* Asked once delivery is wanted (signed in, background delivery on),
|
||||
* each launch until answered: after a second "no" the system stops
|
||||
* showing the prompt by itself, and Settings → Notifications links to
|
||||
|
||||
@@ -79,7 +79,7 @@ class MinstrelApplication :
|
||||
/**
|
||||
* Same construct-the-singleton trick — DeliveryLauncher starts and stops
|
||||
* the background-delivery service and runs the notification catch-up on
|
||||
* every nudge and reconnect (M489 #5347). Without this @Inject no phone
|
||||
* every nudge and reconnect. Without this @Inject no phone
|
||||
* notification would ever be posted.
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var deliveryLauncher: DeliveryLauncher
|
||||
@@ -171,7 +171,7 @@ class MinstrelApplication :
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var liveEventsDispatcher: LiveEventsDispatcher
|
||||
|
||||
// Device diagnostics (M9). The reporter gates itself on the account's
|
||||
// Device diagnostics. The reporter gates itself on the account's
|
||||
// debug flag; the uploader drains its buffer on a tick / recovery.
|
||||
@Suppress("unused") @Inject lateinit var diagnosticsReporter: DiagnosticsReporter
|
||||
@Suppress("unused") @Inject lateinit var diagnosticsUploader: DiagnosticsUploader
|
||||
@@ -181,7 +181,7 @@ class MinstrelApplication :
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// Debug builds get the full DebugTree (verbose). Release builds
|
||||
// get a WARN+ tree so operator-driven diagnosis via `adb logcat`
|
||||
// get a WARN+ tree so 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.
|
||||
@@ -229,10 +229,9 @@ class MinstrelApplication :
|
||||
* before Coil makes its first request.
|
||||
*
|
||||
* Crossfade is set here rather than per-call so every cover surface
|
||||
* in the app fades its artwork in instead of snapping it. Art
|
||||
* landing a beat after its tile was the most visible pop-in on Home
|
||||
* (issue #2327); `ServerImage` fades its placeholder out over the
|
||||
* same window so the two read as one cross-dissolve.
|
||||
* in the app fades its artwork in instead of snapping it, and
|
||||
* `ServerImage` fades its placeholder out over the same window so the
|
||||
* two read as one cross-dissolve.
|
||||
*/
|
||||
override fun newImageLoader(context: android.content.Context): ImageLoader =
|
||||
ImageLoader.Builder(context)
|
||||
|
||||
+3
-4
@@ -11,10 +11,9 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Read-through accessor for the admin quarantine queue. Mirrors the
|
||||
* Flutter `AdminQuarantineController`. No Room cache — same rationale
|
||||
* as AdminRequests. Three resolution actions all delete the row from
|
||||
* the queue once the server accepts them.
|
||||
* Read-through accessor for the admin quarantine queue. No Room cache —
|
||||
* same rationale as AdminRequests. Three resolution actions all delete
|
||||
* the row from the queue once the server accepts them.
|
||||
*/
|
||||
@Singleton
|
||||
class AdminQuarantineRepository @Inject constructor(
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ import javax.inject.Singleton
|
||||
*
|
||||
* No Room caching — admin actions are infrequent and don't benefit
|
||||
* from offline scrollback. `approve` and `reject` fire direct REST
|
||||
* (no mutation queue) because operator confirmation is point-and-shoot;
|
||||
* (no mutation queue) because admin confirmation is point-and-shoot;
|
||||
* if the request fails the UI surfaces the error and rolls back.
|
||||
*/
|
||||
@Singleton
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Read-through accessor for the tag-enrichment provider settings (#1521).
|
||||
* Read-through accessor for the tag-enrichment provider settings.
|
||||
* No Room caching — admin settings are infrequent point-and-shoot edits;
|
||||
* mutations fire direct REST and the ViewModel reconciles on failure.
|
||||
* Exceptions propagate to the ViewModel (which maps them via ErrorCopy).
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@ import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Read-through accessor for the admin users list + the four per-user
|
||||
* mutations. Mirrors Flutter's `AdminUsersController`. Same
|
||||
* no-Room-cache, no-MutationQueue pattern as the other admin slices.
|
||||
* mutations. Same no-Room-cache, no-MutationQueue pattern as the other
|
||||
* admin repositories.
|
||||
*/
|
||||
@Singleton
|
||||
class AdminUsersRepository @Inject constructor(
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ class AdminInvitesViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||
// Screen-level auto-recovery: reload a failed list
|
||||
// when server health returns instead of waiting for a manual pull.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect {
|
||||
|
||||
+3
-3
@@ -40,8 +40,8 @@ class AdminQuarantineViewModel @Inject constructor(
|
||||
/**
|
||||
* One-shot messages for the screen's snackbar. A failed action has to say
|
||||
* why: the row quietly reappearing reads as a glitch, and for a Delete
|
||||
* file refused by a read-only library it hides the one thing the
|
||||
* operator can fix (#3918).
|
||||
* file refused by a read-only library it hides the one thing an
|
||||
* admin can fix.
|
||||
*/
|
||||
private val transientMessagesChannel = Channel<String>(Channel.BUFFERED)
|
||||
val transientMessages: Flow<String> = transientMessagesChannel.receiveAsFlow()
|
||||
@@ -53,7 +53,7 @@ class AdminQuarantineViewModel @Inject constructor(
|
||||
.filter { it.kind.startsWith("quarantine.") }
|
||||
.collect { refresh() }
|
||||
}
|
||||
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||
// Screen-level auto-recovery: reload a failed list
|
||||
// when server health returns instead of waiting for a manual pull.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect {
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ class AdminRequestsViewModel @Inject constructor(
|
||||
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
||||
.collect { refresh() }
|
||||
}
|
||||
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||
// Screen-level auto-recovery: reload a failed list
|
||||
// when server health returns instead of waiting for a manual pull.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect {
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ class AdminTagSourcesViewModel @Inject constructor(
|
||||
init {
|
||||
refresh()
|
||||
// Screen-level auto-recovery: reload a failed list when server
|
||||
// health returns instead of waiting for a manual pull (issue #1245).
|
||||
// health returns instead of waiting for a manual pull.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect {
|
||||
if (internal.value is AdminTagSourcesUiState.Error) refresh()
|
||||
|
||||
@@ -33,7 +33,7 @@ class AdminUsersViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||
// Screen-level auto-recovery: reload a failed list
|
||||
// when server health returns instead of waiting for a manual pull.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect {
|
||||
|
||||
@@ -12,18 +12,12 @@ import javax.inject.Singleton
|
||||
* 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
|
||||
* — 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.
|
||||
* the same way BaseUrlInterceptor is. The shared OkHttpClient also
|
||||
* serves Coil's image fetches to external hosts such as
|
||||
* artwork.musicbrainz.org and coverartarchive.org; unscoped, this
|
||||
* would hand the session identifier to third-party logging, and a 401
|
||||
* from any of those hosts would sign the user out of Minstrel.
|
||||
*/
|
||||
@Singleton
|
||||
class AuthCookieInterceptor @Inject constructor(
|
||||
|
||||
@@ -17,15 +17,14 @@ import javax.inject.Singleton
|
||||
*
|
||||
* This interceptor closes the gap: Retrofit can stay built with the
|
||||
* 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).
|
||||
* retargeted at the live AuthStore value, so the user can change the
|
||||
* server URL in Settings without an app relaunch.
|
||||
*
|
||||
* Only requests whose host is the [PLACEHOLDER_HOST] sentinel are
|
||||
* rewritten. Absolute external URLs — 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.
|
||||
* reach their authored host unchanged: rewritten to the Minstrel host
|
||||
* they 404, and Discover suggestions show no covers.
|
||||
*
|
||||
* No-op when the stored base URL is unparseable (falls through to
|
||||
* whatever Retrofit had) — that case shows up as a transport-level
|
||||
|
||||
@@ -16,11 +16,10 @@ object MinstrelServerRequest
|
||||
|
||||
/**
|
||||
* Plain `http://` to the Minstrel server is allowed only when the connection
|
||||
* actually lands on a private address (family security baseline #5105,
|
||||
* practice 13).
|
||||
* actually lands on a private address.
|
||||
*
|
||||
* Cleartext stays permitted app-wide for LAN servers and UPnP
|
||||
* (network_security_config.xml, #2439), but a password or session cookie sent
|
||||
* (network_security_config.xml), but a password or session cookie sent
|
||||
* over plain HTTP to a public address can be read by anyone on the path.
|
||||
*
|
||||
* Checked per connection, on the address the socket really reached, not on
|
||||
|
||||
@@ -44,9 +44,9 @@ object ErrorCopy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Codes whose server message carries specifics the operator needs in
|
||||
* Codes whose server message carries specifics an admin needs in
|
||||
* order to act — which directory, which uid — that fixed copy cannot say.
|
||||
* For these the message follows the copy (#3918). Kept to a named set on
|
||||
* For these the message follows the copy. Kept to a named set on
|
||||
* purpose: most server messages are internal detail. Mirrors web's
|
||||
* errors.ts.
|
||||
*/
|
||||
|
||||
@@ -57,16 +57,15 @@ object NetworkModule {
|
||||
.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
|
||||
// Minstrel-server requests from external image fetches. 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.
|
||||
// BaseUrlInterceptor retargets to the live AuthStore host for
|
||||
// transport.
|
||||
.addInterceptor(auth)
|
||||
.addInterceptor(baseUrl)
|
||||
.addInterceptor(logging)
|
||||
|
||||
@@ -27,9 +27,8 @@ interface AdminInvitesApi {
|
||||
}
|
||||
|
||||
/**
|
||||
* Body for `POST /api/admin/invites`. The Flutter client omits the
|
||||
* field when note is empty; Kotlin/serialization just serializes
|
||||
* null which the server treats identically.
|
||||
* Body for `POST /api/admin/invites`. An empty note is sent as null,
|
||||
* which the server treats the same as an absent field.
|
||||
*/
|
||||
@Serializable
|
||||
data class CreateInviteBody(val note: String? = null)
|
||||
|
||||
+3
-3
@@ -12,9 +12,9 @@ import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/admin/tag-sources` (#1521) — the admin
|
||||
* surface for the tag-enrichment provider settings (#1490). Mirrors the
|
||||
* web integrations "Tag enrichment sources" card. Same shape as the
|
||||
* Retrofit interface for `/api/admin/tag-sources` — the admin surface
|
||||
* for the tag-enrichment provider settings. Mirrors the web
|
||||
* integrations "Tag enrichment sources" card. Same shape as the
|
||||
* cover-sources admin surface; kept independent so a new tag source is
|
||||
* added without touching art settings.
|
||||
*/
|
||||
|
||||
@@ -30,7 +30,7 @@ interface CastApi {
|
||||
* the 21_600 default (6h) is long enough to play through any typical
|
||||
* track without re-minting mid-playback.
|
||||
*
|
||||
* [level] asks for the leveled stream (M464 #5001): the track rendered at
|
||||
* [level] asks for the leveled stream: the track rendered at
|
||||
* the user's loudness gain, which the server works out from their setting.
|
||||
* [asAlbum] says the track plays among its album in order, which picks
|
||||
* album gain in auto mode. [prerender] says the speaker will fetch it
|
||||
|
||||
@@ -7,7 +7,7 @@ import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* Retrofit interface for device diagnostics ingest (M9). One call
|
||||
* Retrofit interface for device diagnostics ingest. One call
|
||||
* uploads a batch of buffered events. The server stores them only when
|
||||
* the account's debug_mode_enabled flag is on (it returns 204 otherwise,
|
||||
* which the uploader treats as success so it can drop the batch).
|
||||
|
||||
@@ -19,8 +19,7 @@ import retrofit2.http.Query
|
||||
* second, so the Discover screen searches when the query is submitted,
|
||||
* not on every keystroke.
|
||||
*
|
||||
* Cancel + my-requests-list endpoints live on RequestsApi (Phase 10
|
||||
* Commit B), not here.
|
||||
* Cancel + my-requests-list endpoints live on RequestsApi, not here.
|
||||
*/
|
||||
interface DiscoverApi {
|
||||
@GET("api/discover/suggestions")
|
||||
|
||||
@@ -56,16 +56,15 @@ interface LibraryApi {
|
||||
@GET("api/library/shuffle")
|
||||
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
|
||||
|
||||
// Browse axes (#367). Both indexes are unpaged by design: the client needs
|
||||
// Browse axes. Both indexes are unpaged by design: the client needs
|
||||
// the whole set to render a browsable picker, and even a messy library
|
||||
// yields hundreds of rows, not thousands.
|
||||
//
|
||||
// These read the server rather than the local cache on purpose. The cache
|
||||
// is a full mirror of the library, but /api/library/sync ships tracks whose
|
||||
// files are missing and carries no flag for it (#2704), while the browse
|
||||
// index filters them out -- so a locally-computed index would disagree with
|
||||
// the server's and with the web client. One source of truth wins over
|
||||
// offline capability here until #2704 is resolved.
|
||||
// These read the server rather than the local cache on purpose: the
|
||||
// server's index leaves out tracks whose files are missing and is the
|
||||
// one the web client shows, so the two clients agree. The cache carries
|
||||
// the same mark (cached_tracks.missing), but an index built from it
|
||||
// would be a second copy of the server's grouping that could drift.
|
||||
@GET("api/library/genres")
|
||||
suspend fun getGenres(): List<GenreCountWire>
|
||||
|
||||
|
||||
@@ -92,9 +92,7 @@ data class ChangePasswordRequest(
|
||||
|
||||
/**
|
||||
* Body for `PUT /api/me/listenbrainz`. Either `token` or `enabled`
|
||||
* is set; the server treats other fields as untouched. Mirrors
|
||||
* Flutter's `setListenBrainzToken` / `setListenBrainzEnabled` split
|
||||
* over a single endpoint shape.
|
||||
* is set; the server treats other fields as untouched.
|
||||
*/
|
||||
@Serializable
|
||||
data class ListenBrainzPutBody(
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* The notifications inbox and its per-user settings (M489). The server
|
||||
* The notifications inbox and its per-user settings. The server
|
||||
* renders each notice's title, body and link, so the app shows them as given.
|
||||
*/
|
||||
interface NotificationsApi {
|
||||
|
||||
@@ -49,7 +49,7 @@ interface PlaylistsApi {
|
||||
suspend fun refreshSystem(@Path("kind") variant: String): RefreshSystemResponse
|
||||
|
||||
/**
|
||||
* GET /api/playlists/system/{kind}/shuffle (#415 / #411 R2). Returns
|
||||
* GET /api/playlists/system/{kind}/shuffle. Returns
|
||||
* the system playlist's tracks in rotation-aware order without
|
||||
* rebuilding — used by the Home play-button overlay so taps on For
|
||||
* You / Discover / Today's mix advance rotation rather than picking
|
||||
@@ -60,8 +60,8 @@ interface PlaylistsApi {
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/playlists/{id}/tracks body. Server expects snake_case
|
||||
* `track_ids` per the Flutter wire shape.
|
||||
* POST /api/playlists/{id}/tracks body. The server expects snake_case
|
||||
* `track_ids`.
|
||||
*/
|
||||
@Serializable
|
||||
data class AppendTracksRequest(
|
||||
|
||||
@@ -13,7 +13,7 @@ import retrofit2.http.Path
|
||||
*
|
||||
* Both flag and unflag are user-scoped — callers act on their own
|
||||
* quarantine entries. The cross-user admin surface is a separate
|
||||
* `/api/admin/quarantine` route (Phase 10 Commit D).
|
||||
* `/api/admin/quarantine` route.
|
||||
*/
|
||||
interface QuarantineApi {
|
||||
@GET("api/quarantine/mine")
|
||||
|
||||
@@ -4,7 +4,7 @@ import com.fabledsword.minstrel.models.wire.ReplayGainResponseWire
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
/** The player's loudness lookup (#4997), kept apart from the browse surface in [LibraryApi]. */
|
||||
/** The player's loudness lookup, kept apart from the browse surface in [LibraryApi]. */
|
||||
interface ReplayGainApi {
|
||||
/**
|
||||
* ReplayGain values for up to 200 comma-separated track ids. An id
|
||||
|
||||
@@ -10,7 +10,7 @@ import retrofit2.http.Path
|
||||
*
|
||||
* Server scopes results to the caller — admins see only their own
|
||||
* requests through this endpoint. The cross-user admin view lives on
|
||||
* a separate `/api/admin/requests` route (Phase 10 Commit D).
|
||||
* a separate `/api/admin/requests` route.
|
||||
*
|
||||
* The DELETE endpoint returns the cancelled row (not 204) so the UI
|
||||
* can patch local state without a refetch.
|
||||
|
||||
@@ -6,7 +6,7 @@ import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/library/sync` (#357). The endpoint
|
||||
* Retrofit interface for `/api/library/sync`. The endpoint
|
||||
* returns three possible status codes that the SyncController
|
||||
* branches on:
|
||||
* - 200: parsed body with cursor + upserts + deletes
|
||||
|
||||
@@ -57,7 +57,7 @@ class AuthController @Inject constructor(
|
||||
}
|
||||
}
|
||||
// Refresh from /api/me on startup so a remotely-changed account
|
||||
// flag (e.g. admin enabling debug mode, M9) reaches the device
|
||||
// flag (e.g. an admin enabling debug mode) reaches the device
|
||||
// without a re-login. Best-effort; failures keep the cached value.
|
||||
scope.launch { refreshProfile() }
|
||||
}
|
||||
|
||||
@@ -35,13 +35,13 @@ import javax.inject.Singleton
|
||||
* sees the new value immediately; the DAO write coroutine catches up
|
||||
* shortly after.
|
||||
*
|
||||
* **The session cookie is the exception** (M462 #4985): it is persisted
|
||||
* through [SessionVault] (Keystore-encrypted), not the Room row. On the
|
||||
* first launch after the upgrade, a cookie still in the row is moved into
|
||||
* the vault and the column cleared, so nobody is signed out by the change.
|
||||
* If the Keystore cannot be used on a device, the cookie stays in the row
|
||||
* as before rather than being lost. [awaitSessionHydrated] lets a caller
|
||||
* that needs a definitive answer (the auth gate) wait for this.
|
||||
* **The session cookie is the exception**: it is persisted through
|
||||
* [SessionVault] (Keystore-encrypted), not the Room row. A cookie still
|
||||
* in the row is moved into the vault on launch and the column cleared,
|
||||
* so nobody is signed out by the move. If the Keystore cannot be used
|
||||
* on a device, the cookie stays in the row rather than being lost.
|
||||
* [awaitSessionHydrated] lets a caller that needs a definitive answer
|
||||
* (the auth gate) wait for this.
|
||||
*/
|
||||
// AuthStore is the single-row facade over auth_session (de-facto
|
||||
// app_preferences — see entity comment). It legitimately owns one
|
||||
@@ -81,7 +81,7 @@ class AuthStore @Inject constructor(
|
||||
private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
|
||||
val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow()
|
||||
|
||||
// Background delivery (M489 #5347): the device's choice, on by default.
|
||||
// Background delivery: the device's choice, on by default.
|
||||
// The shade's high-water mark lives in the same row but is read and
|
||||
// written through the DAO by NotificationSync, awaited, never cached here.
|
||||
private val backgroundDeliveryState = MutableStateFlow(true)
|
||||
@@ -131,8 +131,8 @@ class AuthStore @Inject constructor(
|
||||
|
||||
/**
|
||||
* Suspends until the stored session cookie has been loaded into
|
||||
* [sessionCookie], or [HYDRATION_DEADLINE_MS] passes (rule 156: a wedged
|
||||
* Keystore must not leave the start screen spinning). Returns false on
|
||||
* [sessionCookie], or [HYDRATION_DEADLINE_MS] passes, so a wedged
|
||||
* Keystore cannot leave the start screen spinning. Returns false on
|
||||
* the deadline; the caller then decides from whatever has loaded, and a
|
||||
* late hydration still lands in [sessionCookie].
|
||||
*/
|
||||
|
||||
@@ -19,15 +19,14 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Where the session cookie lives at rest (M462 #4985).
|
||||
* Where the session cookie lives at rest.
|
||||
*
|
||||
* The cookie is a bearer credential: anyone holding it is signed in as the
|
||||
* user until the server expires or revokes it. It used to sit in plain text
|
||||
* in the Room `auth_session` row, readable from any copy of the app's data
|
||||
* directory (a rooted device, an adb backup of a debuggable build, a
|
||||
* forensic image). Now only ciphertext is stored, under an AES key that
|
||||
* lives in the Android Keystore and never leaves it, so a copy of the
|
||||
* files alone yields nothing usable.
|
||||
* user until the server expires or revokes it. In plain text it would be
|
||||
* readable from any copy of the app's data directory (a rooted device, an
|
||||
* adb backup of a debuggable build, a forensic image). Only ciphertext is
|
||||
* stored, under an AES key that lives in the Android Keystore and never
|
||||
* leaves it, so a copy of the files alone yields nothing usable.
|
||||
*/
|
||||
interface SessionVault {
|
||||
/** The stored cookie, or null when none is stored or it can't be decrypted. */
|
||||
|
||||
@@ -19,8 +19,8 @@ import javax.inject.Inject
|
||||
* persisted auth state. Reads the row from AuthSessionDao directly, and
|
||||
* the session cookie only after [AuthStore.awaitSessionHydrated]: both
|
||||
* StateFlows default to null until their async load lands, and we need a
|
||||
* definitive answer before drawing any nav graph. The cookie is no longer
|
||||
* in the row (it lives in the Keystore-backed SessionVault, #4985).
|
||||
* definitive answer before drawing any nav graph. The cookie lives in the
|
||||
* Keystore-backed SessionVault, not the row.
|
||||
*
|
||||
* - no row at all → ServerUrl (first launch)
|
||||
* - row with baseUrl, no cookie → Login (URL configured, not yet signed in)
|
||||
|
||||
@@ -18,15 +18,13 @@ import javax.inject.Singleton
|
||||
* Populates `audio_cache_index` from playback so the offline pools
|
||||
* ([ShuffleSource]) and the cached indicator have a data source.
|
||||
*
|
||||
* Mirrors the user-visible behavior of Flutter's `AudioCacheManager`
|
||||
* (`registerStreamCache` + `touch`): a track you play gets cached as a
|
||||
* side effect of streaming, and playing it bumps its recency. The
|
||||
* Android idiom differs intentionally — Media3's `SimpleCache` is the
|
||||
* real byte store (span-based, keyed by trackId via the MediaItem's
|
||||
* custom cache key), so this index is a lightweight *play-recency*
|
||||
* record (source = INCIDENTAL) rather than Flutter's file-per-track
|
||||
* table. SimpleCache stays the source of truth for "is it actually on
|
||||
* disk"; this index orders "recently played" for the offline pools.
|
||||
* A track you play gets cached as a side effect of streaming, and
|
||||
* playing it bumps its recency. Media3's `SimpleCache` is the real byte
|
||||
* store (span-based, keyed by trackId via the MediaItem's custom cache
|
||||
* key), so this index is a lightweight *play-recency* record
|
||||
* (source = INCIDENTAL), not a file-per-track table. SimpleCache stays
|
||||
* the source of truth for "is it actually on disk"; this index orders
|
||||
* "recently played" for the offline pools.
|
||||
*
|
||||
* State observed against [PlayerController.uiState], deduped by track
|
||||
* id so each entry into the player records once. Eager-constructed via
|
||||
|
||||
@@ -19,11 +19,10 @@ private const val POOL_LIMIT = 100
|
||||
* storage/eviction-only and never filters playback — exactly what
|
||||
* this relies on.
|
||||
*
|
||||
* Divergence note: Flutter's `audioCacheIndex` only holds fully-cached
|
||||
* tracks, so its pools are inherently resident. Android's index records
|
||||
* plays (see `CacheIndexer`), so the recency list is intersected with
|
||||
* true Media3 SimpleCache residency here — the pool never offers a
|
||||
* played-then-evicted track that would fail to play offline.
|
||||
* The index records plays (see `CacheIndexer`), not residency, so the
|
||||
* recency list is intersected with true Media3 SimpleCache residency
|
||||
* here — the pool never offers a played-then-evicted track that would
|
||||
* fail to play offline.
|
||||
*
|
||||
* Surfaced on Home's Playlists row only when offline; tap shuffles +
|
||||
* plays the pool from the cache.
|
||||
@@ -58,7 +57,7 @@ class ShuffleSource @Inject constructor(
|
||||
if (orderedIds.isEmpty()) return emptyList()
|
||||
val byId = trackDao.getByIds(orderedIds).associateBy { it.id }
|
||||
return orderedIds.mapNotNull { id ->
|
||||
// Clear the server's missing mark (#2704). Every id reaching here
|
||||
// Clear the server's missing mark. Every id reaching here
|
||||
// came through residentIdsByRecency, which already proved the
|
||||
// AUDIO is in the local cache — so these play regardless of what
|
||||
// the server has lost, and the queue filter in PlayerController
|
||||
|
||||
+8
-8
@@ -1,16 +1,16 @@
|
||||
package com.fabledsword.minstrel.cache.audiocache
|
||||
|
||||
/**
|
||||
* Defaults for the 2-bucket audio cache.
|
||||
* Defaults for the audio cache; the user's Storage settings override them.
|
||||
*
|
||||
* - `likedCapBytes`: cap for the protected bucket — cached files for
|
||||
* tracks the user has liked. Evicted only after the rolling bucket
|
||||
* is fully drained (almost never under normal use).
|
||||
* - `rollingCapBytes`: cap for the unprotected bucket — incidental /
|
||||
* auto-prefetch downloads. LRU within this bucket.
|
||||
* - `likedCapBytes`: cap meant for a protected bucket of liked tracks'
|
||||
* files. Nothing enforces it yet; SimpleCache evicts by the rolling
|
||||
* cap alone.
|
||||
* - `rollingCapBytes`: cap for incidental / auto-prefetch downloads,
|
||||
* evicted least-recently-used first.
|
||||
*
|
||||
* Phase 11 (Settings) will allow user overrides; the values are kept
|
||||
* generous so first-time installs work well without configuration.
|
||||
* The values are generous so first-time installs work well without
|
||||
* configuration.
|
||||
*/
|
||||
data class CacheConfig(
|
||||
val likedCapBytes: Long = DEFAULT_LIKED_CAP_BYTES,
|
||||
|
||||
+9
-9
@@ -10,18 +10,18 @@ private const val DEFAULT_PREFETCH_WINDOW = 5
|
||||
* blob on the auth_session single-row table via [AuthStore].
|
||||
*
|
||||
* - [likedCapBytes]: budget for cached files of liked tracks. 0 means
|
||||
* unlimited. Effective at next app start (SimpleCache is built once
|
||||
* per process).
|
||||
* unlimited. Nothing enforces it yet: SimpleCache evicts by
|
||||
* [rollingCapBytes] alone, and the Storage card hides this control.
|
||||
* - [rollingCapBytes]: budget for incidental / auto-prefetch downloads.
|
||||
* Same 0=unlimited / restart-to-apply caveat.
|
||||
* - [prefetchWindow]: 1..10. Number of next-tracks the prefetcher
|
||||
* pre-downloads. Has no effect until the prefetcher lands (separate
|
||||
* audit follow-up) — persisted now so the user can pre-configure.
|
||||
* 0 means unlimited. Effective at next app start (SimpleCache is
|
||||
* built once per process).
|
||||
* - [prefetchWindow]: 1..10. Number of next-tracks `AudioPrefetcher`
|
||||
* pre-downloads.
|
||||
* - [cacheLikedTracks]: when true, liking a track should also pin its
|
||||
* audio to the liked-cache bucket. Has no effect until pin-on-like
|
||||
* lands (separate follow-up).
|
||||
* audio to the liked-cache bucket. Nothing reads it yet, and the
|
||||
* Storage card hides this control.
|
||||
*
|
||||
* Defaults match Flutter: 5 GiB per bucket, prefetch=5, cache-liked=true.
|
||||
* Defaults: 5 GiB per bucket, prefetch=5, cache-liked=true.
|
||||
*/
|
||||
@Serializable
|
||||
data class CacheSettings(
|
||||
|
||||
+20
-25
@@ -40,17 +40,12 @@ import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
||||
|
||||
/**
|
||||
* Single Room database for the whole app. Mirrors the Flutter client's
|
||||
* Drift `AppDb` — same logical schema, one entity-family per @Entity.
|
||||
* Single Room database for the whole app, one entity family per @Entity.
|
||||
*
|
||||
* Native v1 starts at schema version 1 (the Flutter Drift schemaVersion 11
|
||||
* is an internal client detail; the server is the source of truth and the
|
||||
* sync controller refills on first launch). Future schema bumps land per
|
||||
* change with explicit `Migration` entries; `fallbackToDestructiveMigration`
|
||||
* in `DatabaseModule` is the safety net while we're pre-v1.
|
||||
*
|
||||
* Entity families land in Task 4.2 slices. The set will grow until the
|
||||
* whole Drift schema is mirrored.
|
||||
* The server is the source of truth: the sync controller refills the
|
||||
* cache on first launch. Schema bumps land with explicit `Migration`
|
||||
* entries; `fallbackToDestructiveMigration` in `DatabaseModule` rebuilds
|
||||
* the database from a version that has no migration path.
|
||||
*/
|
||||
@Database(
|
||||
entities = [
|
||||
@@ -73,25 +68,25 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
||||
CachedNotificationSettingsEntity::class,
|
||||
],
|
||||
// v12: + auth_session.backgroundDelivery and notifiedUpTo, the device's
|
||||
// background-delivery choice and its shade high-water mark (M489 #5347).
|
||||
// background-delivery choice and its shade high-water mark.
|
||||
// v11: + cached_notifications and cached_notification_settings, the
|
||||
// notifications inbox and its settings (M489). MIGRATION_10_11 creates
|
||||
// notifications inbox and its settings. MIGRATION_10_11 creates
|
||||
// both; nothing to backfill, the first refresh fills them.
|
||||
// v10: + cached_tracks.trackGain/trackPeak and cached_albums.albumGain/
|
||||
// albumPeak, the ReplayGain values the player levels by (M464 #5000).
|
||||
// albumPeak, the ReplayGain values the player levels by.
|
||||
// MIGRATION_9_10 also rewinds the sync cursor, so the next sync re-sends
|
||||
// every row and an existing cache gains its values.
|
||||
// v9: + auth_session.normalizationJson, the loudness-normalization
|
||||
// preference (M464 #4998). The first schema step with an explicit
|
||||
// Migration (MIGRATION_8_9): a destructive rebuild would also wipe this
|
||||
// row — the server address and theme — and the queued offline writes,
|
||||
// preference. The first schema step with an explicit Migration
|
||||
// (MIGRATION_8_9): a destructive rebuild would also wipe this row —
|
||||
// the server address and theme — and the queued offline writes,
|
||||
// which is too much to lose for one added column.
|
||||
// v8: + cached_tracks.missing, the server's missing-file mark (#2704),
|
||||
// v8: + cached_tracks.missing, the server's missing-file mark,
|
||||
// so cache-first surfaces stop offering files that cannot stream.
|
||||
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
|
||||
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
|
||||
// which is exactly right here: the next sync refills every row with the
|
||||
// new column populated, so there is nothing to migrate by hand.
|
||||
// v7: + diagnostic_events table and the diagnosticsOptOut column
|
||||
// on auth_session. Versions before v8 rebuild destructively, which is
|
||||
// right for them: the next sync refills every row with the new
|
||||
// columns populated, so there is nothing to migrate by hand.
|
||||
version = 12,
|
||||
exportSchema = true,
|
||||
)
|
||||
@@ -115,7 +110,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun cachedNotificationDao(): CachedNotificationDao
|
||||
}
|
||||
|
||||
/** v8 → v9: add the nullable normalization preference column (#4998). */
|
||||
/** v8 → v9: add the nullable normalization preference column. */
|
||||
val MIGRATION_8_9: Migration = object : Migration(8, 9) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE auth_session ADD COLUMN normalizationJson TEXT")
|
||||
@@ -123,7 +118,7 @@ val MIGRATION_8_9: Migration = object : Migration(8, 9) {
|
||||
}
|
||||
|
||||
/**
|
||||
* v9 → v10: the gain columns (#5000). Rows synced before this carry no gains,
|
||||
* v9 → v10: the gain columns. Rows synced before this carry no gains,
|
||||
* and the sync is incremental, so it would never re-send them: the cursor goes
|
||||
* back to 0 and the next sync is a full one, upserting every row in place.
|
||||
*/
|
||||
@@ -138,7 +133,7 @@ val MIGRATION_9_10: Migration = object : Migration(9, 10) {
|
||||
}
|
||||
|
||||
/**
|
||||
* v10 → v11: the notifications inbox cache and its settings (M489). The SQL
|
||||
* v10 → v11: the notifications inbox cache and its settings. The SQL
|
||||
* matches what Room generates for the two entities; Room checks it on open.
|
||||
*/
|
||||
val MIGRATION_10_11: Migration = object : Migration(10, 11) {
|
||||
@@ -157,7 +152,7 @@ val MIGRATION_10_11: Migration = object : Migration(10, 11) {
|
||||
}
|
||||
|
||||
/**
|
||||
* v11 → v12: background delivery (M489 #5347). The device choice defaults on,
|
||||
* v11 → v12: background delivery. The device choice defaults on,
|
||||
* as the entity's column default says; the high-water mark starts empty, so
|
||||
* the first catch-up sets it without announcing anything.
|
||||
*/
|
||||
|
||||
+4
-5
@@ -33,11 +33,10 @@ object DatabaseModule {
|
||||
@Singleton
|
||||
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
|
||||
Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME)
|
||||
// Pre-v1 safety net: rebuild on any schema mismatch. The sync
|
||||
// controller refills the cache from the server on first
|
||||
// launch, so users lose only the unsynced mutation queue
|
||||
// (acceptable while we're iterating). Replace with explicit
|
||||
// Migration entries before the first tagged release.
|
||||
// Explicit migrations from v8 onward; an older schema has no path
|
||||
// and rebuilds. The sync controller refills the cache from the
|
||||
// server on first launch, so a rebuild loses only the unsynced
|
||||
// mutation queue.
|
||||
.addMigrations(MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12)
|
||||
.fallbackToDestructiveMigration(dropAllTables = true)
|
||||
.build()
|
||||
|
||||
+7
-8
@@ -4,8 +4,7 @@ import androidx.room.TypeConverter
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Source category for a cached audio file. Mirrors the Drift `CacheSource`
|
||||
* enum used by the Flutter client's `audio_cache_index` table 1:1.
|
||||
* Source category for a cached audio file in `audio_cache_index`.
|
||||
*
|
||||
* - `MANUAL`: user explicitly pinned (e.g. "save for offline")
|
||||
* - `AUTO_LIKED`: auto-cached because the track is liked
|
||||
@@ -13,8 +12,10 @@ import kotlinx.datetime.Instant
|
||||
* - `AUTO_PREFETCH`: pre-warmed by background prefetch heuristics
|
||||
* - `INCIDENTAL`: cached as a side effect of playback streaming
|
||||
*
|
||||
* Eviction priority order (first to evict): INCIDENTAL → AUTO_PREFETCH
|
||||
* → AUTO_PLAYLIST → AUTO_LIKED → MANUAL.
|
||||
* Eviction priority (first to evict): INCIDENTAL → AUTO_PREFETCH
|
||||
* → AUTO_PLAYLIST → AUTO_LIKED → MANUAL. Only INCIDENTAL is written
|
||||
* today, and SimpleCache evicts by plain LRU, so nothing applies the
|
||||
* order yet.
|
||||
*/
|
||||
enum class CacheSource { MANUAL, AUTO_LIKED, AUTO_PLAYLIST, AUTO_PREFETCH, INCIDENTAL }
|
||||
|
||||
@@ -22,10 +23,8 @@ enum class CacheSource { MANUAL, AUTO_LIKED, AUTO_PLAYLIST, AUTO_PREFETCH, INCID
|
||||
* Cross-cutting Room type converters. Registered on `@Database` via
|
||||
* `@TypeConverters(MinstrelTypeConverters::class)`.
|
||||
*
|
||||
* `kotlinx.datetime.Instant` ↔ `Long` epoch millis: the same convention the
|
||||
* Flutter client uses (Drift `text()` column with ISO-8601 strings would
|
||||
* also work but Long is leaner; we don't need human-readable rows in the
|
||||
* SQLite file).
|
||||
* `kotlinx.datetime.Instant` ↔ `Long` epoch millis: leaner than ISO-8601
|
||||
* text, and nothing needs human-readable rows in the SQLite file.
|
||||
*/
|
||||
class MinstrelTypeConverters {
|
||||
@TypeConverter fun instantToLong(i: Instant?): Long? = i?.toEpochMilliseconds()
|
||||
|
||||
+3
-4
@@ -44,10 +44,9 @@ interface CachedLikeDao {
|
||||
/**
|
||||
* 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.
|
||||
* so an unlike made on another device (a row the server no longer
|
||||
* surfaces) leaves the local cache too; upserts alone would keep a
|
||||
* stale Liked tab entry forever.
|
||||
*/
|
||||
@Transaction
|
||||
suspend fun replaceAllForUser(userId: String, rows: List<CachedLikeEntity>) {
|
||||
|
||||
+5
-5
@@ -46,13 +46,13 @@ interface CachedPlaylistTrackDao {
|
||||
* Atomic on purpose. Room's InvalidationTracker only notifies observers
|
||||
* after the transaction commits, so [observeByPlaylist] never sees the
|
||||
* empty gap between the delete and the re-insert. Un-transacted, that
|
||||
* gap is a real observed state — it's what made every Home row visibly
|
||||
* collapse to empty and refill before issue #2327 fixed the equivalent
|
||||
* write in `CachedHomeIndexDao`.
|
||||
* gap is a real observed state: the list visibly collapses to empty
|
||||
* and refills, which is why `CachedHomeIndexDao` transacts its writes
|
||||
* too.
|
||||
*
|
||||
* Nothing observes [observeByPlaylist] live today, so this is
|
||||
* pre-emptive: it means making playlist detail cache-first later can't
|
||||
* silently reintroduce that flicker.
|
||||
* pre-emptive: making playlist detail cache-first later can't
|
||||
* silently bring that flicker in.
|
||||
*/
|
||||
@Transaction
|
||||
suspend fun replacePlaylistTracks(
|
||||
|
||||
+1
-2
@@ -41,8 +41,7 @@ interface CachedQuarantineDao {
|
||||
/**
|
||||
* Atomic full-replace from the server snapshot — one transaction so
|
||||
* the `observeAll()` watcher sees a single merged emission, not a
|
||||
* delete-then-insert flicker. Mirrors Flutter's MyQuarantineController
|
||||
* `_refreshFromServer` transaction.
|
||||
* delete-then-insert flicker.
|
||||
*/
|
||||
@Transaction
|
||||
suspend fun replaceAll(rows: List<CachedQuarantineEntity>) {
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ interface CachedTrackDao {
|
||||
suspend fun upsertAll(rows: List<CachedTrackEntity>)
|
||||
|
||||
/**
|
||||
* ReplayGain values for [ids] (M464 #5000): the track's own from its row,
|
||||
* ReplayGain values for [ids]: the track's own from its row,
|
||||
* the album's from its album row. A track not in the cache has no row.
|
||||
*/
|
||||
@Query(
|
||||
|
||||
Vendored
+5
-7
@@ -7,14 +7,12 @@ import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* One row per fully-downloaded audio file. Mirrors
|
||||
* the Flutter client's `AudioCacheIndex` Drift table.
|
||||
*
|
||||
* Drives the 2-bucket LRU eviction (Phase 12 AudioCacheEvictionWorker):
|
||||
* - `incidental` files (streamed-and-cached side effect) evict first
|
||||
* - `autoPrefetch` second
|
||||
* - `manual` (user explicitly pinned) evict last
|
||||
* One row per track that has played through the audio cache (see
|
||||
* `CacheIndexer`); SimpleCache, not this table, says what is on disk.
|
||||
*
|
||||
* The `source` column is meant to drive a 2-bucket eviction
|
||||
* (incidental first, then auto-prefetch, manual last). Nothing evicts
|
||||
* by it yet: SimpleCache evicts by plain LRU over the rolling cap.
|
||||
* `cachedAt` = when we downloaded. `lastPlayedAt` = when the user last
|
||||
* played it; drives the offline "recently played" ordering and is the
|
||||
* tiebreaker for LRU eviction within a source bucket. Nullable on
|
||||
|
||||
+3
-3
@@ -39,7 +39,7 @@ data class AuthSessionEntity(
|
||||
*/
|
||||
val cacheSettingsJson: String? = null,
|
||||
/**
|
||||
* Per-device opt-out of diagnostics reporting (M9). When the
|
||||
* Per-device opt-out of diagnostics reporting. When the
|
||||
* account's debug mode is enabled by an admin, the user can still
|
||||
* turn reporting OFF on this device (battery/privacy) — that local
|
||||
* choice lives here. Default false = honor the account flag.
|
||||
@@ -47,12 +47,12 @@ data class AuthSessionEntity(
|
||||
val diagnosticsOptOut: Boolean = false,
|
||||
/**
|
||||
* JSON-encoded NormalizationPrefs (settings/data), the last value seen
|
||||
* from the server or set here (M464 #4998). Null = never fetched; the
|
||||
* from the server or set here. Null = never fetched; the
|
||||
* defaults apply. Kept so offline playback still levels.
|
||||
*/
|
||||
val normalizationJson: String? = null,
|
||||
/**
|
||||
* "Notifications when the app is closed" (M489 #5347): keep the
|
||||
* "Notifications when the app is closed": keep the
|
||||
* delivery foreground service running. A device choice, on by default.
|
||||
* The column default matches MIGRATION_11_12's, which Room checks.
|
||||
*/
|
||||
|
||||
+2
-5
@@ -5,10 +5,7 @@ import androidx.room.PrimaryKey
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Cache row for one album. Mirrors the Flutter client's
|
||||
* `CachedAlbums` Drift table.
|
||||
*/
|
||||
/** Cache row for one album. */
|
||||
@Entity(tableName = "cached_albums")
|
||||
data class CachedAlbumEntity(
|
||||
@PrimaryKey val id: String,
|
||||
@@ -18,7 +15,7 @@ data class CachedAlbumEntity(
|
||||
val releaseDate: String? = null,
|
||||
val coverPath: String? = null,
|
||||
val mbid: String? = null,
|
||||
// ReplayGain 2.0 album values (M464); null until every track is measured.
|
||||
// ReplayGain 2.0 album values; null until every track is measured.
|
||||
val albumGain: Float? = null,
|
||||
val albumPeak: Float? = null,
|
||||
val fetchedAt: Instant = Clock.System.now(),
|
||||
|
||||
+4
-6
@@ -6,13 +6,11 @@ import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Cache row for one artist. Mirrors the Flutter client's
|
||||
* `CachedArtists` Drift table.
|
||||
* Cache row for one artist.
|
||||
*
|
||||
* Column names follow Kotlin idiom (camelCase) rather than Drift's
|
||||
* snake_case — the schema is purely internal to the native client; the
|
||||
* sync controller exchanges snake_case JSON with the server and our
|
||||
* mappers translate at the boundary.
|
||||
* Column names follow Kotlin idiom (camelCase) — the schema is purely
|
||||
* internal to the app; the sync controller exchanges snake_case JSON
|
||||
* with the server and our mappers translate at the boundary.
|
||||
*/
|
||||
@Entity(tableName = "cached_artists")
|
||||
data class CachedArtistEntity(
|
||||
|
||||
Vendored
+1
-2
@@ -8,8 +8,7 @@ import kotlinx.datetime.Instant
|
||||
/**
|
||||
* Single-row snapshot of the `/api/me/history` response, stored as the
|
||||
* raw wire JSON so the History tab paints instantly from disk on cold
|
||||
* open and survives connectivity loss. Mirrors the Flutter Drift
|
||||
* `CachedHistorySnapshot` table (id defaults to 1; one row total).
|
||||
* open and survives connectivity loss. One row total; id defaults to 1.
|
||||
*
|
||||
* Cache-first + SWR: the tab observes this row and a background refresh
|
||||
* overwrites it, so the freshest plays land without a blocking spinner.
|
||||
|
||||
Vendored
+1
-2
@@ -5,8 +5,7 @@ import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Per-item row driving the Home screen sections. Mirrors
|
||||
* the Flutter client's `CachedHomeIndex` Drift table.
|
||||
* Per-item row driving the Home screen sections.
|
||||
*
|
||||
* `section` is one of (matching /api/home keys):
|
||||
* - "recently_added_albums"
|
||||
|
||||
+3
-4
@@ -5,14 +5,13 @@ import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Like membership row. Mirrors the Flutter client's
|
||||
* `CachedLikes` Drift table. Composite primary key — one user may
|
||||
* Like membership row. Composite primary key — one user may
|
||||
* independently like a track AND its album AND its artist; rows are
|
||||
* disambiguated by the (userId, entityType, entityId) triple.
|
||||
*
|
||||
* `entityType` is one of "track" | "album" | "artist". Stored as a
|
||||
* plain string for parity with the Drift schema and the server's wire
|
||||
* format; an enum doesn't earn its keep here.
|
||||
* plain string to match the server's wire format; an enum doesn't
|
||||
* earn its keep here.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "cached_likes",
|
||||
|
||||
Vendored
+2
-3
@@ -6,12 +6,11 @@ import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* One row per pending offline-write. Mirrors
|
||||
* the Flutter client's `CachedMutations` Drift table.
|
||||
* One row per pending offline write.
|
||||
*
|
||||
* MutationQueue.enqueue() inserts a row when a server-write fails with
|
||||
* an IOException; MutationReplayer.drain() pops and re-attempts each
|
||||
* row when connectivity returns (Phase 12.2).
|
||||
* row when connectivity returns.
|
||||
*
|
||||
* `kind` is a stable string registered in `MutationKind` — the replayer
|
||||
* looks up the handler in a kind→suspend-function map. Unknown kinds
|
||||
|
||||
Vendored
+1
-1
@@ -5,7 +5,7 @@ import androidx.room.PrimaryKey
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* One notice from the user's inbox (M489), kept so the Notifications screen
|
||||
* One notice from the user's inbox, kept so the Notifications screen
|
||||
* and the bell's badge work offline, and so a read made offline shows at once.
|
||||
* The newest page is cached; older notices are the server's to keep.
|
||||
*/
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Single-row copy of the user's notification settings (M489), stored as the
|
||||
* Single-row copy of the user's notification settings, stored as the
|
||||
* wire JSON, so the settings screen opens offline and a toggle shows at once.
|
||||
*/
|
||||
@Entity(tableName = "cached_notification_settings")
|
||||
|
||||
Vendored
+1
-2
@@ -6,8 +6,7 @@ import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Cache row for one playlist (user or system). Mirrors
|
||||
* the Flutter client's `CachedPlaylists` Drift table.
|
||||
* Cache row for one playlist (user or system).
|
||||
*
|
||||
* `systemVariant` is null for user playlists and one of
|
||||
* "for_you" / "songs_like_artist" / "discover" / "todays_mix" / etc.
|
||||
|
||||
Vendored
+1
-2
@@ -3,8 +3,7 @@ package com.fabledsword.minstrel.cache.db.entities
|
||||
import androidx.room.Entity
|
||||
|
||||
/**
|
||||
* Ordered membership of tracks within a playlist. Mirrors
|
||||
* the Flutter client's `CachedPlaylistTracks` Drift table.
|
||||
* Ordered membership of tracks within a playlist.
|
||||
* Composite PK so the same track can only appear once per playlist;
|
||||
* `position` carries the ordering.
|
||||
*/
|
||||
|
||||
Vendored
+1
-3
@@ -6,9 +6,7 @@ import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* The current user's quarantine flag for one track. Mirrors
|
||||
* the Flutter client's `CachedQuarantineMine` Drift
|
||||
* table.
|
||||
* The current user's quarantine flag for one track.
|
||||
*
|
||||
* The flat denormalized track/album/artist columns let the Quarantine
|
||||
* screen render off this single table without a join — the server
|
||||
|
||||
Vendored
+4
-7
@@ -7,18 +7,15 @@ import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Single-row snapshot of the last playback session — queue (as JSON),
|
||||
* current index, position, and source tag. Mirrors
|
||||
* the Flutter client's `CachedResumeState` Drift table.
|
||||
* current index, position, and source tag.
|
||||
*
|
||||
* Lets a torn-down session (the player's idle/dismissed teardown)
|
||||
* resume on next launch; without it the headset / lock-screen play
|
||||
* button has nothing to act on. The Flutter codebase persists this on
|
||||
* every queue change and reads on cold start.
|
||||
* button has nothing to act on.
|
||||
*
|
||||
* The `json` field carries the entire ResumeState shape (queue trackrefs,
|
||||
* currentIndex, positionMs, sourceTag). Kotlin-side deserialization
|
||||
* happens in the ResumeController (Phase 6.5) so the schema stays
|
||||
* stable across resume-shape changes.
|
||||
* currentIndex, positionMs, sourceTag). The ResumeController decodes it,
|
||||
* so the schema stays stable across resume-shape changes.
|
||||
*/
|
||||
@Entity(tableName = "cached_resume_state")
|
||||
data class CachedResumeStateEntity(
|
||||
|
||||
+3
-4
@@ -6,10 +6,9 @@ import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Cache row for one track. Mirrors the Flutter client's
|
||||
* `CachedTracks` Drift table.
|
||||
* Cache row for one track.
|
||||
*
|
||||
* [missing] carries the server's missing-file mark (#2704). Every read that
|
||||
* [missing] carries the server's missing-file mark. Every read that
|
||||
* can put a track in front of the user — or in a queue — must exclude it, and
|
||||
* the DAO queries do that rather than each call site remembering to.
|
||||
*/
|
||||
@@ -26,7 +25,7 @@ data class CachedTrackEntity(
|
||||
val fileFormat: String? = null,
|
||||
val genre: String? = null,
|
||||
val missing: Boolean = false,
|
||||
// ReplayGain 2.0 track values (M464), kept so cached audio levels
|
||||
// ReplayGain 2.0 track values, kept so cached audio levels
|
||||
// offline. Null until the server has measured the track.
|
||||
val trackGain: Float? = null,
|
||||
val trackPeak: Float? = null,
|
||||
|
||||
Vendored
+2
-2
@@ -4,14 +4,14 @@ import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* One buffered device-diagnostics event (M9). The DiagnosticsReporter
|
||||
* One buffered device-diagnostics event. The DiagnosticsReporter
|
||||
* writes rows here when the account's debug mode is on; the
|
||||
* DiagnosticsUploader drains them to POST /api/diagnostics and deletes
|
||||
* on success.
|
||||
*
|
||||
* This is a deliberate ring buffer that is NOT routed through the offline
|
||||
* MutationQueue: diagnostics are high-volume, best-effort telemetry, and
|
||||
* the bug we're chasing (roaming / dead-zone recovery) happens WHILE
|
||||
* the failures they record (roaming / dead-zone recovery) happen WHILE
|
||||
* offline — so events must persist locally through the dead zone and
|
||||
* upload on recovery, without clogging the user-data mutation replay.
|
||||
*
|
||||
|
||||
+2
-6
@@ -7,12 +7,8 @@ import kotlinx.datetime.Instant
|
||||
/**
|
||||
* Single-row table that tracks where the SyncController left off.
|
||||
*
|
||||
* Mirrors the Flutter Drift `sync_metadata` table. There is only ever
|
||||
* one row at `id = 0`; the SyncController upserts on every successful
|
||||
* `/api/sync` call.
|
||||
*
|
||||
* Pulled into Task 4.1 to give Room a compileable @Database (it needs
|
||||
* at least one entity); the SyncController itself lands in Phase 12.4.
|
||||
* There is only ever one row at `id = 0`; the SyncController upserts it
|
||||
* on every successful `/api/library/sync` call.
|
||||
*/
|
||||
@Entity(tableName = "sync_metadata")
|
||||
data class SyncMetadataEntity(
|
||||
|
||||
+18
-22
@@ -33,30 +33,29 @@ object MutationKind {
|
||||
|
||||
// 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).
|
||||
// a *whole* play when no server row exists. Recording a whole play on
|
||||
// background would duplicate the open row and leave it orphaned.
|
||||
const val PLAY_ENDED: String = "play_ended"
|
||||
|
||||
// #2374 suggestion snooze. ONE toggle kind rather than separate
|
||||
// Suggestion snooze. ONE toggle kind rather than separate
|
||||
// snooze/unsnooze kinds, mirroring LIKE_TOGGLE, so a snooze followed by
|
||||
// an undo collapses to the latest intent instead of replaying as two
|
||||
// opposed calls whose order decides the outcome.
|
||||
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
|
||||
|
||||
// M464 #4998 loudness-normalization preference. The payload is the whole
|
||||
// Loudness-normalization preference. The payload is the whole
|
||||
// preference, a target state like the toggles above, so queued changes
|
||||
// collapse to the last one and an older one can never be replayed last.
|
||||
const val NORMALIZATION_SET: String = "normalization_set"
|
||||
|
||||
// M489 notifications inbox. A read is a one-way action (read never goes
|
||||
// Notifications inbox. A read is a one-way action (read never goes
|
||||
// back to unread), so neither read kind needs collapsing. Read-all
|
||||
// carries the moment the user asked, so a late replay leaves newer
|
||||
// notices unread.
|
||||
const val NOTIFICATION_READ: String = "notification_read"
|
||||
const val NOTIFICATIONS_READ_ALL: String = "notifications_read_all"
|
||||
|
||||
// M489 per-kind channel setting. One row per (kind, channel) target
|
||||
// Per-kind notification channel. One row per (kind, channel) target
|
||||
// state, collapsed on that pair, so the newest choice is the one sent.
|
||||
const val NOTIFICATION_SETTING_SET: String = "notification_setting_set"
|
||||
}
|
||||
@@ -92,16 +91,13 @@ data class RequestCreatePayload(
|
||||
)
|
||||
|
||||
/**
|
||||
* Minimal write-side of the offline mutation queue. Phase 8 v1 only
|
||||
* enqueues — the connectivity-aware replayer that drains the queue
|
||||
* lands with Phase 12.2's SyncController.
|
||||
* Write side of the offline mutation queue; [MutationReplayer] drains it.
|
||||
*
|
||||
* Callers (LikesRepository et al.) follow the optimistic-write
|
||||
* pattern: mutate Room first so the UI reflects the change
|
||||
* immediately, fire the REST call as a best-effort attempt, and on
|
||||
* failure enqueue here so the replayer eventually pushes it through.
|
||||
* This matches `feedback_offline_first_for_server_writes` — writes
|
||||
* never go fire-and-forget.
|
||||
* Writes never go fire-and-forget.
|
||||
*/
|
||||
@Suppress("TooManyFunctions") // one enqueue per mutation kind, like the replayer's dispatchers
|
||||
@Singleton
|
||||
@@ -262,7 +258,7 @@ class MutationQueue @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted payload for `MutationKind.SUGGESTION_SNOOZE_TOGGLE` (#2374).
|
||||
* Persisted payload for `MutationKind.SUGGESTION_SNOOZE_TOGGLE`.
|
||||
* `desiredSnoozed` is the *target* state, matching [LikeTogglePayload], so
|
||||
* the replayer can collapse repeated toggles for one candidate down to the
|
||||
* last intent. Both directions are idempotent server-side: re-snoozing
|
||||
@@ -285,10 +281,10 @@ data class SuggestionSnoozeTogglePayload(
|
||||
data class QuarantineUnflagPayload(val trackId: String)
|
||||
|
||||
/**
|
||||
* Persisted payload for `MutationKind.PLAYLIST_APPEND`. Mirrors the
|
||||
* Flutter wire shape: list of track IDs to append to a specific
|
||||
* playlist. Server is idempotent at the (playlist_id, track_id) pair
|
||||
* level — re-firing the same payload yields no duplicates.
|
||||
* Persisted payload for `MutationKind.PLAYLIST_APPEND`: the track IDs to
|
||||
* append to one playlist. The server is idempotent at the
|
||||
* (playlist_id, track_id) pair level — re-firing the same payload yields
|
||||
* no duplicates.
|
||||
*/
|
||||
@Serializable
|
||||
data class PlaylistAppendPayload(
|
||||
@@ -314,7 +310,7 @@ data class QuarantineFlagPayload(
|
||||
* re-fires `POST /api/events` with type=play_offline; the server
|
||||
* records start+end from `atIso` (the original play-start time)
|
||||
* + `durationPlayedMs`, applies the canonical skip rule, and
|
||||
* advances #415 rotation when source is a system playlist.
|
||||
* advances rotation when source is a system playlist.
|
||||
*/
|
||||
@Serializable
|
||||
data class PlayOfflinePayload(
|
||||
@@ -323,7 +319,7 @@ data class PlayOfflinePayload(
|
||||
val atIso: String,
|
||||
val durationPlayedMs: Long,
|
||||
val source: String? = null,
|
||||
// #1551: device class for context conditioning; null on payloads queued
|
||||
// Device class for context conditioning; null on payloads queued
|
||||
// before this field existed (decodes to null → server stores NULL).
|
||||
val deviceClass: String? = null,
|
||||
)
|
||||
@@ -367,12 +363,12 @@ data class PlaybackErrorReportPayload(
|
||||
val clientId: String,
|
||||
)
|
||||
|
||||
/** Persisted payload for `MutationKind.NOTIFICATION_READ` (M489). */
|
||||
/** Persisted payload for `MutationKind.NOTIFICATION_READ`. */
|
||||
@Serializable
|
||||
data class NotificationReadPayload(val id: String)
|
||||
|
||||
/**
|
||||
* Persisted payload for `MutationKind.NOTIFICATIONS_READ_ALL` (M489).
|
||||
* Persisted payload for `MutationKind.NOTIFICATIONS_READ_ALL`.
|
||||
* `upToIso` is the newest notice the user could see when they asked; null
|
||||
* when the inbox was empty on the device, which marks everything.
|
||||
*/
|
||||
@@ -380,7 +376,7 @@ data class NotificationReadPayload(val id: String)
|
||||
data class NotificationsReadAllPayload(val upToIso: String?)
|
||||
|
||||
/**
|
||||
* Persisted payload for `MutationKind.NOTIFICATION_SETTING_SET` (M489): one
|
||||
* Persisted payload for `MutationKind.NOTIFICATION_SETTING_SET`: one
|
||||
* kind's one channel, as a target state. `channel` is "inbox" | "phone" |
|
||||
* "email". No defaults, so every field is always written.
|
||||
*/
|
||||
|
||||
+4
-4
@@ -160,7 +160,7 @@ class MutationReplayer @Inject constructor(
|
||||
/**
|
||||
* 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.
|
||||
* by [outcomeFor] — dispatchers never swallow them.
|
||||
*/
|
||||
private suspend fun dispatch(row: CachedMutationEntity): Outcome = when (row.kind) {
|
||||
MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload)
|
||||
@@ -182,7 +182,7 @@ class MutationReplayer @Inject constructor(
|
||||
else -> Outcome.DROP
|
||||
}
|
||||
|
||||
/** The notifications inbox's kinds (M489), split out to keep [dispatch] simple. */
|
||||
/** The notifications inbox's kinds, split out to keep [dispatch] simple. */
|
||||
private suspend fun dispatchNotification(row: CachedMutationEntity): Outcome = when (row.kind) {
|
||||
MutationKind.NOTIFICATION_READ -> dispatchNotificationRead(row.payload)
|
||||
MutationKind.NOTIFICATIONS_READ_ALL -> dispatchNotificationsReadAll(row.payload)
|
||||
@@ -354,8 +354,8 @@ class MutationReplayer @Inject constructor(
|
||||
/**
|
||||
* Row ids of desired-state toggles superseded by a later toggle for the same
|
||||
* entity. Applies to every kind whose payload encodes a TARGET state rather
|
||||
* than an action — like-toggles, suggestion snoozes (#2374) and the
|
||||
* normalization preference (#4998) — because
|
||||
* than an action — like-toggles, suggestion snoozes and the
|
||||
* normalization preference — because
|
||||
* replaying a stale one last would invert the final state.
|
||||
*
|
||||
* Top-level and pure so it can be unit-tested without standing up a Retrofit
|
||||
|
||||
+11
-13
@@ -34,9 +34,8 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Drives the delta sync against `/api/library/sync` (#357). Mirrors
|
||||
* the Flutter `SyncController`, scoped to the entity types the
|
||||
* native client cares about (artist/album/track). Likes and
|
||||
* Drives the delta sync against `/api/library/sync`, scoped to the
|
||||
* entity types the app caches (artist/album/track). Likes and
|
||||
* playlists get their own dedicated refresh paths so the sync
|
||||
* controller doesn't need to track them.
|
||||
*
|
||||
@@ -46,10 +45,9 @@ import javax.inject.Singleton
|
||||
* with a persisted cookie). Re-running on every cookie change is
|
||||
* what makes the Library tabs populate within seconds of sign-in.
|
||||
*
|
||||
* Wipes-on-410: when the server's compaction window has passed the
|
||||
* stored cursor, the response is 410 Gone. The controller drops the
|
||||
* three local caches and recurses with cursor=0 — same wipe behavior
|
||||
* as the Flutter port.
|
||||
* Restart-on-410: when the server's compaction window has passed the
|
||||
* stored cursor, the response is 410 Gone. The controller resets the
|
||||
* cursor to 0 and pulls the full entity set again (see [resetCursor]).
|
||||
*/
|
||||
@Singleton
|
||||
class SyncController @Inject constructor(
|
||||
@@ -110,11 +108,11 @@ class SyncController @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls pages until the server has nothing newer (#5654). The server
|
||||
* returns at most 1000 changes a call; taking one page per trigger left
|
||||
* the cache behind a change log that a full re-read grows by ~26,000
|
||||
* rows, so merge deletes never arrived and the freshness sweep kept
|
||||
* asking for tracks that were gone. The cursor is stored after each
|
||||
* Pulls pages until the server has nothing newer. The server returns
|
||||
* at most 1000 changes a call, and a full library re-read adds tens
|
||||
* of thousands, so one page per trigger would leave the cache behind:
|
||||
* merge deletes would never arrive and the freshness sweep would keep
|
||||
* asking for tracks that are gone. The cursor is stored after each
|
||||
* page, so an interrupted pass resumes where it stopped.
|
||||
*/
|
||||
private suspend fun sync() {
|
||||
@@ -153,7 +151,7 @@ class SyncController @Inject constructor(
|
||||
body.cursor > cursor
|
||||
}
|
||||
else -> {
|
||||
// Unhandled status — treat as a no-op for v1; the next
|
||||
// Unhandled status — treat as a no-op; the next
|
||||
// app start retries from the same cursor.
|
||||
false
|
||||
}
|
||||
|
||||
+12
-14
@@ -29,27 +29,25 @@ 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.
|
||||
* THE single authority on network/server reachability: the /healthz poll,
|
||||
* version parsing and the tri-state health derive live in one
|
||||
* signal-driven unit, so the app has one place that answers "can we reach
|
||||
* Minstrel?".
|
||||
*
|
||||
* 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).
|
||||
* immediate probe so recovery is near-instant.
|
||||
* - 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.
|
||||
* - a forced probe when the app returns to the foreground (#1209). Without
|
||||
* it a stale ServerDown outlived the condition that caused it: the poll
|
||||
* loop's delay() is throttled while screen-off/doze, so recovery waited on
|
||||
* whenever the OS next let the loop run. Meanwhile ServerDown makes
|
||||
* OfflineGatedDataSource refuse every uncached track, so the app declined
|
||||
* to play music that would have played fine.
|
||||
* - a forced probe when the app returns to the foreground. The poll
|
||||
* loop's delay() is throttled while screen-off/doze, so without it a
|
||||
* stale ServerDown would outlive its cause until the OS next let the
|
||||
* loop run — and ServerDown makes OfflineGatedDataSource refuse every
|
||||
* uncached track, declining music that would play fine.
|
||||
*
|
||||
* Version compatibility is a byproduct of the same /healthz response.
|
||||
*
|
||||
@@ -112,7 +110,7 @@ class NetworkStatusController @Inject constructor(
|
||||
|
||||
/**
|
||||
* App returned to the foreground — probe now rather than waiting for the
|
||||
* poll loop (#1209).
|
||||
* poll loop.
|
||||
*
|
||||
* The link-return probe in `init` does NOT cover this: it fires on a
|
||||
* connectivity *change*, and an app backgrounded on stable Wi-Fi sees none.
|
||||
@@ -172,7 +170,7 @@ class NetworkStatusController @Inject constructor(
|
||||
|
||||
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.
|
||||
// hydrates the real base URL — that false failure would 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
|
||||
|
||||
+5
-6
@@ -5,15 +5,14 @@ internal const val CORROBORATION_WINDOW_MS = 30_000L
|
||||
internal const val CORROBORATION_OP_THRESHOLD = 2
|
||||
|
||||
/**
|
||||
* Minimum gap between op failures for them to count as SEPARATE evidence
|
||||
* (#1209).
|
||||
* Minimum gap between op failures for them to count as SEPARATE evidence.
|
||||
*
|
||||
* A link handoff fails every in-flight request at once, so a burst is one
|
||||
* event producing N failures — not N independent observations that the server
|
||||
* is gone. Without this, two simultaneous failures corroborated each other
|
||||
* straight to Unreachable, and ServerDown makes OfflineGatedDataSource refuse
|
||||
* every uncached track. The app declined to play music that would have played
|
||||
* fine, for a blip that had already resolved.
|
||||
* is gone. Counted separately, two simultaneous failures would corroborate
|
||||
* each other straight to Unreachable, and ServerDown makes
|
||||
* OfflineGatedDataSource refuse every uncached track: the app would decline
|
||||
* music that plays fine, for a blip that had already resolved.
|
||||
*
|
||||
* 3s is comfortably above the sub-second window an OS handoff occupies while
|
||||
* still letting a genuine outage corroborate within seconds once a client
|
||||
|
||||
+6
-8
@@ -38,7 +38,7 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* On-device diagnostics collector (M9). Active only while the account's
|
||||
* On-device diagnostics collector. Active only while the account's
|
||||
* debug mode is enabled (server flag, read via AuthController.currentUser)
|
||||
* AND the user hasn't opted out locally. When active it taps existing
|
||||
* connectivity / player / UPnP / power seams READ-ONLY and writes a
|
||||
@@ -196,13 +196,11 @@ class DiagnosticsReporter @Inject constructor(
|
||||
/**
|
||||
* Catch the renderer rapidly leaving and re-entering PLAYING.
|
||||
*
|
||||
* The operator reports the Sonos "play pause play pause, like someone
|
||||
* pressing it every half second", usually as a track starts, cleared by a
|
||||
* manual pause or skip. Nothing here could see that: `player_state`
|
||||
* carries source/loading/error but not playing, `track_change` needs the
|
||||
* index to move, and the heartbeat samples once per 45s. The symptom fell
|
||||
* through every existing collector, which is why it has only ever been
|
||||
* described and never measured.
|
||||
* The symptom: a Sonos rapidly play-pause-play-pausing, about every half
|
||||
* second, usually as a track starts, cleared by a manual pause or skip.
|
||||
* No other collector sees it: `player_state` carries
|
||||
* source/loading/error but not playing, `track_change` needs the index
|
||||
* to move, and the heartbeat samples once per 45s.
|
||||
*
|
||||
* Records every raw transport change (cheap — steady playback produces
|
||||
* a couple per track) and, when they come in a burst, one summary event
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Drains the [DiagnosticEventDao] ring buffer to POST /api/diagnostics
|
||||
* (M9). Triggers mirror [com.fabledsword.minstrel.cache.mutations.MutationReplayer]:
|
||||
*. Triggers mirror [com.fabledsword.minstrel.cache.mutations.MutationReplayer]:
|
||||
* a periodic tick, a sign-in (sessionCookie non-null), and a server-health
|
||||
* recovery to [ServerHealth.Healthy] — so a dead-zone's buffered events
|
||||
* upload the moment the link comes back.
|
||||
|
||||
+3
-4
@@ -6,10 +6,9 @@ import com.fabledsword.minstrel.player.TransportObservation
|
||||
* Decides when a run of renderer transport changes is a *flap* — the renderer
|
||||
* repeatedly failing to settle — rather than an ordinary track transition.
|
||||
*
|
||||
* The operator reports the Sonos "play pause play pause, like someone pressing
|
||||
* it every half second", usually as a track starts. No diagnostic event could
|
||||
* see it, so it has been described several times and measured never. This is
|
||||
* the rule that decides when an episode is worth writing down.
|
||||
* The symptom: a Sonos rapidly play-pause-play-pausing, about every half
|
||||
* second, usually as a track starts. No other diagnostic event records it;
|
||||
* this is the rule that decides when an episode is worth writing down.
|
||||
*
|
||||
* Pure decision state, like [com.fabledsword.minstrel.player.RemoteStallWatchdog]:
|
||||
* the caller owns the flow and the recording, this only answers "is this an
|
||||
|
||||
+5
-10
@@ -29,16 +29,11 @@ enum class RequestOutcome { ACCEPTED, QUEUED }
|
||||
|
||||
/**
|
||||
* Read-through Discover surface: catalogue search, suggestion feed, and
|
||||
* the request-creation write path. Mirrors the Flutter
|
||||
* `DiscoverApi` + `mutationQueueProvider` integration —
|
||||
* `feedback_offline_first_for_server_writes` says writes never go
|
||||
* fire-and-forget, so `createRequest` falls back to the
|
||||
* MutationQueue on transport failure.
|
||||
* the request-creation write path. Writes never go fire-and-forget, so
|
||||
* `createRequest` falls back to the MutationQueue on transport failure.
|
||||
*
|
||||
* Reads are not cached locally yet — discovery results are inherently
|
||||
* transient (60s server-side LRU); the cache wins are minor and
|
||||
* unblock no UX. Phase 13's offline pass adds an offline scrollback
|
||||
* for the suggestion feed only.
|
||||
* Reads are not cached locally — discovery results are inherently
|
||||
* transient (60s server-side LRU), so a local cache would win little.
|
||||
*/
|
||||
@Singleton
|
||||
class DiscoverRepository @Inject constructor(
|
||||
@@ -54,7 +49,7 @@ class DiscoverRepository @Inject constructor(
|
||||
api.listSnoozes().map { it.toDomain() }
|
||||
|
||||
/**
|
||||
* Parks a suggestion ("not right now"). Offline-first per rule #100: on
|
||||
* Parks a suggestion ("not right now"). Offline-first: on
|
||||
* transport failure the target state is queued for the replayer rather
|
||||
* than dropped.
|
||||
*
|
||||
|
||||
@@ -195,8 +195,8 @@ private fun KindChips(kind: RequestKind, onChange: (RequestKind) -> Unit) {
|
||||
|
||||
/**
|
||||
* The snooze surface's data and callbacks, bundled rather than threaded
|
||||
* through as four more parameters — the pane grew from one action to three
|
||||
* with slice 4 and the signatures stopped being readable.
|
||||
* through as separate parameters: the pane carries three actions, and the
|
||||
* signatures stop being readable otherwise.
|
||||
*/
|
||||
private data class SnoozeUi(
|
||||
val locallySnoozedMbids: Set<String>,
|
||||
@@ -240,9 +240,9 @@ private fun SuggestionsList(
|
||||
) {
|
||||
item { SuggestionsHeader() }
|
||||
if (items.isEmpty()) {
|
||||
// An empty deck used to mean one thing — no listening signal yet.
|
||||
// With snoozing it can also mean "you parked them all", and telling
|
||||
// that user to go listen to something would be wrong advice.
|
||||
// An empty deck means one of two things — no listening signal yet,
|
||||
// or "you parked them all" — and telling the second user to go
|
||||
// listen to something would be wrong advice.
|
||||
item {
|
||||
CenteredMessage(
|
||||
if (snoozeUi.snoozes.isEmpty()) {
|
||||
|
||||
@@ -79,8 +79,8 @@ internal fun SuggestionTile(
|
||||
IconButton(onClick = onSnooze) {
|
||||
Icon(
|
||||
imageVector = Lucide.Clock,
|
||||
// Rule #101: the label states what happens, and passes no
|
||||
// judgement on the music. Never "not for me".
|
||||
// The label states what happens and passes no judgement on the
|
||||
// music: never "not for me".
|
||||
contentDescription = "Not right now — hide ${s.name} for a while",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -173,7 +173,7 @@ private fun Avatar(imageUrl: String, fallback: ImageVector, circular: Boolean) {
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// The server hands back its own art endpoint (relative, M502 #5519),
|
||||
// The server hands back its own art endpoint (relative),
|
||||
// which answers 404 when no source has an image: ServerImage resolves
|
||||
// the path and shows the icon while loading and on a miss.
|
||||
ServerImage(
|
||||
|
||||
@@ -68,7 +68,7 @@ class DiscoverViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
loadSuggestions()
|
||||
// Screen-level auto-recovery (issue #1245): re-run whichever pane
|
||||
// Screen-level auto-recovery: re-run whichever pane
|
||||
// failed while the server was unreachable once health returns.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect {
|
||||
|
||||
@@ -54,7 +54,7 @@ private const val EVENTS_BUFFER_CAPACITY = 64
|
||||
* retry. Without this a single blip silently kills cross-device
|
||||
* reactivity until the next app launch.
|
||||
* - [connected] says whether a stream is open. Background delivery
|
||||
* (M489 #5347) catches up on every rising edge: nothing replays a
|
||||
* catches up on every rising edge: nothing replays a
|
||||
* frame sent while the stream was down.
|
||||
*
|
||||
* The URL passes through the placeholder host that
|
||||
|
||||
@@ -4,8 +4,7 @@ import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* Parsed event from the server's SSE stream. Mirrors
|
||||
* the Flutter client's `LiveEvent`.
|
||||
* Parsed event from the server's SSE stream.
|
||||
*
|
||||
* - [kind] is the SSE `event:` field (e.g. "track.liked", "playlist.deleted").
|
||||
* - [userId] is the actor whose user-scoped state changed (empty for
|
||||
|
||||
@@ -51,7 +51,7 @@ class LiveEventsDispatcher @Inject constructor(
|
||||
"artist.liked",
|
||||
"artist.unliked",
|
||||
-> refreshLikes()
|
||||
// M489: a contentless nudge; the inbox refetches its newest page.
|
||||
// a contentless nudge; the inbox refetches its newest page.
|
||||
"notification.created" -> refreshNotifications()
|
||||
}
|
||||
// Other kinds (playlist.*, quarantine.*, request.status_changed,
|
||||
|
||||
@@ -3,14 +3,14 @@ package com.fabledsword.minstrel.events
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* How long [EventsStream] waits before reconnecting (M489 #5347).
|
||||
* How long [EventsStream] waits before reconnecting.
|
||||
*
|
||||
* The stream is wanted around the clock once notifications arrive with the app
|
||||
* closed, so a server that is down overnight must not cost a radio wakeup
|
||||
* every few seconds: Roundtable's flat 2s came to about 43,000 of them. The
|
||||
* wait doubles from [BASE_MS] to [MAX_MS]. The jitter spreads the moment every
|
||||
* phone on the server reconnects, which is when the server has just come back
|
||||
* and can least take a spike.
|
||||
* every few seconds: a flat 2s retry is 1,800 wakeups an hour. The wait
|
||||
* doubles from [BASE_MS] to [MAX_MS]. The jitter spreads the moment every
|
||||
* phone on the server reconnects, which is when the server has just come
|
||||
* back and can least take a spike.
|
||||
*
|
||||
* A network coming up (a hint, never a gate) or the app coming to the
|
||||
* foreground reconnects at once and starts the ladder over.
|
||||
|
||||
+3
-5
@@ -33,9 +33,7 @@ data class HistoryPage(
|
||||
)
|
||||
|
||||
/**
|
||||
* Cache-first listening history. Mirrors Flutter's `_historyProvider`
|
||||
* (`cacheFirst` over the `cached_history_snapshot` drift row with
|
||||
* `alwaysRefresh`) using the native Room + Flow idiom:
|
||||
* Cache-first listening history, in the Room + Flow idiom:
|
||||
*
|
||||
* - [observeHistory] emits the cached snapshot instantly (null until
|
||||
* the first fetch lands), so the History tab paints from disk on
|
||||
@@ -47,8 +45,8 @@ data class HistoryPage(
|
||||
*
|
||||
* The whole page is stored as one JSON blob (single-row table) rather
|
||||
* than per-row Room entities — history is a read-only timestamp-keyed
|
||||
* snapshot, so a blob is the simplest faithful mirror of the Drift
|
||||
* snapshot and avoids ordering/paging bookkeeping for no UX gain.
|
||||
* snapshot, so a blob is the simplest faithful copy and avoids
|
||||
* ordering/paging bookkeeping for no UX gain.
|
||||
*/
|
||||
@Singleton
|
||||
class HistoryRepository @Inject constructor(
|
||||
|
||||
@@ -86,7 +86,7 @@ class HistoryTabViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// Screen-level auto-recovery (issue #1245): re-pull when server
|
||||
// Screen-level auto-recovery: re-pull when server
|
||||
// health returns, so a load that failed offline heals unprompted.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect { refresh() }
|
||||
@@ -99,7 +99,7 @@ class HistoryTabViewModel @Inject constructor(
|
||||
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||
}
|
||||
|
||||
/** Single-track play, matching the Flutter history-row behavior. */
|
||||
/** Plays the tapped track on its own. */
|
||||
fun playTrack(track: TrackRef) {
|
||||
if (track.streamUrl.isEmpty()) return
|
||||
player.setQueue(listOf(track), initialIndex = 0, source = "history")
|
||||
|
||||
@@ -33,8 +33,7 @@ import javax.inject.Singleton
|
||||
* [MetadataProvider] Flow that emits the cached row (or null while it
|
||||
* hydrates) and fires a dedup'd, concurrency-gated on-miss fetch. A
|
||||
* null [HomeTile.value] renders as a skeleton in position; the tile
|
||||
* reveals when the fetch lands and Room re-emits. Mirrors Flutter's
|
||||
* per-item tile providers.
|
||||
* reveals when the fetch lands and Room re-emits.
|
||||
*
|
||||
* `refreshIndex()` pulls `GET /api/home/index`, swaps all sections in
|
||||
* one transaction (so the rows update together in a single emission
|
||||
@@ -99,8 +98,8 @@ class HomeRepository @Inject constructor(
|
||||
* One transaction for all seven sections is deliberate: Room notifies
|
||||
* observers once, on commit, so Home swaps from the old content to
|
||||
* the new in a single emission. Per-section, un-transacted writes
|
||||
* made every row visibly collapse to empty and refill, one after
|
||||
* another (issue #2327).
|
||||
* would make every row visibly collapse to empty and refill, one
|
||||
* after another.
|
||||
*/
|
||||
suspend fun refreshIndex() {
|
||||
val wire = api.getHomeIndex()
|
||||
@@ -141,7 +140,7 @@ class HomeRepository @Inject constructor(
|
||||
* the id list instead means a section whose contents didn't actually
|
||||
* move never restarts the `flatMapLatest` below, which would
|
||||
* otherwise tear down and rebuild all of its tiles' hydration flows
|
||||
* and flicker unchanged tiles (issue #2327).
|
||||
* and flicker unchanged tiles.
|
||||
*/
|
||||
private fun observeSectionIds(section: String): Flow<List<String>> =
|
||||
homeIndexDao.observeBySection(section)
|
||||
|
||||
@@ -133,7 +133,7 @@ private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
|
||||
// "Updating your mixes…" veil. UpdateVeilController decides both whether it
|
||||
// appears at all — only when a refresh actually changes something — and how
|
||||
// long it stays, by watching the screen settle rather than by a fixed delay,
|
||||
// which lowered it while tiles and artwork were still landing (#2327).
|
||||
// which would lower it while tiles and artwork are still landing.
|
||||
// Near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
|
||||
private const val VEIL_WIPE_MS = 280
|
||||
private const val VEIL_ALPHA = 0.96f
|
||||
@@ -244,9 +244,8 @@ class HomeViewModel @Inject constructor(
|
||||
|
||||
/**
|
||||
* Tap on a Most-played tile: replace the queue with the section's
|
||||
* tracks and start at the tapped index. Mirrors Flutter's
|
||||
* `_resolveSectionTracks` — Home's section is itself the playable
|
||||
* unit, not a list of album-detail links.
|
||||
* tracks and start at the tapped index. Home's section is itself the
|
||||
* playable unit, not a list of album-detail links.
|
||||
*/
|
||||
fun playMostPlayed(tracks: List<com.fabledsword.minstrel.models.TrackRef>, startIndex: Int) {
|
||||
if (tracks.isEmpty()) return
|
||||
@@ -259,13 +258,11 @@ class HomeViewModel @Inject constructor(
|
||||
|
||||
/**
|
||||
* Tap the album play-button overlay: fetch the album detail and
|
||||
* play its tracks from the first. Mirrors Flutter's AlbumCard
|
||||
* `_playAlbum`. Suspending so the button's spinner waits for the
|
||||
* fetch + queue setup; the inner work is launched in viewModelScope
|
||||
* so cancellation from leaving the screen mid-fetch doesn't kill
|
||||
* playback. Errors surface via the snackbar channel (Android
|
||||
* improvement over Flutter's silent-fail; matches the existing
|
||||
* ArtistDetail play handler).
|
||||
* play its tracks from the first. Suspending so the button's spinner
|
||||
* waits for the fetch + queue setup; the inner work is launched in
|
||||
* viewModelScope so cancellation from leaving the screen mid-fetch
|
||||
* doesn't kill playback. Errors surface via the snackbar channel,
|
||||
* like the ArtistDetail play handler.
|
||||
*/
|
||||
suspend fun playAlbum(albumId: String) {
|
||||
viewModelScope.launch {
|
||||
@@ -288,9 +285,8 @@ class HomeViewModel @Inject constructor(
|
||||
|
||||
/**
|
||||
* Tap the artist play-button overlay: fetch every track across the
|
||||
* artist's albums, Fisher-Yates shuffle, play from index 0. Mirrors
|
||||
* Flutter's ArtistCard `_playArtistShuffle`. Empty + error semantics
|
||||
* follow [playAlbum].
|
||||
* artist's albums, Fisher-Yates shuffle, play from index 0. Empty +
|
||||
* error semantics follow [playAlbum].
|
||||
*/
|
||||
suspend fun playArtistShuffled(artistId: String) {
|
||||
viewModelScope.launch {
|
||||
@@ -315,9 +311,8 @@ class HomeViewModel @Inject constructor(
|
||||
* Tap the playlist play-button overlay: fetch the playlist's tracks
|
||||
* (rotation-aware shuffle for refreshable system playlists, stored
|
||||
* order for everything else), 8s timeout, play from index 0.
|
||||
* Mirrors Flutter PlaylistCard `_playPlaylist`. Empty-mix and
|
||||
* timeout get their own copy strings; other errors fall through to
|
||||
* [ErrorCopy.fromThrowable].
|
||||
* Empty-mix and timeout get their own copy strings; other errors
|
||||
* fall through to [ErrorCopy.fromThrowable].
|
||||
*/
|
||||
suspend fun playPlaylist(playlist: PlaylistRef) {
|
||||
viewModelScope.launch {
|
||||
@@ -366,9 +361,9 @@ class HomeViewModel @Inject constructor(
|
||||
fun retry() = veil.request(userInitiated = true)
|
||||
|
||||
/**
|
||||
* Manual pull-to-refresh. Goes behind the veil like every other refresh
|
||||
* (operator call, 2026-07-31: the churn a pull causes is identical to
|
||||
* the automatic paths, and a small spinner didn't hide it).
|
||||
* Manual pull-to-refresh. Goes behind the veil like every other
|
||||
* refresh: a pull churns the screen exactly as the automatic paths
|
||||
* do, and a small spinner does not hide it.
|
||||
*
|
||||
* Suspends until the veil has taken over OR the session has finished,
|
||||
* so the pull indicator hands off to exactly one successor: the veil if
|
||||
@@ -488,7 +483,7 @@ class HomeViewModel @Inject constructor(
|
||||
* Only user-initiated sessions say anything. The same outcome from a
|
||||
* background check — the initial load, the 03:00 rebuild, a reconnect —
|
||||
* is noise, and "Already up to date" on every launch would be worse
|
||||
* than silence (operator's call, 2026-07-31).
|
||||
* than silence.
|
||||
*/
|
||||
private fun reportRefreshOutcome(result: VeilSessionResult) {
|
||||
if (!result.userInitiated) return
|
||||
@@ -505,7 +500,7 @@ class HomeViewModel @Inject constructor(
|
||||
*
|
||||
* Raised only when a refresh actually changes what's on screen, and then
|
||||
* held until Home settles — tiles hydrated, artwork loaded — instead of
|
||||
* for a fixed delay after the network pull returns (issue #2327). A
|
||||
* for a fixed delay after the network pull returns. A
|
||||
* refresh that returns what's already cached shows no veil at all;
|
||||
* [reportRefreshOutcome] tells the user instead, if they asked.
|
||||
*/
|
||||
@@ -515,9 +510,9 @@ class HomeViewModel @Inject constructor(
|
||||
// Every refresh path goes through the controller, which decides
|
||||
// per session whether to raise the veil. That includes the initial
|
||||
// load: over a warm cache it's a full re-pull that churns every
|
||||
// section, and it used to run completely unveiled.
|
||||
// section.
|
||||
veil.request()
|
||||
// Screen-level auto-recovery (issue #1245): a Home that failed to
|
||||
// Screen-level auto-recovery: a Home that failed to
|
||||
// load while the server was unreachable re-pulls itself the moment
|
||||
// health returns — same idiom as SyncController, one layer up.
|
||||
// This is also the recovery that keeps trying after the veil has
|
||||
@@ -525,9 +520,8 @@ class HomeViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect { veil.request() }
|
||||
}
|
||||
// Server-side changes that rewrite what Home renders (#968 and
|
||||
// the 2026-07-31 widening) re-pull behind the veil. Mirrors the
|
||||
// web SSE consumer.
|
||||
// Server-side changes that rewrite what Home renders re-pull
|
||||
// behind the veil, as the web SSE consumer does.
|
||||
viewModelScope.launch {
|
||||
eventsStream.events
|
||||
.filter { it.kind in VEILED_EVENT_KINDS }
|
||||
@@ -758,8 +752,7 @@ private fun HomeSuccessContent(
|
||||
// Always rendered: real system/user playlists, with
|
||||
// placeholder cards filling the For You / Discover slots
|
||||
// that haven't generated yet. When offline, the cache-backed
|
||||
// pool cards lead the row. Songs-like now lives in its own
|
||||
// dedicated row below (#1491), no longer inside this carousel.
|
||||
// pool cards lead the row. Songs-like has its own row below.
|
||||
PlaylistsRow(
|
||||
title = "Playlists",
|
||||
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
|
||||
@@ -769,10 +762,9 @@ private fun HomeSuccessContent(
|
||||
onPlayPlaylist = onPlayPlaylist,
|
||||
)
|
||||
}
|
||||
// Songs-like is the best-performing surface (#1491) — promoted out
|
||||
// of the Playlists carousel into its own row so it shows a wider
|
||||
// spread of "Songs like {artist}" mixes. Hidden when there's
|
||||
// nothing to show (offline with none cached).
|
||||
// Songs-like gets its own row so it can show a wider spread of
|
||||
// "Songs like {artist}" mixes than a carousel slot allows. Hidden
|
||||
// when there's nothing to show (offline with none cached).
|
||||
songsLikeSection(
|
||||
playlists = sections.playlists,
|
||||
status = systemStatus,
|
||||
@@ -805,11 +797,11 @@ private fun HomeSuccessContent(
|
||||
}
|
||||
|
||||
/**
|
||||
* The dedicated "Songs like…" row (#1491). Reuses [PlaylistsRow]'s card
|
||||
* The dedicated "Songs like…" row. Reuses [PlaylistsRow]'s card
|
||||
* rendering with a distinct title; hidden entirely when there's nothing
|
||||
* to show (offline with no cached mixes). Online with none generated yet
|
||||
* still shows a few placeholders so the building / seed-needed state is
|
||||
* visible, matching the pre-promotion carousel behavior.
|
||||
* visible.
|
||||
*/
|
||||
private fun LazyListScope.songsLikeSection(
|
||||
playlists: List<PlaylistRef>,
|
||||
@@ -1000,7 +992,7 @@ private fun EmptySection(title: String, body: String) {
|
||||
/**
|
||||
* Rediscover may have both an albums sub-row and an artists sub-row. When
|
||||
* both are present the artist row gets an empty title so the section
|
||||
* reads as a single visual block — matches the Flutter behavior.
|
||||
* reads as a single visual block.
|
||||
*/
|
||||
@Composable
|
||||
private fun RediscoverBlock(
|
||||
@@ -1164,18 +1156,12 @@ enum class OfflinePoolKind(val label: String) {
|
||||
* 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. Songs-like has its own dedicated row (#1491) via
|
||||
* [buildSongsLikeRow] and no longer appears in this carousel.
|
||||
* user-owned playlists. Songs-like has its own row via [buildSongsLikeRow].
|
||||
*
|
||||
* 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 (the Flutter client
|
||||
* `_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.
|
||||
*/
|
||||
internal fun buildPlaylistsRow(
|
||||
owned: List<PlaylistRef>,
|
||||
@@ -1204,7 +1190,7 @@ private fun buildOnlineRow(
|
||||
out += owned.firstOrNull { it.systemVariant == "discover" }
|
||||
?.let { PlaylistRowItem.Real(it) }
|
||||
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
|
||||
// Songs-like is no longer here — it has its own dedicated row (#1491).
|
||||
// Songs-like is not here: it has its own row.
|
||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
|
||||
}
|
||||
@@ -1213,8 +1199,8 @@ private fun buildOnlineRow(
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the dedicated Songs-like row (#1491): all "Songs like {artist}"
|
||||
* mixes the server generated, no longer capped to the 3 carousel slots.
|
||||
* Builds the dedicated Songs-like row: every "Songs like {artist}" mix
|
||||
* the server generated, uncapped.
|
||||
*
|
||||
* Online: every generated mix as a real card; when none exist yet, a few
|
||||
* placeholders so the building / seed-needed state stays visible.
|
||||
@@ -1244,7 +1230,7 @@ internal fun buildSongsLikeRow(
|
||||
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
|
||||
* only differs by dropping placeholders + partitioning available-first.
|
||||
* Songs-like is excluded here too — it renders in its own row via
|
||||
* [buildSongsLikeRow] in both online and offline modes (#1491).
|
||||
* [buildSongsLikeRow] in both online and offline modes.
|
||||
*/
|
||||
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
|
||||
val out = mutableListOf<PlaylistRef>()
|
||||
@@ -1266,7 +1252,7 @@ private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
|
||||
|
||||
// How many "Songs like…" placeholder cards the dedicated row shows while
|
||||
// the mixes haven't generated yet (building / seed-needed). Real mixes,
|
||||
// once generated, are shown in full and no longer capped by this (#1491).
|
||||
// once generated, are shown in full, uncapped.
|
||||
private const val SONGS_LIKE_PLACEHOLDER_SLOTS = 3
|
||||
|
||||
/**
|
||||
@@ -1308,8 +1294,7 @@ private fun ArtistsRow(
|
||||
/**
|
||||
* Most-played section. Renders compact track tiles (cover + title + artist).
|
||||
* Tapping a tile replaces the queue with the entire section starting
|
||||
* at that tile — mirrors Flutter's `_resolveSectionTracks` behaviour
|
||||
* where Home sections are themselves playable units.
|
||||
* at that tile — Home sections are themselves playable units.
|
||||
*/
|
||||
@Composable
|
||||
private fun MostPlayedRow(
|
||||
@@ -1373,12 +1358,8 @@ private const val MOST_PLAYED_TILE_HEIGHT_DP = 56
|
||||
private const val MOST_PLAYED_COVER_DP = 48
|
||||
|
||||
// 3 rows of MOST_PLAYED_TILE_HEIGHT_DP + 2 * 8dp inter-row spacing,
|
||||
// rounded up. Mirrors Flutter (`CompactTrackCard` in
|
||||
// the Flutter client) which
|
||||
// uses a horizontal-row card pattern - much denser than the square
|
||||
// per-track tiles that web uses (operator request 2026-06-01: "in the
|
||||
// flutter iteration the tiles were different and smaller so more of
|
||||
// them could fit in a smaller space").
|
||||
// rounded up. Horizontal-row cards, much denser than the square
|
||||
// per-track tiles web uses, so more tracks fit in less space.
|
||||
private const val MOST_PLAYED_GRID_HEIGHT_DP = 200
|
||||
|
||||
@Composable
|
||||
|
||||
+9
-12
@@ -23,10 +23,9 @@ import javax.inject.Singleton
|
||||
* server and upsert into Room — the Flow emissions then propagate
|
||||
* automatically.
|
||||
*
|
||||
* Constructs its own LibraryApi from the shared Retrofit. Per the
|
||||
* "repositories own their interfaces" pattern adopted in Phase 5.1's
|
||||
* NetworkModule, we don't @Provides per-endpoint Retrofit interfaces
|
||||
* — fewer Hilt bindings, locality of reference.
|
||||
* Constructs its own LibraryApi from the shared Retrofit: repositories
|
||||
* own their interfaces (see NetworkModule), so there is no @Provides
|
||||
* per endpoint — fewer Hilt bindings, locality of reference.
|
||||
*/
|
||||
// LibraryRepository owns reads + refreshes for three entity families
|
||||
// (artists / albums / tracks) plus shuffle. Function count scales with
|
||||
@@ -46,8 +45,7 @@ class LibraryRepository @Inject constructor(
|
||||
|
||||
// Joins each artist to its first cached album (by sort title) so the
|
||||
// tile cover derives from /api/albums/{id}/cover — cached_artists
|
||||
// stores no cover and sync carries none. Mirrors Flutter's
|
||||
// artistTileProvider JOIN.
|
||||
// stores no cover and sync carries none.
|
||||
fun observeArtists(): Flow<List<ArtistRef>> =
|
||||
combine(artistDao.observeAll(), albumDao.observeAll()) { artists, albums ->
|
||||
val firstAlbumByArtist = albums
|
||||
@@ -161,13 +159,12 @@ class LibraryRepository @Inject constructor(
|
||||
suspend fun shuffleLibrary(limit: Int = SHUFFLE_DEFAULT_LIMIT): List<TrackRef> =
|
||||
api.shuffleLibrary(limit = limit).map { it.toDomain() }
|
||||
|
||||
// ---- Browse axes (#367 / #2467) ----
|
||||
// ---- Browse axes ----
|
||||
//
|
||||
// Server-backed rather than cache-first, unlike everything above. The
|
||||
// cache mirrors the whole library but includes tracks whose files are
|
||||
// missing, with no flag to spot them (#2704), while the server's index
|
||||
// excludes them -- so a locally-derived index would quietly disagree with
|
||||
// the web client's. Revisit when #2704 lands.
|
||||
// Server-backed rather than cache-first, unlike everything above: the
|
||||
// server's index leaves out tracks whose files are missing and is the
|
||||
// one the web client shows. See LibraryApi for why the cache's own
|
||||
// missing mark is not used to build it locally.
|
||||
|
||||
/** Genre index, ordered by track count then name (server order). */
|
||||
suspend fun genres(): List<GenreCount> =
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ class AlbumDetailViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// Screen-level auto-recovery (issue #1245): a detail that failed to
|
||||
// Screen-level auto-recovery: a detail that failed to
|
||||
// load while the server was unreachable re-fetches when health
|
||||
// returns, so coming back to the screen shows content, not an error.
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -310,9 +310,9 @@ private fun ArtistHeader(
|
||||
|
||||
@Composable
|
||||
private fun ArtistAvatar(artist: ArtistRef, fallbackAlbumId: String? = null) {
|
||||
// Server coverUrl wins; otherwise mirror the server's "use the
|
||||
// first album's cover" rule via the loaded album list (Flutter
|
||||
// _ArtistAvatar). Bare Lucide.User only when neither exists.
|
||||
// Server coverUrl wins; otherwise use the first loaded album's
|
||||
// cover, the same rule the server applies. Bare Lucide.User only
|
||||
// when neither exists.
|
||||
val model = when {
|
||||
artist.coverUrl.isNotEmpty() -> artist.coverUrl
|
||||
fallbackAlbumId != null -> albumCoverPath(fallbackAlbumId)
|
||||
|
||||
+4
-5
@@ -72,7 +72,7 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// Screen-level auto-recovery (issue #1245). From Error, redo the
|
||||
// Screen-level auto-recovery. From Error, redo the
|
||||
// whole load; from Success, re-pull just the secondary sections —
|
||||
// their fetch failures are swallowed to emptyList, so a dead zone
|
||||
// during the first load leaves them permanently absent otherwise.
|
||||
@@ -139,10 +139,9 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
|
||||
/**
|
||||
* Pull every track across the artist's albums, shuffle, and start
|
||||
* playing. Mirrors Flutter's artist Play button — pressing Play on
|
||||
* an artist is implicitly "shuffle this artist", not "play album by
|
||||
* album in tracklist order". Network round-trip per click; fine
|
||||
* because the user explicitly tapped Play.
|
||||
* playing. Pressing Play on an artist means "shuffle this artist",
|
||||
* not "play album by album in tracklist order". One network
|
||||
* round-trip per click; fine because the user explicitly tapped Play.
|
||||
*/
|
||||
fun playArtist() {
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -42,15 +42,12 @@ data class AlbumBrowseState(
|
||||
}
|
||||
|
||||
/**
|
||||
* Backs the Genres and Years tabs (#2467), mirroring the web surfaces #367
|
||||
* shipped.
|
||||
* Backs the Genres and Years tabs, the same browse axes the web client has.
|
||||
*
|
||||
* Both indexes come from the server, which is a deliberate departure from the
|
||||
* cache-first Artists/Albums tabs beside them: the local cache includes tracks
|
||||
* whose files are missing and cannot tell you which (#2704), while the server's
|
||||
* index excludes them, so a locally-derived index would disagree with the web
|
||||
* client's. These two tabs therefore need a connection; the empty states say so
|
||||
* rather than looking broken.
|
||||
* Both indexes come from the server, unlike the cache-first Artists/Albums
|
||||
* tabs beside them: the server's index leaves out tracks whose files are
|
||||
* missing and matches the web client's. These two tabs therefore need a
|
||||
* connection; the empty states say so rather than looking broken.
|
||||
*/
|
||||
// Two browse axes, each with an index, a filter/sort or grouping, a
|
||||
// drill-down and a pager. The function count is two axes' worth of a
|
||||
|
||||
@@ -41,7 +41,7 @@ import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
|
||||
/**
|
||||
* Genres tab (#2467) — the Android half of the browse axis #367 shipped on web.
|
||||
* Genres tab: the genre browse axis, as on web.
|
||||
*
|
||||
* Two states in one tab rather than a navigation destination: the index, and
|
||||
* the albums for a chosen genre. Back returns to the index. A route would have
|
||||
|
||||
@@ -54,17 +54,11 @@ import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
|
||||
/**
|
||||
* Library tab. Seven-tab TabBar (Artists / Albums / Genres / Years /
|
||||
* History / Liked / Hidden), matching the web client's library tab bar.
|
||||
* Genres and Years arrived with #2467; the rest predate it and mirrored
|
||||
* the Flutter client.
|
||||
*
|
||||
* Artists + Albums are wired against the existing LibraryViewModel
|
||||
* (cache-first reads of cached_artists / cached_albums). The other
|
||||
* three tabs render placeholder EmptyStates pointing at the upcoming
|
||||
* data-layer phases:
|
||||
* - History → Phase 9 (server-side /api/me/history wiring)
|
||||
* - Liked → Phase 8 (likes write path + cached_likes hydration)
|
||||
* - Hidden → Phase 10 (quarantine flag/unflag UI lives in Admin slice
|
||||
* but the user-side "my hidden tracks" view comes here)
|
||||
* Artists + Albums are cache-first reads of cached_artists /
|
||||
* cached_albums through LibraryViewModel; Genres and Years read the
|
||||
* server through BrowseViewModel; History, Liked and Hidden are their
|
||||
* own tabs ([HistoryTab], [LikedTab], [HiddenTab]).
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -160,7 +154,7 @@ private const val TAB_LIKED = 5
|
||||
private const val TAB_HIDDEN = 6
|
||||
|
||||
// Genres and Years sit straight after Albums, matching the web tab bar's
|
||||
// order (#2467) -- they are browse axes over the same albums, so they belong
|
||||
// order -- they are browse axes over the same albums, so they belong
|
||||
// beside them rather than after the personal tabs.
|
||||
private val LIBRARY_TABS =
|
||||
listOf("Artists", "Albums", "Genres", "Years", "History", "Liked", "Hidden")
|
||||
|
||||
@@ -26,7 +26,7 @@ import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
|
||||
/**
|
||||
* Years tab (#2467). Same two-state shape as [GenresTab]: the decade-grouped
|
||||
* Years tab. Same two-state shape as [GenresTab]: the decade-grouped
|
||||
* index, then the albums for a chosen year.
|
||||
*
|
||||
* Albums with no release date are absent from this axis entirely — the server
|
||||
|
||||
@@ -25,16 +25,15 @@ import com.fabledsword.minstrel.shared.widgets.PlayCircleButton
|
||||
import androidx.compose.ui.Alignment
|
||||
|
||||
/**
|
||||
* One album tile. Mirrors the Flutter `AlbumCard` (~176dp wide, 144dp
|
||||
* square cover). Tap fires `onClick` — wired to `AlbumDetail` nav in
|
||||
* Phase 5.5. The tile stashes its `album` in [LocalDetailSeedCache]
|
||||
* One album tile (~176dp wide, 144dp square cover). Tap fires
|
||||
* `onClick`. The tile stashes its `album` in [LocalDetailSeedCache]
|
||||
* before firing onClick so the detail screen's AppBar renders title /
|
||||
* cover / counts immediately without waiting for the fetch.
|
||||
*
|
||||
* [onPlay] is an opt-in play-overlay handler. When non-null, a 44dp
|
||||
* circular play button is overlaid at the bottom-right of the cover
|
||||
* (mirrors Flutter's PlayCircleButton). Only Home wires it currently;
|
||||
* Library / Discover / detail surfaces leave it null.
|
||||
* circular play button is overlaid at the bottom-right of the cover.
|
||||
* Only Home wires it currently; Library / Discover / detail surfaces
|
||||
* leave it null.
|
||||
*/
|
||||
@Composable
|
||||
fun AlbumCard(
|
||||
|
||||
@@ -28,15 +28,13 @@ import com.fabledsword.minstrel.shared.widgets.PlayCircleButton
|
||||
|
||||
/**
|
||||
* One artist tile. Circular cover; name centered beneath. Tap fires
|
||||
* `onClick` — wired to `ArtistDetail` nav in Phase 5.5. Stashes the
|
||||
* `artist` in [LocalDetailSeedCache] on click so the ArtistDetail
|
||||
* AppBar renders the name before the fetch lands.
|
||||
* `onClick`. Stashes the `artist` in [LocalDetailSeedCache] on click
|
||||
* so the ArtistDetail AppBar renders the name before the fetch lands.
|
||||
*
|
||||
* [onPlay] is an opt-in play-overlay handler. When non-null, a 44dp
|
||||
* circular play button is overlaid at the bottom-right of the avatar
|
||||
* (mirrors Flutter's PlayCircleButton). Only Home wires it currently.
|
||||
* Padding is 4dp instead of 6dp because the circular avatar pulls the
|
||||
* button visually inward at the corner.
|
||||
* circular play button is overlaid at the bottom-right of the avatar.
|
||||
* Only Home wires it currently. Padding is 4dp instead of 6dp because
|
||||
* the circular avatar pulls the button visually inward at the corner.
|
||||
*/
|
||||
@Composable
|
||||
fun ArtistCard(
|
||||
|
||||
@@ -24,26 +24,21 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Cache-first reads + offline-first writes for likes. Mirrors the
|
||||
* Flutter `likesControllerProvider` + `cached_likes`-driven Liked tab
|
||||
* pattern.
|
||||
* Cache-first reads + offline-first writes for likes.
|
||||
*
|
||||
* 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.
|
||||
* The local `userId` discriminator is the server-side user UUID
|
||||
* (resolved via [AuthController.currentUser]); it falls back to
|
||||
* [ANONYMOUS_USER_ID] when no user is signed in, so rows in that bucket
|
||||
* stay queryable until the first authenticated [refreshIds] call
|
||||
* overwrites them. On user-switch (sign-out / sign-in as a different
|
||||
* user) the OUTGOING user's cached_likes rows are wiped so they don't
|
||||
* leak into the new session.
|
||||
*
|
||||
* Write path follows `feedback_offline_first_for_server_writes` —
|
||||
* never fire-and-forget:
|
||||
* Writes never go fire-and-forget:
|
||||
* 1. Optimistic Room mutation (UI flips instantly).
|
||||
* 2. Best-effort server call.
|
||||
* 3. On exception → enqueue in MutationQueue for the Phase 12.2
|
||||
* replayer to drain when connectivity returns.
|
||||
*
|
||||
* 3. On exception → enqueue in MutationQueue for the replayer to
|
||||
* drain when connectivity returns.
|
||||
* Reads expose Flows of hydrated DomainRefs. Per-tile hydration goes
|
||||
* through the existing Library DAOs — IDs without cached entity rows
|
||||
* are silently dropped (the SyncController fills the gap async).
|
||||
@@ -62,12 +57,10 @@ class LikesRepository @Inject constructor(
|
||||
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.
|
||||
// Clear the outgoing user's cached_likes rows when the signed-in
|
||||
// user changes. Discriminator-based queries already keep the wrong
|
||||
// user from SEEING them; 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 ->
|
||||
@@ -138,7 +131,7 @@ class LikesRepository @Inject constructor(
|
||||
*/
|
||||
suspend fun toggleLike(entityType: String, entityId: String, desiredState: Boolean): Boolean {
|
||||
// 1. Optimistic Room mutation — scoped to the signed-in user
|
||||
// (or the legacy "local" fallback for pre-#576 rows).
|
||||
// (or the "local" bucket when nobody is signed in).
|
||||
val uid = currentUserId()
|
||||
if (desiredState) {
|
||||
likeDao.upsertAll(
|
||||
@@ -157,8 +150,7 @@ class LikesRepository @Inject constructor(
|
||||
) {
|
||||
// Intentional swallow: the queue replays this later; surfacing
|
||||
// the network error here would force callers (UI) to handle it
|
||||
// even though it's already been buffered. The replayer logs
|
||||
// per-attempt diagnostics when it lands in Phase 12.2.
|
||||
// even though it's already been buffered.
|
||||
mutationQueue.enqueueLikeToggle(entityType, entityId, desiredState)
|
||||
false
|
||||
}
|
||||
@@ -166,17 +158,14 @@ class LikesRepository @Inject constructor(
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* cached_likes set with the server's canonical view. Called by the
|
||||
* LikedTab ViewModel on init and on a live likes event.
|
||||
*
|
||||
* 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.
|
||||
* A replace, not an upsert: upserts add rows for IDs the server has
|
||||
* but never remove the ones it dropped, so an unlike made on another
|
||||
* device would stay on the Liked tab forever. The 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()
|
||||
@@ -189,13 +178,10 @@ class LikesRepository @Inject constructor(
|
||||
}
|
||||
|
||||
companion object {
|
||||
// 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().
|
||||
// Fallback discriminator for when no user is signed in. Reads
|
||||
// query this bucket until the first authenticated refreshIds()
|
||||
// writes rows under the real user UUID; reads then switch over
|
||||
// via currentUserId().
|
||||
const val ANONYMOUS_USER_ID: String = "local"
|
||||
const val ENTITY_ARTIST: String = "artist"
|
||||
const val ENTITY_ALBUM: String = "album"
|
||||
|
||||
@@ -81,7 +81,7 @@ class LikedTabViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// Screen-level auto-recovery (issue #1245): re-pull when server
|
||||
// Screen-level auto-recovery: re-pull when server
|
||||
// health returns, so a load that failed offline heals unprompted.
|
||||
viewModelScope.launch {
|
||||
networkStatus.recoveries().collect { refresh() }
|
||||
@@ -122,8 +122,8 @@ class LikedTabViewModel @Inject constructor(
|
||||
|
||||
/**
|
||||
* Build a queue from the currently-displayed liked tracks and start
|
||||
* playback at [initialIndex]. Mirrors Flutter's tap-on-liked-track
|
||||
* behavior: the visible track list becomes the play queue. Tracks
|
||||
* playback at [initialIndex]: the visible track list becomes the
|
||||
* play queue. Tracks
|
||||
* with empty streamUrl are ignored as queue entries (the row that
|
||||
* routes here is itself click-gated on streamUrl).
|
||||
*/
|
||||
|
||||
@@ -7,11 +7,11 @@ import javax.inject.Singleton
|
||||
private const val TOP_N = 8
|
||||
|
||||
/**
|
||||
* Pre-warms the Room cache for likely-tap artist tiles on Home. Mirrors
|
||||
* Flutter's `MetadataPrefetcher`: conservative — artists only (a single
|
||||
* round-trip per id), top-8 per call, and once per id per session so
|
||||
* repeated index refreshes don't re-fetch. Albums are intentionally not
|
||||
* pre-warmed (their detail fetch fans out a track list).
|
||||
* Pre-warms the Room cache for likely-tap artist tiles on Home.
|
||||
* Conservative: artists only (a single round-trip per id), top-8 per
|
||||
* call, and once per id per session so repeated index refreshes don't
|
||||
* re-fetch. Albums are intentionally not pre-warmed (their detail fetch
|
||||
* fans out a track list).
|
||||
*
|
||||
* Fetches go through [MetadataProvider.refreshArtist], so they share the
|
||||
* same per-id dedup AND the 4-slot concurrency gate as on-render
|
||||
|
||||
@@ -29,10 +29,8 @@ private const val HTTP_NOT_FOUND = 404
|
||||
/**
|
||||
* Cache-first metadata reads with eager on-miss live-fetch.
|
||||
*
|
||||
* Mirrors Flutter's tile-provider pattern (`albumTileProvider(id)` /
|
||||
* `artistTileProvider(id)` / `trackTileProvider(id)`): subscribers
|
||||
* observe a per-ID Flow that emits the Room row immediately if
|
||||
* present, and otherwise triggers a single background fetch that
|
||||
* Subscribers observe a per-ID Flow that emits the Room row immediately
|
||||
* if present, and otherwise triggers a single background fetch that
|
||||
* upserts into Room — the flow then re-emits the populated row.
|
||||
*
|
||||
* Fetch dedup is per-ID via a ConcurrentHashMap of in-flight Jobs;
|
||||
@@ -57,9 +55,8 @@ class MetadataProvider @Inject constructor(
|
||||
private val inFlightTracks = ConcurrentHashMap<String, Job>()
|
||||
|
||||
// Caps concurrent on-miss fetches so a cold Home (~30 tiles) doesn't
|
||||
// fire ~30 parallel /api calls. Mirrors Flutter HydrationQueue's
|
||||
// _maxConcurrent = 4. Per-id dedup (above) is orthogonal: the gate
|
||||
// bounds distinct-id fetches; extras suspend on the semaphore.
|
||||
// fire ~30 parallel /api calls. Per-id dedup (above) is orthogonal:
|
||||
// the gate bounds distinct-id fetches; extras suspend on the semaphore.
|
||||
private val fetchGate = Semaphore(MAX_CONCURRENT_FETCHES)
|
||||
|
||||
/**
|
||||
@@ -123,7 +120,7 @@ class MetadataProvider @Inject constructor(
|
||||
* [onGone] runs when the server answers 404: the row no longer exists
|
||||
* there (a duplicate merge, a deleted file), so the cached copy is
|
||||
* dropped. Kept, it stays stale forever and [FreshnessSweeper] asks for
|
||||
* it every pass (#5654). Any other failure leaves the row for a retry.
|
||||
* it every pass. Any other failure leaves the row for a retry.
|
||||
*/
|
||||
private fun launchDeduped(
|
||||
inFlight: ConcurrentHashMap<String, Job>,
|
||||
|
||||
@@ -14,7 +14,7 @@ data class AdminQuarantineReportRef(
|
||||
/**
|
||||
* Aggregated quarantine row in the admin queue — all reports against a
|
||||
* single track collapsed into per-reason counts plus the underlying
|
||||
* per-user reports. Mirrors Flutter's `AdminQuarantineItem`.
|
||||
* per-user reports.
|
||||
*
|
||||
* `topReasonSummary` returns the most-cited reason, with a "(+N more)"
|
||||
* suffix when other distinct reasons exist.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.fabledsword.minstrel.models
|
||||
|
||||
/** Domain model for one tag-enrichment provider in the admin surface (#1521). */
|
||||
/** Domain model for one tag-enrichment provider in the admin surface. */
|
||||
data class AdminTagSourceRef(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.fabledsword.minstrel.models
|
||||
|
||||
/**
|
||||
* One admin-side user row. Mirrors Flutter's `AdminUser`. The
|
||||
* One admin-side user row. The
|
||||
* admin-scoped variant exposes `autoApproveRequests` and the
|
||||
* canonical `createdAt` that the user-scoped /me response omits.
|
||||
*/
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user