Merge PR #126: missing-file lifecycle, UPnP stall recovery, Android browse parity, Flutter client removed
release / Build signed APK (tag releases only) (push) Skipped
test-web / test (push) Successful in 1m5s
test-go / test (push) Successful in 1m26s
release / Build + push container image (push) Successful in 1m38s
release / Verify release artifacts (tag releases only) (push) Skipped
android / Build + lint + test (push) Successful in 4m54s
test-go / integration (push) Successful in 5m19s
release / Build signed APK (tag releases only) (push) Skipped
test-web / test (push) Successful in 1m5s
test-go / test (push) Successful in 1m26s
release / Build + push container image (push) Successful in 1m38s
release / Verify release artifacts (tag releases only) (push) Skipped
android / Build + lint + test (push) Successful in 4m54s
test-go / integration (push) Successful in 5m19s
This commit was merged in pull request #126.
This commit is contained in:
@@ -9,7 +9,6 @@ web/build
|
||||
# Flutter mobile client — built separately on developer machines / Flutter CI.
|
||||
# Including it in the Go build context wastes ~70 files and invalidates the
|
||||
# `COPY . .` layer cache on every Flutter-only change.
|
||||
flutter_client/
|
||||
|
||||
# Docs and IDE noise
|
||||
docs/
|
||||
|
||||
-14
@@ -52,20 +52,6 @@ GEMINI.md
|
||||
.windsurfrules
|
||||
.aider.conf.yml
|
||||
|
||||
# Flutter
|
||||
flutter_client/.dart_tool/
|
||||
flutter_client/.flutter-plugins
|
||||
flutter_client/.flutter-plugins-dependencies
|
||||
flutter_client/build/
|
||||
flutter_client/.idea/
|
||||
flutter_client/ios/Podfile.lock
|
||||
flutter_client/ios/Pods/
|
||||
flutter_client/android/.gradle/
|
||||
flutter_client/android/app/build/
|
||||
flutter_client/android/local.properties
|
||||
flutter_client/android/key.properties
|
||||
flutter_client/*.iml
|
||||
|
||||
# Native Android (Kotlin/Compose) — M8 rewrite
|
||||
android/.gradle/
|
||||
android/.kotlin/
|
||||
|
||||
-2
@@ -11,8 +11,6 @@ import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Read-through accessor for the admin cross-user requests queue.
|
||||
* Mirrors `flutter_client/lib/admin/admin_providers.dart`'s
|
||||
* AdminRequestsController.
|
||||
*
|
||||
* No Room caching — admin actions are infrequent and don't benefit
|
||||
* from offline scrollback. `approve` and `reject` fire direct REST
|
||||
|
||||
@@ -41,6 +41,7 @@ import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||
import com.fabledsword.minstrel.nav.AdminRequests
|
||||
import com.fabledsword.minstrel.nav.AdminTagSources
|
||||
import com.fabledsword.minstrel.nav.AdminUsers
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
@@ -112,6 +113,7 @@ fun AdminLandingScreen(
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
MinstrelTopAppBar(
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
@@ -42,6 +43,7 @@ fun AdminQuarantineScreen(
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
MinstrelTopAppBar(
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.models.RequestRef
|
||||
import com.fabledsword.minstrel.nav.AdminRequests
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
@@ -41,6 +42,7 @@ fun AdminRequestsScreen(
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
MinstrelTopAppBar(
|
||||
|
||||
@@ -35,6 +35,7 @@ import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.models.AdminTagSourceRef
|
||||
import com.fabledsword.minstrel.models.TagSourceTestResult
|
||||
import com.fabledsword.minstrel.nav.AdminTagSources
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
@@ -49,6 +50,7 @@ fun AdminTagSourcesScreen(
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
MinstrelTopAppBar(
|
||||
|
||||
@@ -49,6 +49,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.models.AdminUserRef
|
||||
import com.fabledsword.minstrel.nav.AdminUsers
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -127,6 +128,7 @@ private fun AdminUsersScaffold(
|
||||
onRevokeInvite: (String) -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
MinstrelTopAppBar(
|
||||
|
||||
@@ -8,8 +8,7 @@ import java.io.IOException
|
||||
|
||||
/**
|
||||
* Maps server error codes (and common transport failures) to
|
||||
* friendly, sentence-case copy. Mirrors
|
||||
* `flutter_client/assets/error-copy.json` + `error_copy.dart`.
|
||||
* friendly, sentence-case copy.
|
||||
*
|
||||
* Server errors are `{"error":{"code":"...","message":"..."}}`.
|
||||
* [fromThrowable] pulls the code out of a Retrofit [HttpException]'s
|
||||
|
||||
@@ -10,8 +10,7 @@ import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/admin/invites`. Mirrors
|
||||
* `flutter_client/lib/api/endpoints/admin_invites.dart`.
|
||||
* Retrofit interface for `/api/admin/invites`.
|
||||
*
|
||||
* Server TTL is hardcoded at 24h; the only configurable field is the
|
||||
* optional `note` on create.
|
||||
|
||||
+1
-2
@@ -6,8 +6,7 @@ import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/admin/quarantine`. Mirrors
|
||||
* `flutter_client/lib/api/endpoints/admin_quarantine.dart`.
|
||||
* Retrofit interface for `/api/admin/quarantine`.
|
||||
*
|
||||
* Three resolution endpoints:
|
||||
* - `resolve` → admin reviewed, no action taken (clears flags).
|
||||
|
||||
+1
-2
@@ -6,8 +6,7 @@ import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/admin/requests`. Mirrors
|
||||
* `flutter_client/lib/api/endpoints/admin_requests.dart`.
|
||||
* Retrofit interface for `/api/admin/requests`.
|
||||
*
|
||||
* Server returns the same `requestView` shape as the user-side
|
||||
* `/api/requests`, so RequestWire is reused. Different listing scope —
|
||||
|
||||
@@ -10,8 +10,7 @@ import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/admin/users`. Mirrors
|
||||
* `flutter_client/lib/api/endpoints/admin_users.dart`.
|
||||
* Retrofit interface for `/api/admin/users`.
|
||||
*
|
||||
* Note: the PUT-auto-approve body field is `auto_approve`, NOT
|
||||
* `auto_approve_requests` — the request shape differs from the
|
||||
|
||||
@@ -6,8 +6,7 @@ import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/auth`. Mirrors
|
||||
* `flutter_client/lib/api/endpoints/auth.dart`.
|
||||
* Retrofit interface for `/api/auth`.
|
||||
*
|
||||
* The actual session-cookie capture happens in
|
||||
* [com.fabledsword.minstrel.api.AuthCookieInterceptor]; we don't
|
||||
|
||||
@@ -14,7 +14,6 @@ import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* Retrofit interface for Discover / Lidarr search / request creation.
|
||||
* Mirrors `flutter_client/lib/api/endpoints/discover.dart`.
|
||||
*
|
||||
* `/api/lidarr/search` has a 60s LRU on the server so quick re-types
|
||||
* of the same query are cheap.
|
||||
|
||||
@@ -9,8 +9,7 @@ import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* Retrofit interface for `POST /api/events`. Mirrors the relevant
|
||||
* slice of `flutter_client/lib/api/endpoints/events.dart`. All four
|
||||
* Retrofit interface for `POST /api/events`. All four
|
||||
* variants share the same URL — the discriminator is in the request
|
||||
* body's `type` field. Server contract is best-effort per spec;
|
||||
* callers (the live path in PlayEventsReporter) swallow errors and
|
||||
|
||||
@@ -5,10 +5,9 @@ import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/me/history`. Mirrors the relevant
|
||||
* subset of `flutter_client/lib/api/endpoints/me.dart` (only
|
||||
* `history()`; profile / timezone / quarantine endpoints land with
|
||||
* their respective phases).
|
||||
* Retrofit interface for `/api/me/history` — history only. The profile,
|
||||
* timezone and quarantine endpoints on `/api/me` live with their own
|
||||
* features rather than here.
|
||||
*/
|
||||
interface HistoryApi {
|
||||
@GET("api/me/history")
|
||||
|
||||
@@ -4,10 +4,9 @@ import com.fabledsword.minstrel.models.wire.HomeIndexWire
|
||||
import retrofit2.http.GET
|
||||
|
||||
/**
|
||||
* Retrofit interface for the Home discovery endpoint. Mirrors
|
||||
* `flutter_client/lib/api/endpoints/home.dart` — just the ID-only
|
||||
* `/api/home/index` variant. The Flutter port has a heavier
|
||||
* `/api/home` (full embedded payload) too; we don't use it because
|
||||
* Retrofit interface for the Home discovery endpoint. Only the ID-only
|
||||
* `/api/home/index` variant is used. The server also serves a heavier
|
||||
* `/api/home` (full embedded payload); we don't use it because
|
||||
* the per-item hydration path (sync controller → Room → Flow) is
|
||||
* the only one the native client needs.
|
||||
*/
|
||||
|
||||
@@ -3,14 +3,16 @@ package com.fabledsword.minstrel.api.endpoints
|
||||
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
|
||||
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
|
||||
import com.fabledsword.minstrel.models.wire.ArtistWire
|
||||
import com.fabledsword.minstrel.models.wire.GenreCountWire
|
||||
import com.fabledsword.minstrel.models.wire.PagedAlbumsWire
|
||||
import com.fabledsword.minstrel.models.wire.TrackWire
|
||||
import com.fabledsword.minstrel.models.wire.YearCountWire
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* Retrofit interface for the server's native `/api/...` library surface.
|
||||
* Mirrors `flutter_client/lib/api/endpoints/library.dart` 1:1.
|
||||
*
|
||||
* Notes on shapes:
|
||||
* - `GET /api/artists/{id}` returns ArtistDetailWire (ArtistRef fields
|
||||
@@ -54,6 +56,49 @@ 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
|
||||
// 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.
|
||||
@GET("api/library/genres")
|
||||
suspend fun getGenres(): List<GenreCountWire>
|
||||
|
||||
@GET("api/library/years")
|
||||
suspend fun getAlbumYears(): List<YearCountWire>
|
||||
|
||||
/**
|
||||
* Albums carrying [genre] on any of their tracks.
|
||||
*
|
||||
* @Query, never @Path: "Rock/Pop" is a real ID3 tag and a slash cannot
|
||||
* survive a path segment. Retrofit percent-encodes query values correctly;
|
||||
* a @Path would either 404 or silently address a different genre.
|
||||
*/
|
||||
@GET("api/library/albums")
|
||||
suspend fun getAlbumsByGenre(
|
||||
@Query("genre") genre: String,
|
||||
@Query("limit") limit: Int,
|
||||
@Query("offset") offset: Int,
|
||||
): PagedAlbumsWire
|
||||
|
||||
/**
|
||||
* Albums released in an inclusive year range. Pass the same year twice for
|
||||
* a single year. Sending a genre alongside these is a deliberate 400 on the
|
||||
* server (`unsupported_filter_combination`) -- they are separate axes.
|
||||
*/
|
||||
@GET("api/library/albums")
|
||||
suspend fun getAlbumsByYear(
|
||||
@Query("year_from") yearFrom: Int,
|
||||
@Query("year_to") yearTo: Int,
|
||||
@Query("limit") limit: Int,
|
||||
@Query("offset") offset: Int,
|
||||
): PagedAlbumsWire
|
||||
|
||||
private companion object {
|
||||
const val SIMILAR_ARTISTS_LIMIT = 12
|
||||
const val TOP_TRACKS_LIMIT = 5
|
||||
|
||||
@@ -7,8 +7,7 @@ import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/likes`. Mirrors
|
||||
* `flutter_client/lib/api/endpoints/likes.dart`.
|
||||
* Retrofit interface for `/api/likes`.
|
||||
*
|
||||
* Path segment `kind` is one of "artists" | "albums" | "tracks"
|
||||
* (plural, matching the server route). The Repository hides that
|
||||
|
||||
@@ -11,7 +11,6 @@ import retrofit2.http.PUT
|
||||
|
||||
/**
|
||||
* Retrofit interface for the `/api/me` endpoints — caller-scoped account endpoints.
|
||||
* Mirrors the relevant slice of `flutter_client/lib/api/endpoints/settings.dart`.
|
||||
*
|
||||
* History + timezone + system-playlists-status live under /api/me too
|
||||
* but are handled by their respective feature repositories; this
|
||||
|
||||
@@ -11,8 +11,7 @@ import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/playlists`. Mirrors
|
||||
* `flutter_client/lib/api/endpoints/playlists.dart`.
|
||||
* Retrofit interface for `/api/playlists`.
|
||||
*/
|
||||
interface PlaylistsApi {
|
||||
/**
|
||||
@@ -54,7 +53,7 @@ interface PlaylistsApi {
|
||||
* 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
|
||||
* the stored order. Mirrors `playlists.dart.systemShuffle`.
|
||||
* the stored order.
|
||||
*/
|
||||
@GET("api/playlists/system/{kind}/shuffle")
|
||||
suspend fun systemShuffle(@Path("kind") variant: String): PlaylistDetailWire
|
||||
|
||||
@@ -8,9 +8,8 @@ import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/quarantine`. Mirrors the relevant
|
||||
* parts of `flutter_client/lib/api/endpoints/quarantine.dart` (flag
|
||||
* and unflag) plus the `/api/quarantine/mine` endpoint from `me.dart`.
|
||||
* Retrofit interface for `/api/quarantine`: flag and unflag, plus the
|
||||
* `/api/quarantine/mine` listing.
|
||||
*
|
||||
* Both flag and unflag are user-scoped — callers act on their own
|
||||
* quarantine entries. The cross-user admin surface is a separate
|
||||
|
||||
@@ -5,9 +5,7 @@ import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* Retrofit interface for `/api/radio`. Mirrors the relevant slice of
|
||||
* `flutter_client/lib/api/endpoints/radio.dart` (a single GET that
|
||||
* returns the seeded queue). The server picks a fresh shuffle each
|
||||
* Retrofit interface for `/api/radio`. The server picks a fresh shuffle each
|
||||
* invocation — clients call this once per radio start.
|
||||
*/
|
||||
interface RadioApi {
|
||||
|
||||
@@ -6,8 +6,7 @@ import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* Retrofit interface for the user-side `/api/requests`. Mirrors
|
||||
* `flutter_client/lib/api/endpoints/requests.dart`.
|
||||
* Retrofit interface for the user-side `/api/requests`.
|
||||
*
|
||||
* Server scopes results to the caller — admins see only their own
|
||||
* requests through this endpoint. The cross-user admin view lives on
|
||||
|
||||
@@ -5,8 +5,7 @@ import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* Retrofit interface for `GET /api/search`. Mirrors
|
||||
* `flutter_client/lib/api/endpoints/search.dart`. Server returns 400
|
||||
* Retrofit interface for `GET /api/search`. Server returns 400
|
||||
* on empty/whitespace-only `q` — the caller is responsible for
|
||||
* guarding.
|
||||
*/
|
||||
|
||||
@@ -17,8 +17,7 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Singleton facade over the auth state machine. Mirrors Flutter's
|
||||
* `AuthController` from `auth_provider.dart`.
|
||||
* Singleton facade over the auth state machine.
|
||||
*
|
||||
* Cookie persistence is handled by [AuthCookieInterceptor] capturing
|
||||
* Set-Cookie on the login response; the user identity itself
|
||||
|
||||
@@ -12,8 +12,7 @@ import javax.inject.Singleton
|
||||
private const val POOL_LIMIT = 100
|
||||
|
||||
/**
|
||||
* Offline play sources over the local audio-cache index. Mirrors
|
||||
* `flutter_client/lib/cache/shuffle_source.dart`.
|
||||
* Offline play sources over the local audio-cache index.
|
||||
*
|
||||
* Both pools are UNIONs over the cache regardless of storage bucket
|
||||
* (liked AND recently-played both included). The two-bucket split is
|
||||
@@ -58,6 +57,14 @@ class ShuffleSource @Inject constructor(
|
||||
private suspend fun materialize(orderedIds: List<String>): List<TrackRef> {
|
||||
if (orderedIds.isEmpty()) return emptyList()
|
||||
val byId = trackDao.getByIds(orderedIds).associateBy { it.id }
|
||||
return orderedIds.mapNotNull { byId[it]?.toDomain() }
|
||||
return orderedIds.mapNotNull { id ->
|
||||
// Clear the server's missing mark (#2704). 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
|
||||
// would otherwise throw away tracks that work perfectly. Missing
|
||||
// means "cannot stream", not "cannot play".
|
||||
byId[id]?.toDomain()?.copy(unavailable = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
package com.fabledsword.minstrel.cache.audiocache
|
||||
|
||||
/**
|
||||
* Defaults for the 2-bucket audio cache. Matches the Flutter client.
|
||||
* Defaults for the 2-bucket audio cache.
|
||||
*
|
||||
* - `likedCapBytes`: cap for the protected bucket — cached files for
|
||||
* tracks the user has liked. Evicted only after the rolling bucket
|
||||
|
||||
+1
-2
@@ -6,8 +6,7 @@ private const val FIVE_GIB_BYTES = 5L * 1024 * 1024 * 1024
|
||||
private const val DEFAULT_PREFETCH_WINDOW = 5
|
||||
|
||||
/**
|
||||
* User-tunable audio cache settings. Mirrors Flutter's `CacheSettings`
|
||||
* (cache_settings_provider.dart) field-for-field. Persisted as a JSON
|
||||
* User-tunable audio cache settings. Persisted as a JSON
|
||||
* blob on the auth_session single-row table via [AuthStore].
|
||||
*
|
||||
* - [likedCapBytes]: budget for cached files of liked tracks. 0 means
|
||||
|
||||
@@ -65,9 +65,13 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
||||
AuthSessionEntity::class,
|
||||
DiagnosticEventEntity::class,
|
||||
],
|
||||
// v8: + cached_tracks.missing, the server's missing-file mark (#2704),
|
||||
// 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.
|
||||
version = 7,
|
||||
// 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.
|
||||
version = 8,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(MinstrelTypeConverters::class)
|
||||
|
||||
-1
@@ -59,7 +59,6 @@ interface CachedPlaylistDao {
|
||||
|
||||
/**
|
||||
* Atomically reconciles the cache against the fresh list response.
|
||||
* Mirrors `flutter_client/lib/playlists/playlists_provider.dart:54` —
|
||||
* `BuildSystemPlaylists` rotates system-playlist UUIDs every
|
||||
* rebuild, so upsert alone leaves stale rows whose detail fetch
|
||||
* 404s. Delete any of the user's rows not in [freshOwnedIds] (this
|
||||
|
||||
Vendored
+1
-1
@@ -8,7 +8,7 @@ import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* One row per fully-downloaded audio file. Mirrors
|
||||
* `flutter_client/lib/cache/db.dart`'s `AudioCacheIndex` Drift table.
|
||||
* 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
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Cache row for one album. Mirrors `flutter_client/lib/cache/db.dart`'s
|
||||
* Cache row for one album. Mirrors the Flutter client's
|
||||
* `CachedAlbums` Drift table.
|
||||
*/
|
||||
@Entity(tableName = "cached_albums")
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Cache row for one artist. Mirrors `flutter_client/lib/cache/db.dart`'s
|
||||
* Cache row for one artist. Mirrors the Flutter client's
|
||||
* `CachedArtists` Drift table.
|
||||
*
|
||||
* Column names follow Kotlin idiom (camelCase) rather than Drift's
|
||||
|
||||
Vendored
+1
-1
@@ -6,7 +6,7 @@ import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Per-item row driving the Home screen sections. Mirrors
|
||||
* `flutter_client/lib/cache/db.dart`'s `CachedHomeIndex` Drift table.
|
||||
* the Flutter client's `CachedHomeIndex` Drift table.
|
||||
*
|
||||
* `section` is one of (matching /api/home keys):
|
||||
* - "recently_added_albums"
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Like membership row. Mirrors `flutter_client/lib/cache/db.dart`'s
|
||||
* Like membership row. Mirrors the Flutter client's
|
||||
* `CachedLikes` Drift table. 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.
|
||||
|
||||
Vendored
+1
-1
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* One row per pending offline-write. Mirrors
|
||||
* `flutter_client/lib/cache/db.dart`'s `CachedMutations` Drift table.
|
||||
* the Flutter client's `CachedMutations` Drift table.
|
||||
*
|
||||
* MutationQueue.enqueue() inserts a row when a server-write fails with
|
||||
* an IOException; MutationReplayer.drain() pops and re-attempts each
|
||||
|
||||
Vendored
+1
-1
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Cache row for one playlist (user or system). Mirrors
|
||||
* `flutter_client/lib/cache/db.dart`'s `CachedPlaylists` Drift table.
|
||||
* the Flutter client's `CachedPlaylists` Drift table.
|
||||
*
|
||||
* `systemVariant` is null for user playlists and one of
|
||||
* "for_you" / "songs_like_artist" / "discover" / "todays_mix" / etc.
|
||||
|
||||
Vendored
+1
-1
@@ -4,7 +4,7 @@ import androidx.room.Entity
|
||||
|
||||
/**
|
||||
* Ordered membership of tracks within a playlist. Mirrors
|
||||
* `flutter_client/lib/cache/db.dart`'s `CachedPlaylistTracks` Drift table.
|
||||
* the Flutter client's `CachedPlaylistTracks` Drift table.
|
||||
* Composite PK so the same track can only appear once per playlist;
|
||||
* `position` carries the ordering.
|
||||
*/
|
||||
|
||||
Vendored
+1
-1
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* The current user's quarantine flag for one track. Mirrors
|
||||
* `flutter_client/lib/cache/db.dart`'s `CachedQuarantineMine` Drift
|
||||
* the Flutter client's `CachedQuarantineMine` Drift
|
||||
* table.
|
||||
*
|
||||
* The flat denormalized track/album/artist columns let the Quarantine
|
||||
|
||||
Vendored
+1
-1
@@ -8,7 +8,7 @@ import kotlinx.datetime.Instant
|
||||
/**
|
||||
* Single-row snapshot of the last playback session — queue (as JSON),
|
||||
* current index, position, and source tag. Mirrors
|
||||
* `flutter_client/lib/cache/db.dart`'s `CachedResumeState` Drift table.
|
||||
* the Flutter client's `CachedResumeState` Drift table.
|
||||
*
|
||||
* Lets a torn-down session (the player's idle/dismissed teardown)
|
||||
* resume on next launch; without it the headset / lock-screen play
|
||||
|
||||
+6
-1
@@ -6,8 +6,12 @@ import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Cache row for one track. Mirrors `flutter_client/lib/cache/db.dart`'s
|
||||
* Cache row for one track. Mirrors the Flutter client's
|
||||
* `CachedTracks` Drift table.
|
||||
*
|
||||
* [missing] carries the server's missing-file mark (#2704). 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.
|
||||
*/
|
||||
@Entity(tableName = "cached_tracks")
|
||||
data class CachedTrackEntity(
|
||||
@@ -21,5 +25,6 @@ data class CachedTrackEntity(
|
||||
val filePath: String? = null,
|
||||
val fileFormat: String? = null,
|
||||
val genre: String? = null,
|
||||
val missing: Boolean = false,
|
||||
val fetchedAt: Instant = Clock.System.now(),
|
||||
)
|
||||
|
||||
+1
@@ -219,4 +219,5 @@ private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
|
||||
filePath = filePath,
|
||||
fileFormat = fileFormat,
|
||||
genre = genre,
|
||||
missing = missing,
|
||||
)
|
||||
|
||||
@@ -42,6 +42,7 @@ import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||
import com.fabledsword.minstrel.nav.Discover
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
@@ -61,6 +62,7 @@ fun DiscoverScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
MinstrelTopAppBar(
|
||||
|
||||
@@ -38,8 +38,7 @@ private const val BACKOFF_FACTOR = 2
|
||||
* ViewModels + the central [LiveEventsDispatcher]) collect filtered
|
||||
* subsets of the stream.
|
||||
*
|
||||
* Connection lifecycle mirrors
|
||||
* `flutter_client/lib/shared/live_events_provider.dart`:
|
||||
* Connection lifecycle:
|
||||
* - Gated on having a session cookie. Subscription opens when the
|
||||
* cookie transitions to non-null and closes when it transitions
|
||||
* back to null (sign-out).
|
||||
|
||||
@@ -5,7 +5,7 @@ import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* Parsed event from the server's SSE stream. Mirrors
|
||||
* `flutter_client/lib/shared/live_events_provider.dart`'s `LiveEvent`.
|
||||
* the Flutter client's `LiveEvent`.
|
||||
*
|
||||
* - [kind] is the SSE `event:` field (e.g. "track.liked", "playlist.deleted").
|
||||
* - [userId] is the actor whose user-scoped state changed (empty for
|
||||
|
||||
@@ -11,8 +11,7 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Maps incoming [LiveEvent]s to cross-screen state refreshes. Mirrors
|
||||
* `flutter_client/lib/shared/live_events_dispatcher.dart`. Activated
|
||||
* Maps incoming [LiveEvent]s to cross-screen state refreshes. Activated
|
||||
* by force-@Inject in MinstrelApplication.
|
||||
*
|
||||
* Scope is deliberately narrow: this dispatcher only touches state
|
||||
|
||||
@@ -204,8 +204,7 @@ private const val HOURS_PER_DAY = 24L
|
||||
private const val DAYS_PER_WEEK = 7L
|
||||
|
||||
/**
|
||||
* Lightweight relative-time formatter mirroring Flutter's
|
||||
* `library_screen.dart`'s `_relativeTime`:
|
||||
* Lightweight relative-time formatter:
|
||||
*
|
||||
* < 1h → "Nm ago"
|
||||
* < 24h → "Nh ago"
|
||||
|
||||
@@ -91,6 +91,7 @@ import com.fabledsword.minstrel.shared.VeilOutcome
|
||||
import com.fabledsword.minstrel.shared.VeilSessionResult
|
||||
import com.fabledsword.minstrel.shared.VeilSettleState
|
||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.ArtSettleTracker
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
@@ -564,6 +565,7 @@ fun HomeScreen(
|
||||
viewModel.transientMessages.collect { snackbarHostState.showSnackbar(it) }
|
||||
}
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
MinstrelTopAppBar(
|
||||
@@ -1170,7 +1172,7 @@ enum class OfflinePoolKind(val label: String) {
|
||||
* first / greyed after, and the "building/pending" placeholders are dropped
|
||||
* (they need the server to generate, so they're meaningless offline).
|
||||
*
|
||||
* Diverges from Flutter (`flutter_client/lib/library/home_screen.dart`
|
||||
* Diverges from Flutter (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.
|
||||
@@ -1372,7 +1374,7 @@ 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
|
||||
// flutter_client/lib/library/widgets/compact_track_card.dart) which
|
||||
// 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
|
||||
|
||||
@@ -97,6 +97,7 @@ fun CachedTrackEntity.toDomain(
|
||||
trackNumber = trackNumber,
|
||||
discNumber = discNumber,
|
||||
durationSec = durationMs.millisToSeconds(),
|
||||
unavailable = missing,
|
||||
// Deterministic from track id; matches the server's stream_url
|
||||
// (internal/api/convert.go:75 streamURL builder). Cached rows
|
||||
// didn't carry streamUrl before, which left MetadataProvider-
|
||||
@@ -121,6 +122,7 @@ fun TrackWire.toDomain(): TrackRef =
|
||||
discNumber = discNumber,
|
||||
durationSec = durationSec,
|
||||
streamUrl = streamUrl,
|
||||
unavailable = unavailable,
|
||||
)
|
||||
|
||||
fun ArtistWire.toDomain(): ArtistRef =
|
||||
|
||||
@@ -161,7 +161,52 @@ 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) ----
|
||||
//
|
||||
// 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.
|
||||
|
||||
/** Genre index, ordered by track count then name (server order). */
|
||||
suspend fun genres(): List<GenreCount> =
|
||||
api.getGenres().map { GenreCount(genre = it.genre, trackCount = it.trackCount) }
|
||||
|
||||
/** Year index, newest first. Albums with no release date are absent. */
|
||||
suspend fun albumYears(): List<YearCount> =
|
||||
api.getAlbumYears().map { YearCount(year = it.year, albumCount = it.albumCount) }
|
||||
|
||||
/** One page of albums carrying [genre] on any track. */
|
||||
suspend fun albumsByGenre(genre: String, limit: Int, offset: Int): AlbumPage {
|
||||
val page = api.getAlbumsByGenre(genre = genre, limit = limit, offset = offset)
|
||||
return AlbumPage(items = page.items.map { it.toDomain() }, total = page.total)
|
||||
}
|
||||
|
||||
/** One page of albums released in [year]. */
|
||||
suspend fun albumsByYear(year: Int, limit: Int, offset: Int): AlbumPage {
|
||||
val page = api.getAlbumsByYear(
|
||||
yearFrom = year,
|
||||
yearTo = year,
|
||||
limit = limit,
|
||||
offset = offset,
|
||||
)
|
||||
return AlbumPage(items = page.items.map { it.toDomain() }, total = page.total)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SHUFFLE_DEFAULT_LIMIT = 100
|
||||
}
|
||||
}
|
||||
|
||||
/** One row of the genre index. */
|
||||
data class GenreCount(val genre: String, val trackCount: Int)
|
||||
|
||||
/** One row of the year index. */
|
||||
data class YearCount(val year: Int, val albumCount: Int)
|
||||
|
||||
/**
|
||||
* A page of albums plus the server's total for the whole filter, which is
|
||||
* what lets the UI say how many are left rather than just offering "more".
|
||||
*/
|
||||
data class AlbumPage(val items: List<AlbumRef>, val total: Int)
|
||||
|
||||
@@ -6,7 +6,6 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -52,6 +51,7 @@ import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||
@@ -70,6 +70,7 @@ fun AlbumDetailScreen(
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -165,7 +166,6 @@ private fun AlbumBody(
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 140.dp),
|
||||
) {
|
||||
item {
|
||||
AlbumHeader(
|
||||
|
||||
@@ -56,6 +56,7 @@ import com.fabledsword.minstrel.models.albumCoverPath
|
||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||
@@ -79,6 +80,7 @@ fun ArtistDetailScreen(
|
||||
}
|
||||
}
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
package com.fabledsword.minstrel.library.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.library.data.AlbumPage
|
||||
import com.fabledsword.minstrel.library.data.GenreCount
|
||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||
import com.fabledsword.minstrel.library.data.YearCount
|
||||
import com.fabledsword.minstrel.models.AlbumRef
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/** How the genre index is ordered. */
|
||||
enum class GenreSort {
|
||||
/** Server order: track count descending, name breaking ties. */
|
||||
COUNT,
|
||||
|
||||
/** Alphabetical, case-insensitive. */
|
||||
NAME,
|
||||
}
|
||||
|
||||
/**
|
||||
* Albums for whichever genre or year is currently drilled into.
|
||||
*
|
||||
* [total] is the server's count for the whole filter, not the loaded slice,
|
||||
* so the UI can say how many are left instead of only offering "more".
|
||||
*/
|
||||
data class AlbumBrowseState(
|
||||
val albums: List<AlbumRef> = emptyList(),
|
||||
val total: Int = 0,
|
||||
val loading: Boolean = false,
|
||||
val failed: Boolean = false,
|
||||
) {
|
||||
val hasMore: Boolean get() = albums.size < total
|
||||
val remaining: Int get() = (total - albums.size).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Backs the Genres and Years tabs (#2467), mirroring the web surfaces #367
|
||||
* shipped.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
// 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
|
||||
// cohesive surface; splitting into GenresViewModel + YearsViewModel would
|
||||
// duplicate the shared paging body for no gain.
|
||||
@Suppress("TooManyFunctions")
|
||||
@HiltViewModel
|
||||
class BrowseViewModel @Inject constructor(
|
||||
private val repository: LibraryRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val genresInternal = MutableStateFlow<UiState<List<GenreCount>>>(UiState.Loading)
|
||||
val genres: StateFlow<UiState<List<GenreCount>>> = genresInternal.asStateFlow()
|
||||
|
||||
private val yearsInternal = MutableStateFlow<UiState<List<YearCount>>>(UiState.Loading)
|
||||
val years: StateFlow<UiState<List<YearCount>>> = yearsInternal.asStateFlow()
|
||||
|
||||
private val genreFilterInternal = MutableStateFlow("")
|
||||
val genreFilter: StateFlow<String> = genreFilterInternal.asStateFlow()
|
||||
|
||||
private val genreSortInternal = MutableStateFlow(GenreSort.COUNT)
|
||||
val genreSort: StateFlow<GenreSort> = genreSortInternal.asStateFlow()
|
||||
|
||||
private val selectedGenreInternal = MutableStateFlow<String?>(null)
|
||||
val selectedGenre: StateFlow<String?> = selectedGenreInternal.asStateFlow()
|
||||
|
||||
private val selectedYearInternal = MutableStateFlow<Int?>(null)
|
||||
val selectedYear: StateFlow<Int?> = selectedYearInternal.asStateFlow()
|
||||
|
||||
private val genreAlbumsInternal = MutableStateFlow(AlbumBrowseState())
|
||||
val genreAlbums: StateFlow<AlbumBrowseState> = genreAlbumsInternal.asStateFlow()
|
||||
|
||||
private val yearAlbumsInternal = MutableStateFlow(AlbumBrowseState())
|
||||
val yearAlbums: StateFlow<AlbumBrowseState> = yearAlbumsInternal.asStateFlow()
|
||||
|
||||
// Guards against a slow response for a previously-selected genre/year
|
||||
// landing after the user has moved on and painting over the new list.
|
||||
// One counter per axis, since the two drill-downs are independent.
|
||||
private var genreRequestToken = 0
|
||||
private var yearRequestToken = 0
|
||||
|
||||
init {
|
||||
loadGenres()
|
||||
loadYears()
|
||||
}
|
||||
|
||||
fun loadGenres() {
|
||||
viewModelScope.launch {
|
||||
genresInternal.value = UiState.Loading
|
||||
genresInternal.value = runCatching { repository.genres() }.fold(
|
||||
onSuccess = { if (it.isEmpty()) UiState.Empty else UiState.Success(it) },
|
||||
onFailure = { UiState.Error(ErrorCopy.fromThrowable(it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadYears() {
|
||||
viewModelScope.launch {
|
||||
yearsInternal.value = UiState.Loading
|
||||
yearsInternal.value = runCatching { repository.albumYears() }.fold(
|
||||
onSuccess = { if (it.isEmpty()) UiState.Empty else UiState.Success(it) },
|
||||
onFailure = { UiState.Error(ErrorCopy.fromThrowable(it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setGenreFilter(value: String) { genreFilterInternal.value = value }
|
||||
|
||||
fun setGenreSort(sort: GenreSort) { genreSortInternal.value = sort }
|
||||
|
||||
/** Drill into [genre], or pass null to go back to the index. */
|
||||
fun selectGenre(genre: String?) {
|
||||
selectedGenreInternal.value = genre
|
||||
genreRequestToken += 1
|
||||
genreAlbumsInternal.value = AlbumBrowseState()
|
||||
if (genre == null) return
|
||||
fetchGenrePage(genre, offset = 0, token = genreRequestToken)
|
||||
}
|
||||
|
||||
fun loadMoreGenreAlbums() {
|
||||
val genre = selectedGenreInternal.value ?: return
|
||||
val state = genreAlbumsInternal.value
|
||||
if (state.loading || !state.hasMore) return
|
||||
fetchGenrePage(genre, offset = state.albums.size, token = genreRequestToken)
|
||||
}
|
||||
|
||||
fun retryGenreAlbums() {
|
||||
selectedGenreInternal.value?.let { selectGenre(it) }
|
||||
}
|
||||
|
||||
/** Drill into [year], or pass null to go back to the index. */
|
||||
fun selectYear(year: Int?) {
|
||||
selectedYearInternal.value = year
|
||||
yearRequestToken += 1
|
||||
yearAlbumsInternal.value = AlbumBrowseState()
|
||||
if (year == null) return
|
||||
fetchYearPage(year, offset = 0, token = yearRequestToken)
|
||||
}
|
||||
|
||||
fun loadMoreYearAlbums() {
|
||||
val year = selectedYearInternal.value ?: return
|
||||
val state = yearAlbumsInternal.value
|
||||
if (state.loading || !state.hasMore) return
|
||||
fetchYearPage(year, offset = state.albums.size, token = yearRequestToken)
|
||||
}
|
||||
|
||||
fun retryYearAlbums() {
|
||||
selectedYearInternal.value?.let { selectYear(it) }
|
||||
}
|
||||
|
||||
private fun fetchGenrePage(genre: String, offset: Int, token: Int) {
|
||||
fetchPage(
|
||||
state = genreAlbumsInternal,
|
||||
offset = offset,
|
||||
isCurrent = { token == genreRequestToken },
|
||||
fetch = { repository.albumsByGenre(genre, PAGE_SIZE, offset) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun fetchYearPage(year: Int, offset: Int, token: Int) {
|
||||
fetchPage(
|
||||
state = yearAlbumsInternal,
|
||||
offset = offset,
|
||||
isCurrent = { token == yearRequestToken },
|
||||
fetch = { repository.albumsByYear(year, PAGE_SIZE, offset) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The paging body both axes share: append on success, and drop the result
|
||||
* entirely if the selection moved while the request was in flight.
|
||||
*/
|
||||
private fun fetchPage(
|
||||
state: MutableStateFlow<AlbumBrowseState>,
|
||||
offset: Int,
|
||||
isCurrent: () -> Boolean,
|
||||
fetch: suspend () -> AlbumPage,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
state.value = state.value.copy(loading = true, failed = false)
|
||||
runCatching { fetch() }.fold(
|
||||
onSuccess = { page ->
|
||||
if (!isCurrent()) return@launch
|
||||
val merged =
|
||||
if (offset == 0) page.items else state.value.albums + page.items
|
||||
state.value = AlbumBrowseState(
|
||||
albums = merged,
|
||||
total = page.total,
|
||||
loading = false,
|
||||
failed = false,
|
||||
)
|
||||
},
|
||||
onFailure = {
|
||||
if (!isCurrent()) return@launch
|
||||
state.value = state.value.copy(loading = false, failed = true)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// Matches the web client's BROWSE_PAGE_SIZE so "Load more (N left)"
|
||||
// steps at the same rate on both clients.
|
||||
const val PAGE_SIZE = 50
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the current filter and sort to a genre index.
|
||||
*
|
||||
* Pure so the ordering rules are testable without a ViewModel. Sorting copies
|
||||
* first: the input is the list held in the loaded state, and sorting in place
|
||||
* would reorder what every other reader sees.
|
||||
*/
|
||||
fun visibleGenres(
|
||||
genres: List<GenreCount>,
|
||||
filter: String,
|
||||
sort: GenreSort,
|
||||
): List<GenreCount> {
|
||||
val q = filter.trim()
|
||||
val matched =
|
||||
if (q.isEmpty()) genres else genres.filter { it.genre.contains(q, ignoreCase = true) }
|
||||
return when (sort) {
|
||||
// Server order is already count DESC then name; don't re-sort it.
|
||||
GenreSort.COUNT -> matched
|
||||
GenreSort.NAME -> matched.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.genre })
|
||||
}
|
||||
}
|
||||
|
||||
// Integer division by this floors a year to its decade: 2007 -> 2000. Named
|
||||
// because detekt counts it as magic, and because the arithmetic reads as
|
||||
// arbitrary otherwise.
|
||||
private const val YEARS_PER_DECADE = 10
|
||||
|
||||
/** A decade's worth of the year index, newest year first. */
|
||||
data class DecadeGroup(
|
||||
val decade: Int,
|
||||
val years: List<YearCount>,
|
||||
val albumCount: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Group the year index by decade, newest first.
|
||||
*
|
||||
* A flat list of every year in a decades-deep library is a wall of numbers, and
|
||||
* the decade is usually how someone actually thinks about it. Pure, for the
|
||||
* same reason as [visibleGenres].
|
||||
*/
|
||||
fun groupByDecade(years: List<YearCount>): List<DecadeGroup> =
|
||||
years.groupBy { (it.year / YEARS_PER_DECADE) * YEARS_PER_DECADE }
|
||||
.map { (decade, entries) ->
|
||||
DecadeGroup(
|
||||
decade = decade,
|
||||
years = entries.sortedByDescending { it.year },
|
||||
albumCount = entries.sumOf { it.albumCount },
|
||||
)
|
||||
}
|
||||
.sortedByDescending { it.decade }
|
||||
@@ -0,0 +1,341 @@
|
||||
package com.fabledsword.minstrel.library.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.composables.icons.lucide.ArrowLeft
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.LibraryBig
|
||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||
import com.fabledsword.minstrel.models.AlbumRef
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* meant carrying the genre in the path, and "Rock/Pop" is a real ID3 tag whose
|
||||
* slash a path segment cannot carry — the same reason the server takes it as a
|
||||
* query parameter.
|
||||
*/
|
||||
@Composable
|
||||
fun GenresTab(
|
||||
onAlbumClick: (String) -> Unit,
|
||||
viewModel: BrowseViewModel = hiltViewModel(),
|
||||
) {
|
||||
val selected by viewModel.selectedGenre.collectAsStateWithLifecycle()
|
||||
val genre = selected
|
||||
if (genre == null) {
|
||||
GenreIndex(viewModel = viewModel)
|
||||
} else {
|
||||
GenreAlbums(
|
||||
genre = genre,
|
||||
viewModel = viewModel,
|
||||
onAlbumClick = onAlbumClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreIndex(viewModel: BrowseViewModel) {
|
||||
val state by viewModel.genres.collectAsStateWithLifecycle()
|
||||
val filter by viewModel.genreFilter.collectAsStateWithLifecycle()
|
||||
val sort by viewModel.genreSort.collectAsStateWithLifecycle()
|
||||
|
||||
when (val s = state) {
|
||||
UiState.Loading -> EmptyState(
|
||||
title = "Reading your genres…",
|
||||
body = "",
|
||||
icon = Lucide.LibraryBig,
|
||||
)
|
||||
UiState.Empty -> EmptyState(
|
||||
title = "No genres found",
|
||||
body = "Genres come from the genre tag on your audio files. If your " +
|
||||
"library is tagged but this is empty, try a rescan from the admin " +
|
||||
"screen.",
|
||||
icon = Lucide.LibraryBig,
|
||||
)
|
||||
is UiState.Error -> ErrorRetry(
|
||||
message = s.message,
|
||||
onRetry = viewModel::loadGenres,
|
||||
)
|
||||
is UiState.Success -> {
|
||||
val visible = visibleGenres(s.data, filter, sort)
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
GenreIndexControls(
|
||||
total = s.data.size,
|
||||
shown = visible.size,
|
||||
filter = filter,
|
||||
sort = sort,
|
||||
onFilterChange = viewModel::setGenreFilter,
|
||||
onSortChange = viewModel::setGenreSort,
|
||||
)
|
||||
if (visible.isEmpty()) {
|
||||
EmptyState(
|
||||
title = "No genres match \"${filter.trim()}\"",
|
||||
body = "Try a shorter search.",
|
||||
icon = Lucide.LibraryBig,
|
||||
)
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(items = visible, key = { it.genre }) { row ->
|
||||
GenreRow(
|
||||
genre = row.genre,
|
||||
trackCount = row.trackCount,
|
||||
onClick = { viewModel.selectGenre(row.genre) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreIndexControls(
|
||||
total: Int,
|
||||
shown: Int,
|
||||
filter: String,
|
||||
sort: GenreSort,
|
||||
onFilterChange: (String) -> Unit,
|
||||
onSortChange: (GenreSort) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = filter,
|
||||
onValueChange = onFilterChange,
|
||||
label = { Text("Filter genres") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Count-first is the default because the head of that list is
|
||||
// genuinely where you are going; raw tags carry a long tail of
|
||||
// one-offs that A-Z would bury the real genres under. A-Z is here
|
||||
// for when you already know roughly what it is called.
|
||||
FilterChip(
|
||||
selected = sort == GenreSort.COUNT,
|
||||
onClick = { onSortChange(GenreSort.COUNT) },
|
||||
label = { Text("Most tracks") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = sort == GenreSort.NAME,
|
||||
onClick = { onSortChange(GenreSort.NAME) },
|
||||
label = { Text("A–Z") },
|
||||
)
|
||||
Text(
|
||||
text = if (filter.isBlank()) {
|
||||
"$total genres"
|
||||
} else {
|
||||
"$shown of $total"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreRow(genre: String, trackCount: Int, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = genre,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = "$trackCount",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreAlbums(
|
||||
genre: String,
|
||||
viewModel: BrowseViewModel,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
) {
|
||||
val albums by viewModel.genreAlbums.collectAsStateWithLifecycle()
|
||||
BrowseAlbumResults(
|
||||
heading = genre,
|
||||
subtitle = albumCountLabel(albums.total, albums.loading, albums.albums.size),
|
||||
state = albums,
|
||||
emptyTitle = "No albums for this genre",
|
||||
emptyBody = "The library may have been rescanned since this list was built.",
|
||||
onBack = { viewModel.selectGenre(null) },
|
||||
onRetry = viewModel::retryGenreAlbums,
|
||||
onLoadMore = viewModel::loadMoreGenreAlbums,
|
||||
onAlbumClick = onAlbumClick,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared results pane for both browse axes: a back affordance, a heading, the
|
||||
* album grid, and the load-more footer. Genres and Years differ only in their
|
||||
* heading and copy, so the layout lives once.
|
||||
*/
|
||||
@Composable
|
||||
@Suppress("LongParameterList") // one presentational surface; all of it varies by axis
|
||||
fun BrowseAlbumResults(
|
||||
heading: String,
|
||||
subtitle: String,
|
||||
state: AlbumBrowseState,
|
||||
emptyTitle: String,
|
||||
emptyBody: String,
|
||||
onBack: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onLoadMore: () -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Lucide.ArrowLeft, contentDescription = "Back to the index")
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = heading,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
state.failed && state.albums.isEmpty() -> ErrorRetry(
|
||||
message = "Couldn't load albums.",
|
||||
onRetry = onRetry,
|
||||
)
|
||||
state.loading && state.albums.isEmpty() -> EmptyState(
|
||||
title = "Loading…",
|
||||
body = "",
|
||||
)
|
||||
state.albums.isEmpty() -> EmptyState(title = emptyTitle, body = emptyBody)
|
||||
else -> BrowseAlbumGrid(
|
||||
state = state,
|
||||
onLoadMore = onLoadMore,
|
||||
onAlbumClick = onAlbumClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BrowseAlbumGrid(
|
||||
state: AlbumBrowseState,
|
||||
onLoadMore: () -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
) {
|
||||
LazyVerticalGrid(
|
||||
// Same 176dp cell as the Albums tab, so a genre's grid and the full
|
||||
// album grid line up rather than each inventing a column count.
|
||||
columns = GridCells.Adaptive(minSize = 176.dp),
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(items = state.albums, key = { it.id }) { album: AlbumRef ->
|
||||
AlbumCard(album = album, onClick = { onAlbumClick(album.id) })
|
||||
}
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (state.hasMore) {
|
||||
// Explicit rather than infinite scroll, matching web: the
|
||||
// remaining count is useful, and a browse axis is a place
|
||||
// people skim rather than fall through.
|
||||
TextButton(onClick = onLoadMore, enabled = !state.loading) {
|
||||
Text(
|
||||
if (state.loading) {
|
||||
"Loading…"
|
||||
} else {
|
||||
"Load more (${state.remaining} left)"
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "That's everything",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "12 albums" once the total is known, and nothing at all while the first page
|
||||
* is still in flight — a count that appears as 0 and then corrects itself reads
|
||||
* as a bug.
|
||||
*/
|
||||
internal fun albumCountLabel(total: Int, loading: Boolean, loaded: Int): String = when {
|
||||
loading && loaded == 0 -> ""
|
||||
total == 1 -> "1 album"
|
||||
else -> "$total albums"
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import com.fabledsword.minstrel.nav.Library
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Shuffle
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
@@ -51,8 +52,10 @@ import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
|
||||
|
||||
/**
|
||||
* Library tab. Five-tab TabBar (Artists / Albums / History / Liked /
|
||||
* Hidden) matching `flutter_client/lib/library/library_screen.dart`.
|
||||
* 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
|
||||
@@ -77,6 +80,7 @@ fun LibraryScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
Column {
|
||||
@@ -113,27 +117,53 @@ fun LibraryScreen(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize().padding(inner),
|
||||
) { page ->
|
||||
when (page) {
|
||||
TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController)
|
||||
TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController)
|
||||
TAB_HISTORY -> HistoryTab(
|
||||
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
||||
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
||||
)
|
||||
TAB_LIKED -> LikedTab(navController = navController)
|
||||
TAB_HIDDEN -> HiddenTab()
|
||||
}
|
||||
LibraryTabPage(page = page, viewModel = viewModel, navController = navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pager's page bodies, split out of [LibraryScreen] so the screen stays
|
||||
* the scaffold + tab bar and this stays the routing table. Adding a tab is
|
||||
* then one line here and one label in [LIBRARY_TABS].
|
||||
*/
|
||||
@Composable
|
||||
private fun LibraryTabPage(
|
||||
page: Int,
|
||||
viewModel: LibraryViewModel,
|
||||
navController: NavHostController,
|
||||
) {
|
||||
when (page) {
|
||||
TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController)
|
||||
TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController)
|
||||
TAB_GENRES -> GenresTab(
|
||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||
)
|
||||
TAB_YEARS -> YearsTab(
|
||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||
)
|
||||
TAB_HISTORY -> HistoryTab(
|
||||
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
||||
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
||||
)
|
||||
TAB_LIKED -> LikedTab(navController = navController)
|
||||
TAB_HIDDEN -> HiddenTab()
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAB_ARTISTS = 0
|
||||
private const val TAB_ALBUMS = 1
|
||||
private const val TAB_HISTORY = 2
|
||||
private const val TAB_LIKED = 3
|
||||
private const val TAB_HIDDEN = 4
|
||||
private const val TAB_GENRES = 2
|
||||
private const val TAB_YEARS = 3
|
||||
private const val TAB_HISTORY = 4
|
||||
private const val TAB_LIKED = 5
|
||||
private const val TAB_HIDDEN = 6
|
||||
|
||||
private val LIBRARY_TABS = listOf("Artists", "Albums", "History", "Liked", "Hidden")
|
||||
// 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
|
||||
// beside them rather than after the personal tabs.
|
||||
private val LIBRARY_TABS =
|
||||
listOf("Artists", "Albums", "Genres", "Years", "History", "Liked", "Hidden")
|
||||
|
||||
@Composable
|
||||
private fun ArtistsTab(
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.fabledsword.minstrel.library.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.composables.icons.lucide.Clock
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
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
|
||||
* index, then the albums for a chosen year.
|
||||
*
|
||||
* Albums with no release date are absent from this axis entirely — the server
|
||||
* leaves them out rather than inventing a year-0 bucket, and the empty state
|
||||
* says so, because "my albums aren't here" otherwise looks like a bug.
|
||||
*/
|
||||
@Composable
|
||||
fun YearsTab(
|
||||
onAlbumClick: (String) -> Unit,
|
||||
viewModel: BrowseViewModel = hiltViewModel(),
|
||||
) {
|
||||
val selected by viewModel.selectedYear.collectAsStateWithLifecycle()
|
||||
val year = selected
|
||||
if (year == null) {
|
||||
YearIndex(viewModel = viewModel)
|
||||
} else {
|
||||
YearAlbums(year = year, viewModel = viewModel, onAlbumClick = onAlbumClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun YearIndex(viewModel: BrowseViewModel) {
|
||||
val state by viewModel.years.collectAsStateWithLifecycle()
|
||||
|
||||
when (val s = state) {
|
||||
UiState.Loading -> EmptyState(
|
||||
title = "Reading release years…",
|
||||
body = "",
|
||||
icon = Lucide.Clock,
|
||||
)
|
||||
UiState.Empty -> EmptyState(
|
||||
title = "No release years found",
|
||||
body = "Years come from the release date on your albums. Albums " +
|
||||
"without one don't appear on this axis at all.",
|
||||
icon = Lucide.Clock,
|
||||
)
|
||||
is UiState.Error -> ErrorRetry(
|
||||
message = s.message,
|
||||
onRetry = viewModel::loadYears,
|
||||
)
|
||||
is UiState.Success -> {
|
||||
val decades = groupByDecade(s.data)
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
decades.forEach { group ->
|
||||
item(key = "decade-${group.decade}") {
|
||||
DecadeHeader(decade = group.decade, albumCount = group.albumCount)
|
||||
}
|
||||
items(
|
||||
count = group.years.size,
|
||||
key = { i -> "year-${group.years[i].year}" },
|
||||
) { i ->
|
||||
val row = group.years[i]
|
||||
YearRow(
|
||||
year = row.year,
|
||||
albumCount = row.albumCount,
|
||||
onClick = { viewModel.selectYear(row.year) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DecadeHeader(decade: Int, albumCount: Int) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = "${decade}s",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = albumCountLabel(albumCount, loading = false, loaded = albumCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun YearRow(year: Int, albumCount: Int, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "$year",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = "$albumCount",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun YearAlbums(
|
||||
year: Int,
|
||||
viewModel: BrowseViewModel,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
) {
|
||||
val albums by viewModel.yearAlbums.collectAsStateWithLifecycle()
|
||||
BrowseAlbumResults(
|
||||
heading = "$year",
|
||||
subtitle = albumCountLabel(albums.total, albums.loading, albums.albums.size),
|
||||
state = albums,
|
||||
emptyTitle = "No albums for $year",
|
||||
emptyBody = "The library may have been rescanned since this list was built.",
|
||||
onBack = { viewModel.selectYear(null) },
|
||||
onRetry = viewModel::retryYearAlbums,
|
||||
onLoadMore = viewModel::loadMoreYearAlbums,
|
||||
onAlbumClick = onAlbumClick,
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
|
||||
|
||||
/**
|
||||
* Lightweight reference to one album. Mirrors
|
||||
* `flutter_client/lib/models/album.dart`'s `AlbumRef`.
|
||||
* the Flutter client's `AlbumRef`.
|
||||
*
|
||||
* `coverUrl` and `durationSec` match the server contract (not
|
||||
* `cover_art_url` / `duration_ms`). `year` is omitempty server-side so
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
|
||||
|
||||
/**
|
||||
* Lightweight reference to one artist. Mirrors
|
||||
* `flutter_client/lib/models/artist.dart`'s `ArtistRef`.
|
||||
* the Flutter client's `ArtistRef`.
|
||||
*
|
||||
* `coverUrl` is the server's field name (NOT cover_art_url). Server emits
|
||||
* empty string when the artist has no representative album cover; UI code
|
||||
|
||||
@@ -14,7 +14,7 @@ enum class LidarrRequestKind {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lidarr search hit. Mirrors `flutter_client/lib/models/lidarr.dart`'s
|
||||
* Lidarr search hit. Mirrors the Flutter client's
|
||||
* `LidarrSearchResult` — `mbid` is the result's own MBID; `artistMbid`
|
||||
* and `albumMbid` are filled when the row is an album/track and the
|
||||
* UI needs the parent IDs to build the request.
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
|
||||
|
||||
/**
|
||||
* Domain shape for one admin-issued registration invite. Mirrors
|
||||
* `flutter_client/lib/models/invite.dart Invite` and the server's
|
||||
* the Flutter client's `Invite` and the server's
|
||||
* `inviteResp` from `internal/api/admin_invites.go`.
|
||||
*
|
||||
* `invitedBy` and `redeemedBy` are UUIDs of users (not usernames);
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
|
||||
|
||||
/**
|
||||
* Caller's ListenBrainz integration state. Mirrors
|
||||
* `flutter_client/lib/models/my_profile.dart ListenBrainzStatus`
|
||||
* the Flutter client's `ListenBrainzStatus`
|
||||
* and the server's `listenBrainzResp`.
|
||||
*
|
||||
* The token itself is never read back from the server — `tokenSet`
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
|
||||
|
||||
/**
|
||||
* Lightweight reference to one playlist (user or system-generated).
|
||||
* Mirrors `flutter_client/lib/models/playlist.dart`'s `Playlist`.
|
||||
* Mirrors the Flutter client's `Playlist`.
|
||||
*
|
||||
* `systemVariant` discriminates user vs. system playlists — null for
|
||||
* user-owned, one of "for_you" / "discover" / "songs_like_artist" / etc.
|
||||
@@ -47,7 +47,10 @@ data class PlaylistRef(
|
||||
* `trackId` and `streamUrl` are nullable because the upstream track can
|
||||
* be removed from the library while the row stays in the playlist —
|
||||
* those tiles render grey + unplayable per Flutter's `isAvailable`
|
||||
* convention.
|
||||
* convention. [unavailable] is the second, softer case: the track is
|
||||
* still there but its file is missing. Both render grey and refuse to
|
||||
* play; only the second is worth explaining to the user, because it
|
||||
* can fix itself.
|
||||
*/
|
||||
data class PlaylistTrackRef(
|
||||
val position: Int,
|
||||
@@ -59,8 +62,21 @@ data class PlaylistTrackRef(
|
||||
val artistName: String = "",
|
||||
val durationSec: Int = 0,
|
||||
val streamUrl: String? = null,
|
||||
/**
|
||||
* The track is still in the library but its file is missing from
|
||||
* disk (#2527). Unlike a null [trackId] this is expected to be
|
||||
* temporary — the scanner clears it when the file returns, and
|
||||
* adopts the row if it returns under a new name (#2528) — so the
|
||||
* row keeps its identity, its likes and its play history.
|
||||
*/
|
||||
val unavailable: Boolean = false,
|
||||
) {
|
||||
val isAvailable: Boolean get() = trackId != null
|
||||
/**
|
||||
* Playable-ness, covering both ways a row can outlive its audio.
|
||||
* Everything that greys a row or refuses to queue it reads this, so
|
||||
* neither concern has to be re-derived at a call site.
|
||||
*/
|
||||
val isAvailable: Boolean get() = trackId != null && !unavailable
|
||||
|
||||
/**
|
||||
* Cover URL derived from the parent album's `/api/albums/{id}/cover`
|
||||
|
||||
@@ -24,7 +24,7 @@ enum class RequestStatus {
|
||||
|
||||
/**
|
||||
* One Lidarr request the user has submitted. Mirrors
|
||||
* `flutter_client/lib/models/admin_request.dart AdminRequest` —
|
||||
* the Flutter client's `AdminRequest` —
|
||||
* shared between the user-side `/api/requests` view and the admin
|
||||
* cross-user view since the wire shape is identical.
|
||||
*
|
||||
|
||||
@@ -3,8 +3,7 @@ package com.fabledsword.minstrel.models
|
||||
/**
|
||||
* Caller's most recent system_playlist_runs state, driving the Home
|
||||
* placeholder cards for not-yet-generated system playlists. Mirrors
|
||||
* `flutter_client/lib/models/system_playlists_status.dart` and the
|
||||
* server's `systemPlaylistsStatusResp`.
|
||||
* the server's `systemPlaylistsStatusResp`.
|
||||
*
|
||||
* Zero values (inFlight=false, both timestamps null) mean the user
|
||||
* has never had a build attempted — the placeholders read as
|
||||
|
||||
@@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Lightweight reference to one track. Mirrors
|
||||
* `flutter_client/lib/models/track.dart`'s `TrackRef`.
|
||||
* the Flutter client's `TrackRef`.
|
||||
*
|
||||
* The `Ref` suffix matches the Flutter convention — these types carry
|
||||
* only the IDs + display fields needed for list rendering + the player
|
||||
@@ -31,6 +31,16 @@ data class TrackRef(
|
||||
val discNumber: Int? = null,
|
||||
val durationSec: Int = 0,
|
||||
val streamUrl: String = "",
|
||||
/**
|
||||
* The server has no file for this track right now (#2704). It still
|
||||
* belongs to the library, keeps its history, and may come back — but
|
||||
* streaming it will fail, so nothing should queue it.
|
||||
*
|
||||
* NOT the same as unplayable on this device: audio already resident in
|
||||
* the local cache plays regardless of what the server has, which is why
|
||||
* the offline pools in ShuffleSource deliberately ignore this.
|
||||
*/
|
||||
val unavailable: Boolean = false,
|
||||
) {
|
||||
/**
|
||||
* Cover URL derived from the parent album's `/api/albums/{id}/cover`
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
|
||||
|
||||
/**
|
||||
* Wire shape returned by `GET /api/client/version`. Mirrors
|
||||
* `flutter_client/lib/update/update_info.dart UpdateInfo`.
|
||||
* the Flutter client's `UpdateInfo`.
|
||||
*
|
||||
* `version` is the server-bundled APK version (may have a leading
|
||||
* "v" from the git tag); `apkUrl` is server-relative (e.g.
|
||||
|
||||
@@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Wire shape for `AlbumRef`. Mirrors `flutter_client/lib/models/album.dart`.
|
||||
* Wire shape for `AlbumRef`.
|
||||
*/
|
||||
@Serializable
|
||||
data class AlbumWire(
|
||||
|
||||
@@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Wire shape for `ArtistRef`. Mirrors `flutter_client/lib/models/artist.dart`.
|
||||
* Wire shape for `ArtistRef`.
|
||||
*/
|
||||
@Serializable
|
||||
data class ArtistWire(
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.fabledsword.minstrel.models.wire
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* One row of `GET /api/library/genres` (#367).
|
||||
*
|
||||
* The label is the file tag's own string, split on `[;,]` and trimmed but
|
||||
* otherwise untouched by the server — no case folding, no synonym mapping.
|
||||
* So "Rock" and "rock" can both appear, as can "Rock/Pop" beside "Rock" and
|
||||
* "Pop". Don't normalise it on the client either: the index and the album
|
||||
* filter have to agree on the exact string, and the filter matches what the
|
||||
* server stored.
|
||||
*/
|
||||
@Serializable
|
||||
data class GenreCountWire(
|
||||
val genre: String = "",
|
||||
@SerialName("track_count") val trackCount: Int = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* One row of `GET /api/library/years`.
|
||||
*
|
||||
* Albums with no release date are absent from this axis entirely rather than
|
||||
* bucketed under year 0 — "unknown" is not a year, and the UI should say so
|
||||
* instead of showing a fake row.
|
||||
*/
|
||||
@Serializable
|
||||
data class YearCountWire(
|
||||
val year: Int = 0,
|
||||
@SerialName("album_count") val albumCount: Int = 0,
|
||||
)
|
||||
@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
|
||||
/**
|
||||
* One row of `GET /api/lidarr/search`. Mirrors
|
||||
* `web/src/lib/api/types.ts LidarrSearchResult` /
|
||||
* `flutter_client/lib/models/lidarr.dart LidarrSearchResult`.
|
||||
* the Flutter client's `LidarrSearchResult`.
|
||||
*
|
||||
* `inLibrary` and `requested` let the UI greyout rows the user can't
|
||||
* act on (already imported / already awaiting review). All defaults
|
||||
|
||||
@@ -9,8 +9,7 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Wire shapes for `POST /api/events`. The endpoint multiplexes four
|
||||
* variants on the `type` discriminator field, mirroring
|
||||
* `flutter_client/lib/api/endpoints/events.dart`.
|
||||
* variants on the `type` discriminator field.
|
||||
*
|
||||
* play_started returns the server-assigned play_event_id (nullable —
|
||||
* server may suppress under certain conditions); the other three
|
||||
|
||||
@@ -5,9 +5,8 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Wire shape of `GET /api/home/index` — five flat slices of entity-ID
|
||||
* strings, one per Home section. Mirrors
|
||||
* `flutter_client/lib/models/home_index.dart` (and the server's
|
||||
* `internal/api/types.go HomeIndexPayload`).
|
||||
* strings, one per Home section. Mirrors the server's
|
||||
* `HomeIndexPayload` in `internal/api/types.go`.
|
||||
*
|
||||
* Section name implies entity type; no per-entry type tag is needed:
|
||||
* - recentlyAddedAlbums → album
|
||||
|
||||
@@ -5,8 +5,7 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Wire shape for `GET /api/me` and the return value of
|
||||
* `PUT /api/me/profile`. Mirrors
|
||||
* `flutter_client/lib/models/my_profile.dart`:
|
||||
* `PUT /api/me/profile`. Two things the shape assumes:
|
||||
* - `display_name` and `email` are nullable; server returns null
|
||||
* when the user hasn't set them yet (registration only requires
|
||||
* a username).
|
||||
|
||||
@@ -41,6 +41,14 @@ data class PlaylistsListWire(
|
||||
* / `artistId` / `streamUrl` are nullable because the upstream track
|
||||
* may have been removed from the library while the row stays in the
|
||||
* playlist with its display fields preserved.
|
||||
*
|
||||
* [unavailable] is the other way a row outlives its audio (#2527): the
|
||||
* track is still in the library, with its history and likes, but its
|
||||
* file is missing from disk. The server withholds `stream_url` in that
|
||||
* case too, so a client that only checked the URL would already skip
|
||||
* it — the flag is what lets the UI say WHY instead of rendering a
|
||||
* mysteriously dead row. Defaults false so a server that predates the
|
||||
* field deserialises cleanly.
|
||||
*/
|
||||
@Serializable
|
||||
data class PlaylistTrackWire(
|
||||
@@ -53,6 +61,7 @@ data class PlaylistTrackWire(
|
||||
@SerialName("artist_name") val artistName: String = "",
|
||||
@SerialName("duration_sec") val durationSec: Int = 0,
|
||||
@SerialName("stream_url") val streamUrl: String? = null,
|
||||
val unavailable: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* One row of `GET /api/quarantine/mine`. Mirrors
|
||||
* `flutter_client/lib/models/quarantine_mine.dart QuarantineMineRow`
|
||||
* the Flutter client's `QuarantineMineRow`
|
||||
* (web `LidarrQuarantineMineRow`).
|
||||
*
|
||||
* Reason values: `bad_rip` / `wrong_file` / `wrong_tags` / `duplicate`
|
||||
|
||||
@@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable
|
||||
* Wire shape of `requestView` from `internal/api/requests.go` — the
|
||||
* row returned by both `GET /api/requests` (caller's own requests) and
|
||||
* `GET /api/admin/requests` (cross-user admin view). Mirrors
|
||||
* `flutter_client/lib/models/admin_request.dart AdminRequest`.
|
||||
* the Flutter client's `AdminRequest`.
|
||||
*
|
||||
* Status values: `pending` / `approved` / `rejected` / `completed` /
|
||||
* `failed`. Kind values: `artist` / `album` / `track`.
|
||||
|
||||
@@ -42,6 +42,15 @@ data class SyncTrackWire(
|
||||
@SerialName("file_path") val filePath: String? = null,
|
||||
@SerialName("file_format") val fileFormat: String? = null,
|
||||
val genre: String? = null,
|
||||
// The file is currently absent from disk server-side (#2704). Shipped as
|
||||
// state rather than the row being withheld, because a missing file is
|
||||
// expected to return — dropping it would churn the cache on every
|
||||
// transient unmount and discard the identity #2528 preserves.
|
||||
//
|
||||
// Defaults false so a server predating the field deserialises cleanly and
|
||||
// its tracks stay playable, which is the correct reading of "this server
|
||||
// has nothing to say about missing files".
|
||||
val missing: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Wire shape for `TrackRef` as the server emits it. Mirrors
|
||||
* `flutter_client/lib/models/track.dart`'s `TrackRef.fromJson`
|
||||
* the Flutter client's `TrackRef.fromJson`
|
||||
* field-for-field; the keys are snake_case because the server is Go
|
||||
* (json:"album_id" etc.).
|
||||
*
|
||||
@@ -26,4 +26,9 @@ data class TrackWire(
|
||||
@SerialName("disc_number") val discNumber: Int? = null,
|
||||
@SerialName("duration_sec") val durationSec: Int = 0,
|
||||
@SerialName("stream_url") val streamUrl: String = "",
|
||||
// Omitted by the server when false, so the default carries most rows
|
||||
// (#2704). True only from the direct-lookup surfaces — album detail and
|
||||
// search — which return a track the user asked for by name or container
|
||||
// rather than one Minstrel chose.
|
||||
val unavailable: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ import javax.inject.Singleton
|
||||
* Pre-downloads the next-N tracks in the queue into the shared Media3
|
||||
* [androidx.media3.datasource.cache.SimpleCache] so a skip-forward or
|
||||
* natural advance plays from disk instead of waiting on a fresh HTTP
|
||||
* connection. Mirrors the Flutter `Prefetcher` (cache/prefetcher.dart):
|
||||
* connection. Behaviour:
|
||||
* watches the player's current track, walks forward by
|
||||
* [com.fabledsword.minstrel.cache.audiocache.CacheSettings.prefetchWindow]
|
||||
* tracks, and pins each one. Idempotent — `CacheWriter` is a no-op when
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||
import com.fabledsword.minstrel.player.output.upnp.TransportInfo
|
||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||
import java.io.IOException
|
||||
import kotlin.math.abs
|
||||
@@ -71,12 +72,18 @@ class MinstrelForwardingPlayer(
|
||||
private val castNetworkLock: CastNetworkLock,
|
||||
private val networkStatus: NetworkStatusController,
|
||||
private val onDrop: (routeName: String) -> Unit,
|
||||
private val onStalled: (trackId: String) -> Unit = {},
|
||||
) : ForwardingPlayer(delegate) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val handler = Handler(delegate.applicationLooper)
|
||||
private var pollJob: Job? = null
|
||||
|
||||
// Watches for the renderer stopping without being asked to. A UPnP
|
||||
// renderer streams on its own, so a stream that dies looks like silence
|
||||
// and nothing else in the app would notice -- see [RemoteStallWatchdog].
|
||||
private val stallWatchdog = RemoteStallWatchdog()
|
||||
|
||||
// Tracks consecutive non-PLAYING poll observations so a single transient
|
||||
// PAUSED_PLAYBACK / STOPPED tick during a Sonos track transition does not
|
||||
// flip the play/pause button. Manual pause still feels instant because it
|
||||
@@ -492,6 +499,9 @@ class MinstrelForwardingPlayer(
|
||||
} else {
|
||||
castNetworkLock.release()
|
||||
remoteState.reset()
|
||||
// The next cast starts with a clean attempt budget; a stall on the
|
||||
// route we just left says nothing about the next one.
|
||||
stallWatchdog.reset()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,9 +594,63 @@ class MinstrelForwardingPlayer(
|
||||
}
|
||||
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
|
||||
}
|
||||
checkForStall(active, info.trackUri, transport)
|
||||
notifyRemoteStateChanged()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the watchdog what to make of this poll, and act on its answer.
|
||||
*
|
||||
* Recovery re-issues Play and then seeks back to the last position the
|
||||
* renderer was observed playing, so a stream that died 90 seconds into a
|
||||
* track resumes near there rather than restarting it. The seek is
|
||||
* best-effort and deliberately after the play: a renderer that refuses
|
||||
* the seek is still better off playing from zero than silent.
|
||||
*/
|
||||
private suspend fun checkForStall(
|
||||
active: ActiveUpnp,
|
||||
trackUri: String,
|
||||
transport: TransportInfo,
|
||||
) {
|
||||
val decision = stallWatchdog.onPoll(
|
||||
trackUri = trackUri,
|
||||
state = transport.state,
|
||||
statusOk = transport.statusOk,
|
||||
playIntent = remoteState.lastPlayIntent,
|
||||
positionMs = remoteState.positionMs,
|
||||
nowMs = SystemClock.elapsedRealtime(),
|
||||
)
|
||||
when (decision) {
|
||||
is RemoteStallWatchdog.Decision.Resume -> {
|
||||
Timber.w(
|
||||
"UPnP stall on %s: renderer stopped unasked (status_ok=%b), " +
|
||||
"resume attempt %d at %dms",
|
||||
active.routeName, transport.statusOk, decision.attempt, decision.resumeAtMs,
|
||||
)
|
||||
runCatching {
|
||||
retryTransport { active.avTransport.play() }
|
||||
if (decision.resumeAtMs > 0L) {
|
||||
retryTransport { active.avTransport.seek(decision.resumeAtMs) }
|
||||
}
|
||||
}.onFailure {
|
||||
// Leave the streak alone: a failed recovery is more
|
||||
// evidence of a stall, and the next poll re-decides.
|
||||
Timber.w(it, "UPnP stall: resume attempt failed on %s", active.routeName)
|
||||
}
|
||||
}
|
||||
RemoteStallWatchdog.Decision.GiveUp -> {
|
||||
Timber.w(
|
||||
"UPnP stall on %s: giving up after repeated resume attempts",
|
||||
active.routeName,
|
||||
)
|
||||
// Tell the user and the admin inbox. Silence here would be the
|
||||
// original bug: playback simply ends and nobody finds out.
|
||||
trackIdFromStreamUri(trackUri)?.let { handler.post { onStalled(it) } }
|
||||
}
|
||||
RemoteStallWatchdog.Decision.None -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Align the paused local delegate cursor to the track the renderer is
|
||||
* actually playing, so the un-overridden current-item getters
|
||||
|
||||
@@ -23,7 +23,7 @@ private const val DEBOUNCE_MS = 2_000L
|
||||
* operator never finds out the track is bad and the next user hits
|
||||
* the same wall.
|
||||
*
|
||||
* The snackbar text mirrors Flutter's `playback_error_reporter.dart`:
|
||||
* The snackbar text:
|
||||
* collect [PlayerController.playbackErrorEvents], debounce in a 2s
|
||||
* window, emit "Couldn't play 'X' — skipping" for a single error or
|
||||
* "Skipped N unplayable tracks" when a burst lands inside the window.
|
||||
|
||||
@@ -128,6 +128,28 @@ class PlayerController @Inject constructor(
|
||||
*/
|
||||
private var queueRefs: List<TrackRef> = emptyList()
|
||||
|
||||
init {
|
||||
// A remote stall that survived the watchdog's retries is a playback
|
||||
// failure like any other: the user gets the snackbar and the operator
|
||||
// gets an admin-inbox row, via the same reporter that handles dead
|
||||
// files. Without this the session just ends in silence -- the exact
|
||||
// failure the watchdog exists to surface.
|
||||
scope.launch {
|
||||
playerFactory.stallEvents.collect { trackId ->
|
||||
val title = queueRefs.firstOrNull { it.id == trackId }?.title
|
||||
?.takeIf { it.isNotEmpty() } ?: "Track"
|
||||
playbackErrorEventsChannel.trySend(
|
||||
PlaybackErrorEvent(
|
||||
trackId = trackId,
|
||||
kind = "stalled",
|
||||
title = title,
|
||||
detail = "remote renderer stopped and would not resume",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes when [mediaController] is non-null and the listener has
|
||||
* been attached. Used by [awaitReady] so cold-boot callers like
|
||||
@@ -238,8 +260,16 @@ class PlayerController @Inject constructor(
|
||||
autoplay: Boolean = true,
|
||||
) {
|
||||
val controller = mediaController ?: return
|
||||
queueRefs = tracks
|
||||
val items = tracks.map { it.toMediaItem(source) }
|
||||
// One choke point for #2704: a track whose file the server has lost
|
||||
// must not take a queue slot, whichever surface built the list.
|
||||
// Playlists already drop them earlier (toPlayableTrackRefs), but
|
||||
// album play-all, search, radio and cold-boot resume all arrive here
|
||||
// too, and catching it once beats remembering at five call sites.
|
||||
val playable = dropUnavailable(tracks, initialIndex)
|
||||
if (playable.tracks.isEmpty()) return
|
||||
queueRefs = playable.tracks
|
||||
val items = playable.tracks.map { it.toMediaItem(source) }
|
||||
val startIndex = playable.initialIndex
|
||||
// Drift #562 cold-boot resume calls this from a non-Main suspend
|
||||
// context after awaitReady() unblocks (ResumeController launches
|
||||
// on Dispatchers.Default by the time it reaches us). MediaController
|
||||
@@ -248,7 +278,7 @@ class PlayerController @Inject constructor(
|
||||
// if we're already there, run directly to avoid the re-dispatch
|
||||
// latency UI callers depend on.
|
||||
runOnControllerThread(controller) {
|
||||
controller.setMediaItems(items, initialIndex, /* startPositionMs = */ 0L)
|
||||
controller.setMediaItems(items, startIndex, /* startPositionMs = */ 0L)
|
||||
controller.prepare()
|
||||
if (autoplay) controller.play()
|
||||
}
|
||||
@@ -852,3 +882,37 @@ data class PlaybackErrorEvent(
|
||||
val title: String,
|
||||
val detail: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A queue with the server-missing tracks removed, and the caller's starting
|
||||
* index moved to match (#2704).
|
||||
*/
|
||||
data class PlayableQueue(val tracks: List<TrackRef>, val initialIndex: Int)
|
||||
|
||||
/**
|
||||
* Drop tracks the server has no file for, keeping [initialIndex] pointing at
|
||||
* the same music.
|
||||
*
|
||||
* The index is the fiddly half and the reason this is a function rather than
|
||||
* a `filter` at the call site: removing entries before the requested position
|
||||
* would otherwise start playback on the wrong track. The new index is the
|
||||
* count of surviving tracks ahead of it, which also gives the right behaviour
|
||||
* when the requested track is ITSELF missing — playback starts at the next
|
||||
* one that can play, i.e. it gets skipped.
|
||||
*
|
||||
* Returns an empty queue when nothing survives, which the caller treats as
|
||||
* "don't touch the player": replacing a playing queue with silence because a
|
||||
* stale list turned out to be entirely missing would be worse than ignoring
|
||||
* the request.
|
||||
*/
|
||||
fun dropUnavailable(tracks: List<TrackRef>, initialIndex: Int): PlayableQueue {
|
||||
if (tracks.none { it.unavailable }) return PlayableQueue(tracks, initialIndex)
|
||||
val kept = ArrayList<TrackRef>(tracks.size)
|
||||
var newIndex = 0
|
||||
tracks.forEachIndexed { i, track ->
|
||||
if (track.unavailable) return@forEachIndexed
|
||||
if (i < initialIndex) newIndex++
|
||||
kept.add(track)
|
||||
}
|
||||
return PlayableQueue(kept, newIndex.coerceAtMost((kept.size - 1).coerceAtLeast(0)))
|
||||
}
|
||||
|
||||
@@ -75,6 +75,15 @@ class PlayerFactory @Inject constructor(
|
||||
)
|
||||
val dropEvents: SharedFlow<String> = dropEventsInternal.asSharedFlow()
|
||||
|
||||
// Track ids whose remote playback stalled and could not be resumed. Same
|
||||
// buffering rationale as dropEvents: a burst is one problem, not N.
|
||||
private val stallEventsInternal = MutableSharedFlow<String>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
val stallEvents: SharedFlow<String> = stallEventsInternal.asSharedFlow()
|
||||
|
||||
fun build(): Player {
|
||||
val exo = buildExoPlayer()
|
||||
return MinstrelForwardingPlayer(
|
||||
@@ -84,6 +93,7 @@ class PlayerFactory @Inject constructor(
|
||||
castNetworkLock = CastNetworkLock(context),
|
||||
networkStatus = serverHealth,
|
||||
onDrop = { name -> emitDrop(name) },
|
||||
onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@ package com.fabledsword.minstrel.player
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
|
||||
/**
|
||||
* Cycle on the repeat button: off → all → one → off. Mirrors
|
||||
* `AudioServiceRepeatMode` in flutter_client and maps directly to
|
||||
* Cycle on the repeat button: off → all → one → off. Maps directly to
|
||||
* the three Media3 `Player.REPEAT_MODE_*` int constants.
|
||||
*/
|
||||
enum class RepeatMode { OFF, ALL, ONE }
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||
|
||||
/**
|
||||
* Notices when a UPnP renderer has stopped playing without being asked, and
|
||||
* decides whether to try getting it going again.
|
||||
*
|
||||
* The gap this closes (diagnostics 2026-08-16): a Sonos playing from the
|
||||
* server stopped by itself mid-track while the phone was in Doze. The poll
|
||||
* loop was frozen, so nothing saw it; when the screen came back on the app
|
||||
* faithfully reported "queue track 10, position 113s, not playing" and then
|
||||
* sat there. Playback was over and no part of the app considered that a
|
||||
* problem. A renderer streams autonomously, which is exactly why a failed
|
||||
* stream is invisible without something watching for it.
|
||||
*
|
||||
* Deliberately conservative about what counts as a stall:
|
||||
*
|
||||
* - Only STOPPED (or a transport reporting an error) triggers recovery.
|
||||
* PAUSED is left alone: the likely cause is a person pausing from the
|
||||
* Sonos app or a wall controller, and fighting them for the transport is
|
||||
* obnoxious. A stream that dies stops, it does not pause.
|
||||
* - Only when the operator's last intent was to play. A stop we asked for
|
||||
* is not a stall.
|
||||
* - Only after [STALL_CONFIRM_POLLS] consecutive polls agree, so a single
|
||||
* reading during a track change (Sonos passes through STOPPED and
|
||||
* TRANSITIONING between queue items) never trips it.
|
||||
* - At most [MAX_RESUME_ATTEMPTS] per track, spaced by
|
||||
* [RETRY_SPACING_MS]. A genuinely unplayable file must not become an
|
||||
* infinite retry loop against the renderer.
|
||||
*
|
||||
* Pure decision state, no coroutines and no SOAP: the caller owns the poll
|
||||
* loop and performs the transport calls, this only says what should happen.
|
||||
* That keeps the awkward part — counting, keying and giving up — testable
|
||||
* without a renderer.
|
||||
*/
|
||||
class RemoteStallWatchdog {
|
||||
|
||||
sealed interface Decision {
|
||||
/** Nothing to do. */
|
||||
data object None : Decision
|
||||
|
||||
/**
|
||||
* Ask the renderer to play again. [resumeAtMs] is the last position
|
||||
* observed while it was actually playing, so the caller can seek back
|
||||
* to roughly where the listener was rather than restarting the track.
|
||||
*/
|
||||
data class Resume(val attempt: Int, val resumeAtMs: Long) : Decision
|
||||
|
||||
/** Attempts are exhausted. Report it and stop trying for this track. */
|
||||
data object GiveUp : Decision
|
||||
}
|
||||
|
||||
private var trackKey: String = ""
|
||||
private var lastPlayingPositionMs: Long = 0L
|
||||
private var stoppedStreak: Int = 0
|
||||
private var attempts: Int = 0
|
||||
private var lastAttemptAtMs: Long = 0L
|
||||
private var gaveUp: Boolean = false
|
||||
|
||||
/**
|
||||
* Feed one poll result in, get the action out.
|
||||
*
|
||||
* @param trackUri the renderer's current track URI — identity for the
|
||||
* per-track attempt budget, so moving to the next track forgives a
|
||||
* previous one's failures.
|
||||
* @param statusOk the transport's own status flag: false means the
|
||||
* renderer is reporting an error rather than merely being stopped.
|
||||
* @param playIntent the operator's last play/pause intent.
|
||||
* @param nowMs a monotonic clock (SystemClock.elapsedRealtime), passed in
|
||||
* so tests can drive time.
|
||||
*/
|
||||
@Suppress("ReturnCount") // early returns per state are clearer than nesting
|
||||
fun onPoll(
|
||||
trackUri: String,
|
||||
state: TransportState,
|
||||
statusOk: Boolean,
|
||||
playIntent: Boolean,
|
||||
positionMs: Long,
|
||||
nowMs: Long,
|
||||
): Decision {
|
||||
if (trackUri != trackKey) {
|
||||
// New track: a fresh attempt budget, and no inherited stall state.
|
||||
trackKey = trackUri
|
||||
resetStall()
|
||||
attempts = 0
|
||||
gaveUp = false
|
||||
lastPlayingPositionMs = 0L
|
||||
}
|
||||
|
||||
if (!playIntent) {
|
||||
// Stopped because we asked. Not a stall, and the next genuine one
|
||||
// should start from a clean budget.
|
||||
resetStall()
|
||||
attempts = 0
|
||||
gaveUp = false
|
||||
return Decision.None
|
||||
}
|
||||
|
||||
if (state == TransportState.PLAYING && statusOk) {
|
||||
lastPlayingPositionMs = positionMs
|
||||
resetStall()
|
||||
// A track that recovered and is playing again has earned back its
|
||||
// budget; a later, unrelated stall on the same track should get
|
||||
// the full set of attempts rather than the remainder.
|
||||
attempts = 0
|
||||
return Decision.None
|
||||
}
|
||||
|
||||
val stalled = state == TransportState.STOPPED || !statusOk
|
||||
if (!stalled) {
|
||||
// PAUSED (someone else's doing) or TRANSITIONING/UNKNOWN (in
|
||||
// flight). Neither is a stall; drop the streak so a mid-track
|
||||
// transition doesn't accumulate toward one.
|
||||
resetStall()
|
||||
return Decision.None
|
||||
}
|
||||
|
||||
stoppedStreak += 1
|
||||
if (stoppedStreak < STALL_CONFIRM_POLLS) return Decision.None
|
||||
if (gaveUp) return Decision.None
|
||||
|
||||
if (attempts >= MAX_RESUME_ATTEMPTS) {
|
||||
gaveUp = true
|
||||
return Decision.GiveUp
|
||||
}
|
||||
if (attempts > 0 && nowMs - lastAttemptAtMs < RETRY_SPACING_MS) return Decision.None
|
||||
|
||||
attempts += 1
|
||||
lastAttemptAtMs = nowMs
|
||||
return Decision.Resume(attempt = attempts, resumeAtMs = lastPlayingPositionMs)
|
||||
}
|
||||
|
||||
/** Forget everything — call when the route changes or playback is torn down. */
|
||||
fun reset() {
|
||||
trackKey = ""
|
||||
lastPlayingPositionMs = 0L
|
||||
resetStall()
|
||||
attempts = 0
|
||||
lastAttemptAtMs = 0L
|
||||
gaveUp = false
|
||||
}
|
||||
|
||||
private fun resetStall() {
|
||||
stoppedStreak = 0
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// At the 1s poll cadence this is ~3s of agreement. Sonos passes
|
||||
// through STOPPED between queue items, so one or two readings mean
|
||||
// nothing on their own.
|
||||
const val STALL_CONFIRM_POLLS = 3
|
||||
|
||||
// Three tries at ~5s spacing covers a server blip or a dropped
|
||||
// connection without hammering a renderer whose file is simply bad.
|
||||
const val MAX_RESUME_ATTEMPTS = 3
|
||||
const val RETRY_SPACING_MS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,7 @@ import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Persists the player's last queue + position to Room so a torn-down
|
||||
* session can resume on next app launch. Mirrors
|
||||
* `flutter_client/lib/cache/resume_controller.dart`.
|
||||
* session can resume on next app launch.
|
||||
*
|
||||
* Subscribes to [PlayerController.uiState] in init; persists when the
|
||||
* (track-id, queueIndex) changes — captures real session transitions
|
||||
|
||||
+14
-2
@@ -225,7 +225,14 @@ class AVTransportClient(
|
||||
"TRANSITIONING" -> TransportState.TRANSITIONING
|
||||
else -> TransportState.UNKNOWN
|
||||
}
|
||||
return TransportInfo(state)
|
||||
// CurrentTransportStatus is the renderer's own verdict on whether it is
|
||||
// healthy, and it is the one unambiguous way to tell "the stream died"
|
||||
// from "somebody pressed stop" — both of which land in STOPPED. The
|
||||
// spec defines OK and ERROR_OCCURRED; anything unrecognised (or absent,
|
||||
// which some renderers do) is read as OK so a quiet device is never
|
||||
// treated as a broken one.
|
||||
val statusOk = result["CurrentTransportStatus"]?.let { it != "ERROR_OCCURRED" } ?: true
|
||||
return TransportInfo(state, statusOk)
|
||||
}
|
||||
|
||||
private fun buildDidlLite(uri: String, mime: String, title: String): String {
|
||||
@@ -293,4 +300,9 @@ data class PositionInfo(
|
||||
|
||||
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, UNKNOWN }
|
||||
|
||||
data class TransportInfo(val state: TransportState)
|
||||
/**
|
||||
* [statusOk] is CurrentTransportStatus, defaulted true so the many call sites
|
||||
* that only care about [state] read unchanged and an older/quieter renderer is
|
||||
* never mistaken for a failing one.
|
||||
*/
|
||||
data class TransportInfo(val state: TransportState, val statusOk: Boolean = true)
|
||||
|
||||
@@ -30,8 +30,8 @@ private const val GRADIENT_TWEEN_MS = 600
|
||||
* The held color is NOT reset when [coverUrl] changes — it stays on
|
||||
* the previous track's dominant until the new palette resolves, so the
|
||||
* gradient tweens old→new directly instead of dipping toward the
|
||||
* fallback mid-swap. Mirrors `now_playing_screen.dart`'s preload-then-
|
||||
* swap ("keep the previous dominant"); the cover image swaps smoothly
|
||||
* fallback mid-swap — preload-then-swap, keeping the previous dominant
|
||||
* until the new one resolves. The cover image swaps smoothly
|
||||
* via `CoverPrefetcher`, which warms the next track's bytes into Coil.
|
||||
*
|
||||
* Starts at [Color.Transparent] (cold mount) and resets to it only
|
||||
|
||||
@@ -55,8 +55,9 @@ private const val COVER_SIZE_DP = 48
|
||||
private const val SCRUBBER_ROW_HEIGHT_DP = 4
|
||||
|
||||
// Upward flick speed (dp/s) that expands the bar into NowPlaying.
|
||||
// Mirrors player_bar.dart's 200 px/s threshold; expressed in dp and
|
||||
// converted via density so the gesture feels the same across screens.
|
||||
// The 200 px/s threshold carries over from the Flutter player bar,
|
||||
// expressed in dp and converted via density so the gesture feels the
|
||||
// same across screens.
|
||||
private const val SWIPE_UP_VELOCITY_DP = 200
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@@ -129,7 +130,7 @@ fun MiniPlayer(
|
||||
.collectAsStateWithLifecycle(initialValue = false)
|
||||
|
||||
// Swipe up anywhere on the bar to expand into the full player —
|
||||
// mirrors player_bar.dart. We only act on a clear upward flick
|
||||
// We only act on a clear upward flick
|
||||
// (negative velocity past the threshold) so a slow tap-with-jitter
|
||||
// doesn't accidentally open the screen. The horizontal seek slider
|
||||
// keeps its own gestures; a vertical draggable only claims
|
||||
|
||||
@@ -371,9 +371,7 @@ private fun NowPlayingContent(
|
||||
TrackHeader(title = track.title, artist = track.artistName, album = track.albumTitle)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
// Action row (like, shuffle, repeat, queue, kebab) sits ABOVE the
|
||||
// scrubber — Flutter's _SecondaryControls placement
|
||||
// (now_playing_screen.dart:464). Android previously had it below
|
||||
// the transport row.
|
||||
// scrubber. Android previously had it below the transport row.
|
||||
BottomActionsRow(
|
||||
navController = navController,
|
||||
track = track,
|
||||
|
||||
+3
-3
@@ -88,7 +88,7 @@ class PlaylistsRepository @Inject constructor(
|
||||
// Reconcile: BuildSystemPlaylists rotates system-playlist
|
||||
// UUIDs every rebuild, so upsert alone leaves stale rows
|
||||
// whose detail fetch 404s ("That playlist no longer
|
||||
// exists"). Mirrors playlists_provider.dart's deleteWhere.
|
||||
// exists").
|
||||
playlistDao.replaceList(
|
||||
userId = userId,
|
||||
freshOwnedIds = wire.owned.map { it.id },
|
||||
@@ -111,8 +111,7 @@ class PlaylistsRepository @Inject constructor(
|
||||
api.get(id)
|
||||
} catch (e: HttpException) {
|
||||
// Server says this playlist is gone — drop the stale cache
|
||||
// row so the list stops showing it. Mirrors
|
||||
// playlists_provider.dart's deleteWhere on detail failure.
|
||||
// row so the list stops showing it.
|
||||
if (e.code() == HttpURLConnection.HTTP_NOT_FOUND) {
|
||||
playlistDao.deleteByIds(listOf(id))
|
||||
}
|
||||
@@ -326,4 +325,5 @@ private fun PlaylistTrackWire.toDomain(): PlaylistTrackRef =
|
||||
artistName = artistName,
|
||||
durationSec = durationSec,
|
||||
streamUrl = streamUrl,
|
||||
unavailable = unavailable,
|
||||
)
|
||||
|
||||
+10
-3
@@ -7,7 +7,6 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -75,6 +74,7 @@ import com.fabledsword.minstrel.playlists.data.PlaylistDetailRef
|
||||
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
||||
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||
@@ -339,6 +339,7 @@ fun PlaylistDetailScreen(
|
||||
}
|
||||
}
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
@@ -430,7 +431,6 @@ private fun PlaylistDetailBody(
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 140.dp),
|
||||
) {
|
||||
item { PlaylistHeader(detail.playlist, onPlayAll, onShuffleAll, onRegenerate) }
|
||||
item { HorizontalDivider() }
|
||||
@@ -577,9 +577,16 @@ private fun TrackRow(
|
||||
) {
|
||||
val enabled = row.isAvailable
|
||||
val rowAlpha = if (enabled) 1f else UNAVAILABLE_ALPHA
|
||||
// A greyed row with no explanation reads as a bug. Say which kind of
|
||||
// dead it is: a missing file (#2527) is the library's problem and can
|
||||
// fix itself when the file returns, so it earns a line the user can
|
||||
// act on. A removed track needs no note — grey and unplayable is the
|
||||
// whole story once it's gone from the library.
|
||||
val subtitle = row.artistName.ifEmpty { row.albumTitle }
|
||||
val artistLine = if (row.unavailable) "$subtitle · File missing" else subtitle
|
||||
TrackRow(
|
||||
title = row.title,
|
||||
artist = row.artistName.ifEmpty { row.albumTitle },
|
||||
artist = artistLine,
|
||||
trackId = row.trackId.orEmpty(),
|
||||
onClick = onClick,
|
||||
nowPlaying = nowPlaying,
|
||||
|
||||
@@ -41,6 +41,7 @@ import com.fabledsword.minstrel.playlists.data.playPlaylistShuffled
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
@@ -149,6 +150,7 @@ fun PlaylistsListScreen(
|
||||
viewModel.transientMessages.collect { snackbar.showSnackbar(it) }
|
||||
}
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
MinstrelTopAppBar(
|
||||
|
||||
@@ -30,7 +30,6 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
||||
/**
|
||||
* One playlist tile. Sized to match `AlbumCard` (176dp wide, 144dp
|
||||
* square cover) so they line up in the Home Playlists carousel.
|
||||
* Mirrors `flutter_client/lib/playlists/widgets/playlist_card.dart`.
|
||||
*
|
||||
* System playlists carry a small "For You" / "Discover" / etc. label
|
||||
* subtitle under the name (substituting for the artist-name line on
|
||||
|
||||
-1
@@ -28,7 +28,6 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
||||
|
||||
/**
|
||||
* Placeholder tile for a system playlist that hasn't generated yet.
|
||||
* Mirrors `flutter_client/lib/playlists/widgets/playlist_placeholder_card.dart`.
|
||||
* Sized to match [PlaylistCard] so the Home Playlists row stays
|
||||
* visually consistent.
|
||||
*
|
||||
|
||||
@@ -45,6 +45,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.nav.Requests
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
@@ -58,6 +59,7 @@ fun RequestsScreen(
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
MinstrelTopAppBar(
|
||||
@@ -229,7 +231,7 @@ private fun CancelConfirmDialog(
|
||||
@Composable
|
||||
private fun KindAvatar(kind: String) {
|
||||
// Flutter mapping: disc-3 for artist, library-big for album,
|
||||
// music for track. Mirrors lib/requests/requests_screen.dart.
|
||||
// music for track.
|
||||
val icon = when (kind) {
|
||||
"artist" -> Lucide.Disc3
|
||||
"album" -> Lucide.LibraryBig
|
||||
|
||||
@@ -55,6 +55,7 @@ import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
@@ -76,6 +77,7 @@ fun SearchScreen(
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
|
||||
@@ -55,6 +55,7 @@ import com.fabledsword.minstrel.nav.Admin
|
||||
import com.fabledsword.minstrel.nav.Requests
|
||||
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
|
||||
import com.fabledsword.minstrel.nav.ServerUrl
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
import com.fabledsword.minstrel.theme.ThemeMode
|
||||
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
||||
@@ -81,6 +82,7 @@ fun SettingsScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
MinstrelTopAppBar(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user