diff --git a/.dockerignore b/.dockerignore index 2653c48b..938a46c7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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/ diff --git a/.gitignore b/.gitignore index b2fcc709..f1619185 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminRequestsRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminRequestsRepository.kt index 18587b41..c6ee38f5 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminRequestsRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminRequestsRepository.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminLandingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminLandingScreen.kt index 0a60d78b..a3c0b9cf 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminLandingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminLandingScreen.kt @@ -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( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineScreen.kt index 5822ad19..4031a5fc 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminQuarantineScreen.kt @@ -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( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsScreen.kt index bad71271..114102df 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsScreen.kt @@ -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( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminTagSourcesScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminTagSourcesScreen.kt index cc6f0cf8..541130a7 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminTagSourcesScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminTagSourcesScreen.kt @@ -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( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersScreen.kt index 1d4d5fff..2b232a27 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersScreen.kt @@ -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( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt index caabc851..8456fad9 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminInvitesApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminInvitesApi.kt index 04bd383c..b0af65c1 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminInvitesApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminInvitesApi.kt @@ -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. diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminQuarantineApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminQuarantineApi.kt index 4a483849..1e8f168a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminQuarantineApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminQuarantineApi.kt @@ -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). diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminRequestsApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminRequestsApi.kt index ef5b86cd..ef0db4d8 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminRequestsApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminRequestsApi.kt @@ -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 — diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminUsersApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminUsersApi.kt index 3a0d4622..b6ef8172 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminUsersApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminUsersApi.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AuthApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AuthApi.kt index cb8cbebb..b7dc6e3a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AuthApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AuthApi.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/DiscoverApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/DiscoverApi.kt index cd0215ea..2b8e1945 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/DiscoverApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/DiscoverApi.kt @@ -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. diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/EventsApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/EventsApi.kt index dff15f50..0c56f259 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/EventsApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/EventsApi.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/HistoryApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/HistoryApi.kt index ee4dcbaf..c5e43cc5 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/HistoryApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/HistoryApi.kt @@ -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") diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/HomeApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/HomeApi.kt index acfe8261..d4ece00d 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/HomeApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/HomeApi.kt @@ -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. */ diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LibraryApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LibraryApi.kt index 557f5443..5f16721f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LibraryApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LibraryApi.kt @@ -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 + // 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 + + @GET("api/library/years") + suspend fun getAlbumYears(): List + + /** + * 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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LikesApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LikesApi.kt index e3c914ce..0be5ba45 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LikesApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LikesApi.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/MeApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/MeApi.kt index 99dc9c7c..6970e767 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/MeApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/MeApi.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/PlaylistsApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/PlaylistsApi.kt index c322250a..6844baa1 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/PlaylistsApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/PlaylistsApi.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/QuarantineApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/QuarantineApi.kt index 574b299c..91f40d4b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/QuarantineApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/QuarantineApi.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/RadioApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/RadioApi.kt index 14ea0eda..12bcecc7 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/RadioApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/RadioApi.kt @@ -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 { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/RequestsApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/RequestsApi.kt index cc9ed2db..dd24e0b5 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/RequestsApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/RequestsApi.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/SearchApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/SearchApi.kt index 70fd52fd..5023c6c4 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/SearchApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/SearchApi.kt @@ -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. */ diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthController.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthController.kt index 54cb281b..440efd8c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthController.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/ShuffleSource.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/ShuffleSource.kt index 6cf6db84..d64a3f0a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/ShuffleSource.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/ShuffleSource.kt @@ -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): List { 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) + } } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheConfig.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheConfig.kt index dc0453c2..6b20c669 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheConfig.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheConfig.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheSettings.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheSettings.kt index 246ac2a0..4e576cf9 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheSettings.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheSettings.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt index 5e5c9306..49b0f9cf 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt @@ -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) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistDao.kt index 1fc818b8..88e78401 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistDao.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AudioCacheIndexEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AudioCacheIndexEntity.kt index efdc9a87..07ead191 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AudioCacheIndexEntity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AudioCacheIndexEntity.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedAlbumEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedAlbumEntity.kt index 6255a20b..05274bda 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedAlbumEntity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedAlbumEntity.kt @@ -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") diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedArtistEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedArtistEntity.kt index 8601805c..7ba15a50 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedArtistEntity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedArtistEntity.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedHomeIndexEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedHomeIndexEntity.kt index 7d439de7..914bccd4 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedHomeIndexEntity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedHomeIndexEntity.kt @@ -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" diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedLikeEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedLikeEntity.kt index 6f3af1e2..34050fdc 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedLikeEntity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedLikeEntity.kt @@ -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. diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedMutationEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedMutationEntity.kt index bbc979bb..bed65edb 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedMutationEntity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedMutationEntity.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedPlaylistEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedPlaylistEntity.kt index 8b0bb20a..82701d62 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedPlaylistEntity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedPlaylistEntity.kt @@ -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. diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedPlaylistTrackEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedPlaylistTrackEntity.kt index ef29db40..d70acbb6 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedPlaylistTrackEntity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedPlaylistTrackEntity.kt @@ -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. */ diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedQuarantineEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedQuarantineEntity.kt index 5d50238f..f3ccc73f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedQuarantineEntity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedQuarantineEntity.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedResumeStateEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedResumeStateEntity.kt index 1066006d..64cbf588 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedResumeStateEntity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedResumeStateEntity.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedTrackEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedTrackEntity.kt index 8653a60a..d8fe1624 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedTrackEntity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedTrackEntity.kt @@ -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(), ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/sync/SyncController.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/sync/SyncController.kt index 106c3827..a7c8620b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/sync/SyncController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/sync/SyncController.kt @@ -219,4 +219,5 @@ private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity( filePath = filePath, fileFormat = fileFormat, genre = genre, + missing = missing, ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverScreen.kt index 02a21224..f62d06a6 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/discover/ui/DiscoverScreen.kt @@ -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( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/events/EventsStream.kt b/android/app/src/main/java/com/fabledsword/minstrel/events/EventsStream.kt index b375756a..b7b7e32d 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/events/EventsStream.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/events/EventsStream.kt @@ -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). diff --git a/android/app/src/main/java/com/fabledsword/minstrel/events/LiveEvent.kt b/android/app/src/main/java/com/fabledsword/minstrel/events/LiveEvent.kt index c296b25f..99656fec 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/events/LiveEvent.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/events/LiveEvent.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/events/LiveEventsDispatcher.kt b/android/app/src/main/java/com/fabledsword/minstrel/events/LiveEventsDispatcher.kt index 9913508d..ed69d41b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/events/LiveEventsDispatcher.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/events/LiveEventsDispatcher.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt b/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt index 290dc0a6..5a9d436a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/history/ui/HistoryTab.kt @@ -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" diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt index c5c22095..21a4e9cb 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryMappers.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryMappers.kt index 8df83a69..b90e9359 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryMappers.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryMappers.kt @@ -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 = diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryRepository.kt index 5dc4bc69..90749260 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryRepository.kt @@ -161,7 +161,52 @@ class LibraryRepository @Inject constructor( suspend fun shuffleLibrary(limit: Int = SHUFFLE_DEFAULT_LIMIT): List = 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 = + 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 = + 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, val total: Int) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt index ecb398d0..ee428610 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt @@ -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( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt index f1fa4359..398b4e90 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt @@ -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( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/BrowseViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/BrowseViewModel.kt new file mode 100644 index 00000000..957c1ed4 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/BrowseViewModel.kt @@ -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 = 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.Loading) + val genres: StateFlow>> = genresInternal.asStateFlow() + + private val yearsInternal = MutableStateFlow>>(UiState.Loading) + val years: StateFlow>> = yearsInternal.asStateFlow() + + private val genreFilterInternal = MutableStateFlow("") + val genreFilter: StateFlow = genreFilterInternal.asStateFlow() + + private val genreSortInternal = MutableStateFlow(GenreSort.COUNT) + val genreSort: StateFlow = genreSortInternal.asStateFlow() + + private val selectedGenreInternal = MutableStateFlow(null) + val selectedGenre: StateFlow = selectedGenreInternal.asStateFlow() + + private val selectedYearInternal = MutableStateFlow(null) + val selectedYear: StateFlow = selectedYearInternal.asStateFlow() + + private val genreAlbumsInternal = MutableStateFlow(AlbumBrowseState()) + val genreAlbums: StateFlow = genreAlbumsInternal.asStateFlow() + + private val yearAlbumsInternal = MutableStateFlow(AlbumBrowseState()) + val yearAlbums: StateFlow = 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, + 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, + filter: String, + sort: GenreSort, +): List { + 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, + 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): List = + 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 } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/GenresTab.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/GenresTab.kt new file mode 100644 index 00000000..cf0290e2 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/GenresTab.kt @@ -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" +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt index ee290b44..7d31d1b4 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt @@ -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( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/YearsTab.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/YearsTab.kt new file mode 100644 index 00000000..b4d53c0a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/YearsTab.kt @@ -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, + ) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/AlbumRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/AlbumRef.kt index a58369de..512565f1 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/AlbumRef.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/AlbumRef.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/ArtistRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/ArtistRef.kt index 94b78166..73dbf1f1 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/ArtistRef.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/ArtistRef.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/Discover.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/Discover.kt index bd05de60..c89d7ad5 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/Discover.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/Discover.kt @@ -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. diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/Invite.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/Invite.kt index c7eadab2..5513da15 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/Invite.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/Invite.kt @@ -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); diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/ListenBrainzStatus.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/ListenBrainzStatus.kt index 0813d11e..70175138 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/ListenBrainzStatus.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/ListenBrainzStatus.kt @@ -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` diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt index b690f911..676eb33c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt @@ -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` diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/Request.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/Request.kt index 45b075d6..b4cdbe0e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/Request.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/Request.kt @@ -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. * diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/SystemPlaylistsStatus.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/SystemPlaylistsStatus.kt index edee47ee..5204a0dc 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/SystemPlaylistsStatus.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/SystemPlaylistsStatus.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/TrackRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/TrackRef.kt index 345d0137..06cc3ce1 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/TrackRef.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/TrackRef.kt @@ -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` diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/UpdateInfo.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/UpdateInfo.kt index 745de7c4..bf6d052a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/UpdateInfo.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/UpdateInfo.kt @@ -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. diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AlbumWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AlbumWire.kt index acf8f626..a01a84bf 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AlbumWire.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AlbumWire.kt @@ -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( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/ArtistWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/ArtistWire.kt index 1c389a6a..a9d2f551 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/ArtistWire.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/ArtistWire.kt @@ -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( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/BrowseWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/BrowseWire.kt new file mode 100644 index 00000000..346311c1 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/BrowseWire.kt @@ -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, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/DiscoverWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/DiscoverWire.kt index 25ae924e..d7db19fa 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/DiscoverWire.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/DiscoverWire.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/EventsWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/EventsWire.kt index 47072c12..5ccecbdd 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/EventsWire.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/EventsWire.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/HomeIndexWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/HomeIndexWire.kt index 3c254e33..4f12c73f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/HomeIndexWire.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/HomeIndexWire.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/MyProfileWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/MyProfileWire.kt index 0086bb3e..a1e8aa58 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/MyProfileWire.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/MyProfileWire.kt @@ -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). diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/PlaylistWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/PlaylistWire.kt index dd1b977f..a4fa6152 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/PlaylistWire.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/PlaylistWire.kt @@ -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, ) /** diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/QuarantineMineWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/QuarantineMineWire.kt index ac77d37c..2a9fde15 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/QuarantineMineWire.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/QuarantineMineWire.kt @@ -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` diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/RequestWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/RequestWire.kt index faa4973c..f6be7723 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/RequestWire.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/RequestWire.kt @@ -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`. diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SyncResponseWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SyncResponseWire.kt index 23bbc822..eb107be8 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SyncResponseWire.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SyncResponseWire.kt @@ -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, ) /** diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/TrackWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/TrackWire.kt index dba0ab70..ec230fc0 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/TrackWire.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/TrackWire.kt @@ -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, ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/AudioPrefetcher.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/AudioPrefetcher.kt index 1ac73695..805d68d9 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/AudioPrefetcher.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/AudioPrefetcher.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt index be14cc46..7bc0bb40 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelForwardingPlayer.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlaybackErrorReporter.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlaybackErrorReporter.kt index 24e6458d..929692b4 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlaybackErrorReporter.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlaybackErrorReporter.kt @@ -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. diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt index 0a4222b6..631b0ad5 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt @@ -128,6 +128,28 @@ class PlayerController @Inject constructor( */ private var queueRefs: List = 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, 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, initialIndex: Int): PlayableQueue { + if (tracks.none { it.unavailable }) return PlayableQueue(tracks, initialIndex) + val kept = ArrayList(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))) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt index 7c3a2cad..745e6b8c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt @@ -75,6 +75,15 @@ class PlayerFactory @Inject constructor( ) val dropEvents: SharedFlow = 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( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val stallEvents: SharedFlow = 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) }, ) } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerUiState.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerUiState.kt index 4a101ec0..ae7518cb 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerUiState.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerUiState.kt @@ -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 } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/RemoteStallWatchdog.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/RemoteStallWatchdog.kt new file mode 100644 index 00000000..ed993480 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/RemoteStallWatchdog.kt @@ -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 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ResumeController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ResumeController.kt index fddf8e46..ca8569a7 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ResumeController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ResumeController.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt index 0d27a4f2..6670db0c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/upnp/AVTransportClient.kt @@ -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) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/DominantColor.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/DominantColor.kt index b4be6420..acaa72ef 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/DominantColor.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/DominantColor.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt index 1e378ff5..eba3ddce 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt index 7fb99d3d..7179063a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt @@ -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, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt index d7b697ce..b3d99126 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt @@ -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, ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt index a63bed9a..1106fdc0 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt @@ -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, diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt index f038550e..3df31460 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistsListScreen.kt @@ -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( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt index 55ef6cfb..afaeca0f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistPlaceholderCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistPlaceholderCard.kt index 272abda5..86377ce4 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistPlaceholderCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistPlaceholderCard.kt @@ -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. * diff --git a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt index 2856a704..e0db9edc 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt index 2766ba43..55abfcc9 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/search/ui/SearchScreen.kt @@ -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( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt index 1dfc17c6..52fa6333 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt @@ -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( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/HorizontalScrollRow.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/HorizontalScrollRow.kt index d1e4642f..b544f5c1 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/HorizontalScrollRow.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/HorizontalScrollRow.kt @@ -15,9 +15,6 @@ import androidx.compose.ui.unit.dp /** * Labeled horizontal-scroll section used throughout the Home screen. - * Mirrors `flutter_client/lib/library/widgets/horizontal_scroll_row.dart` - * — a Fraunces section title at 16dp gutter, then a horizontal LazyRow - * with the same gutter and 8dp inter-item spacing. * * Pass empty `title` to render a continuation row directly under a * previously-titled one (used by Rediscover when it has both album and diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/PlayCircleButton.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/PlayCircleButton.kt index 933cfa75..c7c7e677 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/PlayCircleButton.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/PlayCircleButton.kt @@ -24,10 +24,7 @@ import kotlinx.coroutines.launch /** * Always-visible circular play button overlaid on Home tile cover art - * (AlbumCard / ArtistCard / PlaylistCard). Mirrors - * `flutter_client/lib/library/widgets/play_circle_button.dart`: 44dp - * accent-colored disc, parchment Play icon, drop shadow, self-managed - * loading spinner. + * (AlbumCard / ArtistCard / PlaylistCard). * * [onPlay] is a suspend lambda so the caller can await the fetch-and- * queue setup. While it runs, the icon is swapped for a spinner and diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt index d5e3310d..d445cb5d 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt @@ -2,9 +2,14 @@ package com.fabledsword.minstrel.shared.widgets import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable @@ -20,6 +25,26 @@ import com.fabledsword.minstrel.update.ui.UpdateBanner import com.fabledsword.minstrel.update.ui.VersionTooOldBanner import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel +/** + * `contentWindowInsets` for a screen's own Scaffold when that screen + * renders inside [ShellScaffold]. + * + * The shell owns the window insets for in-shell routes: it consumes the + * status bar at the top and holds the navigation-bar space at the + * bottom, below the MiniPlayer. A screen Scaffold's default + * `contentWindowInsets` is the raw system bars, and Scaffold reads that + * value directly (`contentWindowInsets.asPaddingValues()`) rather than + * through the consumption-aware modifier chain — so the shell consuming + * the inset does NOT reach it. Left at the default, every in-shell + * screen pads its content up by the nav-bar height a second time: the + * dead strip between the last list row and the MiniPlayer. + * + * Full-screen routes (NowPlaying / Queue / Login / ServerUrl) keep the + * default — no shell sits above them, so their Scaffold is the only + * thing that can inset them. + */ +val ShellContentWindowInsets: WindowInsets = WindowInsets(0, 0, 0, 0) + /** * Outer shell wrapper for "in-app" routes — banners on top * (conditional, none implemented yet), routed screen filling the @@ -29,8 +54,6 @@ import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewMode * its own Scaffold's snackbar host as well; this one is the shell- * wide catch-all for events that outlive a single screen). * - * Mirrors the Flutter `_ShellWithPlayerBar` (lib/shared/routing.dart). - * * Top-level routes (Home / Library / Search / Discover / Playlists / * Settings / Admin / detail screens) wrap themselves with this. * Full-screen routes (NowPlaying / Queue / ServerUrl / Login) @@ -82,12 +105,31 @@ fun ShellScaffold( VersionTooOldBanner() UpdateBanner() ConnectionErrorBanner() - Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + // The window is edge-to-edge, so something has to own the + // bottom navigation-bar inset, and the shell claims it once + // here: the content region consumes it so nothing inside a + // screen pads for it a second time (see + // [ShellContentWindowInsets] for the Scaffold half of that — + // Scaffold reads its insets outside the consumption chain), and + // the Spacer below re-holds the space for the system bar. + // Un-owned, the inset re-appears inside each screen as a dead + // strip between the last list row and the MiniPlayer. + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .consumeWindowInsets(WindowInsets.navigationBars), + ) { content() } SnackbarHost(hostState = snackbarHostState) MiniPlayer( onExpandClick = onExpandPlayer, ) + // Unconditional: the MiniPlayer renders nothing when no track + // is loaded, and the content region has already given the inset + // up, so this is what keeps a fresh install's last row off the + // gesture pill. + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/theme/FabledSwordTokens.kt b/android/app/src/main/java/com/fabledsword/minstrel/theme/FabledSwordTokens.kt index 7309a465..5a52e73c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/theme/FabledSwordTokens.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/theme/FabledSwordTokens.kt @@ -5,7 +5,8 @@ import androidx.compose.ui.unit.dp /** * Raw color values for the dark surface cohort. Source of truth: - * `flutter_client/shared/fabledsword.tokens.json` (dark block). + * `shared/fabledsword.tokens.json` (dark block) — moved to the repo root + * when the Flutter client was deleted; the tokens were never its property. * * Token names are **semantic roles**, not literal colors. In dark * mode, `parchment` is light text on dark surfaces (#E8E4D8); in diff --git a/android/app/src/main/java/com/fabledsword/minstrel/theme/MinstrelTheme.kt b/android/app/src/main/java/com/fabledsword/minstrel/theme/MinstrelTheme.kt index b68282f1..220e9f1b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/theme/MinstrelTheme.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/theme/MinstrelTheme.kt @@ -8,7 +8,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider /** - * The Flutter theme's `ColorScheme` role mapping (theme_data.dart): + * The `ColorScheme` role mapping: * primary = accent (forest-teal) — same both modes * onPrimary = onAction * surface = iron diff --git a/android/app/src/test/java/com/fabledsword/minstrel/library/ui/BrowseIndexTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/library/ui/BrowseIndexTest.kt new file mode 100644 index 00000000..6b3ac788 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/library/ui/BrowseIndexTest.kt @@ -0,0 +1,148 @@ +package com.fabledsword.minstrel.library.ui + +import com.fabledsword.minstrel.library.data.GenreCount +import com.fabledsword.minstrel.library.data.YearCount +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class VisibleGenresTest { + + // Server order: track count descending, name breaking ties. + private val index = listOf( + GenreCount("Rock", 420), + GenreCount("Electronic", 260), + GenreCount("rock", 12), + GenreCount("Alt. Rock", 7), + GenreCount("Rock/Pop", 3), + ) + + @Test + fun `count sort leaves the server's order alone`() { + val out = visibleGenres(index, filter = "", sort = GenreSort.COUNT) + assertEquals(index.map { it.genre }, out.map { it.genre }) + } + + @Test + fun `name sort is alphabetical and case-insensitive`() { + val out = visibleGenres(index, filter = "", sort = GenreSort.NAME) + assertEquals( + listOf("Alt. Rock", "Electronic", "Rock", "rock", "Rock/Pop"), + out.map { it.genre }, + ) + } + + /** + * The input is the list held in the loaded UiState. Sorting it in place + * would reorder what every other reader sees — including the count-sorted + * view the user can toggle straight back to. + */ + @Test + fun `sorting does not mutate the input`() { + val before = index.map { it.genre } + visibleGenres(index, filter = "", sort = GenreSort.NAME) + assertEquals(before, index.map { it.genre }) + } + + @Test + fun `filter matches anywhere in the label, ignoring case`() { + val out = visibleGenres(index, filter = "roCK", sort = GenreSort.COUNT) + assertEquals(listOf("Rock", "rock", "Alt. Rock", "Rock/Pop"), out.map { it.genre }) + } + + @Test + fun `a blank filter is not a filter`() { + assertEquals(index.size, visibleGenres(index, " ", GenreSort.COUNT).size) + } + + @Test + fun `filter and sort compose`() { + val out = visibleGenres(index, filter = "rock", sort = GenreSort.NAME) + assertEquals(listOf("Alt. Rock", "Rock", "rock", "Rock/Pop"), out.map { it.genre }) + } + + @Test + fun `no match yields an empty list rather than everything`() { + assertTrue(visibleGenres(index, "zydeco", GenreSort.COUNT).isEmpty()) + } + + /** + * "Rock/Pop" is a real ID3 tag and the reason the server takes the genre as + * a query parameter. The client must never split or rewrite it — the album + * filter matches the whole stored string. + */ + @Test + fun `a slashed tag survives intact`() { + val out = visibleGenres(index, filter = "Rock/", sort = GenreSort.COUNT) + assertEquals(listOf("Rock/Pop"), out.map { it.genre }) + } +} + +class GroupByDecadeTest { + + @Test + fun `groups by decade, newest decade first`() { + val out = groupByDecade( + listOf( + YearCount(2003, 4), + YearCount(1999, 2), + YearCount(2007, 1), + YearCount(1994, 3), + YearCount(2021, 5), + ), + ) + assertEquals(listOf(2020, 2000, 1990), out.map { it.decade }) + } + + @Test + fun `years within a decade are newest first`() { + val out = groupByDecade( + listOf(YearCount(2003, 1), YearCount(2007, 1), YearCount(2001, 1)), + ) + assertEquals(listOf(2007, 2003, 2001), out.single().years.map { it.year }) + } + + @Test + fun `a decade's album count is the sum of its years`() { + val out = groupByDecade(listOf(YearCount(2003, 4), YearCount(2007, 6))) + assertEquals(10, out.single().albumCount) + } + + @Test + fun `a year on a decade boundary lands in the decade it starts`() { + val out = groupByDecade(listOf(YearCount(2000, 1), YearCount(1999, 1))) + assertEquals(listOf(2000, 1990), out.map { it.decade }) + } + + @Test + fun `an empty index yields no groups`() { + assertTrue(groupByDecade(emptyList()).isEmpty()) + } +} + +class AlbumCountLabelTest { + + /** + * A count that appears as "0 albums" and then corrects itself reads as a + * bug, so the first page's flight shows nothing at all. + */ + @Test + fun `no label while the first page is loading`() { + assertEquals("", albumCountLabel(total = 0, loading = true, loaded = 0)) + } + + @Test + fun `a later page keeps showing the known total`() { + assertEquals("40 albums", albumCountLabel(total = 40, loading = true, loaded = 20)) + } + + @Test + fun `singular is not pluralised`() { + assertEquals("1 album", albumCountLabel(total = 1, loading = false, loaded = 1)) + } + + @Test + fun `a genuinely empty result says zero`() { + assertEquals("0 albums", albumCountLabel(total = 0, loading = false, loaded = 0)) + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/DropUnavailableTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/DropUnavailableTest.kt new file mode 100644 index 00000000..fd332b5a --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/DropUnavailableTest.kt @@ -0,0 +1,102 @@ +package com.fabledsword.minstrel.player + +import com.fabledsword.minstrel.models.TrackRef +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The queue filter for #2704. The index arithmetic is the part worth pinning: + * getting it wrong starts playback on the wrong track, which is a subtler and + * more annoying bug than the one being fixed. + */ +class DropUnavailableTest { + + private fun track(id: String, unavailable: Boolean = false) = + TrackRef( + id = id, + title = id, + albumId = "al-1", + artistId = "ar-1", + unavailable = unavailable, + ) + + private fun queue(vararg spec: Pair) = + spec.map { (id, missing) -> track(id, missing) } + + @Test + fun `a queue with nothing missing is returned untouched`() { + val tracks = queue("a" to false, "b" to false, "c" to false) + val out = dropUnavailable(tracks, initialIndex = 1) + assertEquals(tracks, out.tracks) + assertEquals(1, out.initialIndex) + } + + @Test + fun `missing tracks are dropped`() { + val out = dropUnavailable(queue("a" to false, "b" to true, "c" to false), 0) + assertEquals(listOf("a", "c"), out.tracks.map { it.id }) + } + + /** + * The whole reason for the index math: two removals ahead of the target + * would otherwise start playback two tracks early. + */ + @Test + fun `the starting index follows its track past earlier removals`() { + val out = dropUnavailable( + queue("a" to true, "b" to true, "c" to false, "d" to false), + initialIndex = 2, + ) + assertEquals(listOf("c", "d"), out.tracks.map { it.id }) + assertEquals(0, out.initialIndex) + assertEquals("c", out.tracks[out.initialIndex].id) + } + + @Test + fun `removals after the starting index leave it alone`() { + val out = dropUnavailable( + queue("a" to false, "b" to false, "c" to true), + initialIndex = 1, + ) + assertEquals("b", out.tracks[out.initialIndex].id) + } + + /** Tapping a missing track starts the next playable one — it gets skipped. */ + @Test + fun `asking to start on a missing track starts on the next playable one`() { + val out = dropUnavailable( + queue("a" to false, "b" to true, "c" to false), + initialIndex = 1, + ) + assertEquals("c", out.tracks[out.initialIndex].id) + } + + @Test + fun `a missing track at the end cannot push the index out of bounds`() { + val out = dropUnavailable( + queue("a" to false, "b" to false, "c" to true), + initialIndex = 2, + ) + assertTrue(out.initialIndex in out.tracks.indices) + assertEquals("b", out.tracks[out.initialIndex].id) + } + + /** + * The caller treats this as "don't touch the player". Replacing what is + * currently playing with silence, because a stale list turned out to be + * entirely missing, would be worse than ignoring the request. + */ + @Test + fun `an entirely missing queue comes back empty`() { + val out = dropUnavailable(queue("a" to true, "b" to true), 0) + assertTrue(out.tracks.isEmpty()) + assertEquals(0, out.initialIndex) + } + + @Test + fun `an empty queue stays empty`() { + val out = dropUnavailable(emptyList(), 0) + assertTrue(out.tracks.isEmpty()) + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/player/RemoteStallWatchdogTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/player/RemoteStallWatchdogTest.kt new file mode 100644 index 00000000..144db35c --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/RemoteStallWatchdogTest.kt @@ -0,0 +1,187 @@ +package com.fabledsword.minstrel.player + +import com.fabledsword.minstrel.player.output.upnp.TransportState +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class RemoteStallWatchdogTest { + + private val uri = "http://server/api/tracks/t-1/stream.flac" + private val other = "http://server/api/tracks/t-2/stream.flac" + + /** Feed one poll, defaulting everything to "playing normally". */ + private fun RemoteStallWatchdog.poll( + trackUri: String = uri, + state: TransportState = TransportState.PLAYING, + statusOk: Boolean = true, + playIntent: Boolean = true, + positionMs: Long = 0L, + nowMs: Long = 0L, + ) = onPoll(trackUri, state, statusOk, playIntent, positionMs, nowMs) + + /** Drive [n] stopped polls and return the last decision. */ + private fun RemoteStallWatchdog.stopFor( + n: Int, + nowMs: Long = 0L, + ): RemoteStallWatchdog.Decision { + var last: RemoteStallWatchdog.Decision = RemoteStallWatchdog.Decision.None + repeat(n) { last = poll(state = TransportState.STOPPED, nowMs = nowMs) } + return last + } + + @Test + fun `playing normally asks for nothing`() { + val w = RemoteStallWatchdog() + repeat(10) { assertIs(w.poll(positionMs = it * 1000L)) } + } + + @Test + fun `a stop we asked for is not a stall`() { + val w = RemoteStallWatchdog() + repeat(10) { + assertIs( + w.poll(state = TransportState.STOPPED, playIntent = false), + ) + } + } + + /** + * Sonos passes through STOPPED between queue items. One or two readings + * must never trigger a resume or every track change would fight itself. + */ + @Test + fun `a brief stop during a track transition is ignored`() { + val w = RemoteStallWatchdog() + assertIs(w.stopFor(2)) + } + + @Test + fun `a sustained unrequested stop asks for a resume`() { + val w = RemoteStallWatchdog() + val decision = w.stopFor(3) + assertIs(decision) + assertEquals(1, decision.attempt) + } + + /** The point of the resume: come back where the listener was. */ + @Test + fun `resume carries the last position seen while playing`() { + val w = RemoteStallWatchdog() + w.poll(state = TransportState.PLAYING, positionMs = 113_000L) + val decision = w.stopFor(3) + assertIs(decision) + assertEquals(113_000L, decision.resumeAtMs) + } + + /** + * Someone pausing from the Sonos app or a wall controller owns the + * transport. Grabbing it back would be a fight the user always loses. + */ + @Test + fun `a pause from elsewhere is left alone`() { + val w = RemoteStallWatchdog() + repeat(10) { + assertIs(w.poll(state = TransportState.PAUSED)) + } + } + + @Test + fun `transitioning is not treated as a stall`() { + val w = RemoteStallWatchdog() + repeat(10) { + assertIs( + w.poll(state = TransportState.TRANSITIONING), + ) + } + } + + /** + * A renderer reporting ERROR_OCCURRED is the unambiguous signal, and it + * should not have to also say STOPPED before we act. + */ + @Test + fun `a transport reporting an error counts as a stall`() { + val w = RemoteStallWatchdog() + var last: RemoteStallWatchdog.Decision = RemoteStallWatchdog.Decision.None + repeat(3) { last = w.poll(state = TransportState.PLAYING, statusOk = false) } + assertIs(last) + } + + @Test + fun `retries are spaced out rather than fired every poll`() { + val w = RemoteStallWatchdog() + assertIs(w.stopFor(3, nowMs = 0L)) + // Immediately after, still stopped: too soon to try again. + assertIs(w.stopFor(1, nowMs = 1_000L)) + assertIs(w.stopFor(1, nowMs = 4_999L)) + // Past the spacing, the next attempt goes out. + val second = w.stopFor(1, nowMs = 5_000L) + assertIs(second) + assertEquals(2, second.attempt) + } + + /** + * The failure this guards against is an unplayable file turning into an + * endless retry loop against the renderer. + */ + @Test + fun `attempts are capped and then it gives up exactly once`() { + val w = RemoteStallWatchdog() + assertIs(w.stopFor(3, nowMs = 0L)) + assertIs(w.stopFor(1, nowMs = 5_000L)) + assertIs(w.stopFor(1, nowMs = 10_000L)) + assertIs(w.stopFor(1, nowMs = 15_000L)) + // Reported once; after that it stays quiet instead of spamming. + repeat(20) { + assertIs(w.stopFor(1, nowMs = 20_000L + it * 5_000L)) + } + } + + @Test + fun `moving to another track restores the attempt budget`() { + val w = RemoteStallWatchdog() + assertIs(w.stopFor(3, nowMs = 0L)) + assertIs(w.stopFor(1, nowMs = 5_000L)) + assertIs(w.stopFor(1, nowMs = 10_000L)) + assertIs(w.stopFor(1, nowMs = 15_000L)) + + // A different track is a different problem. + w.poll(trackUri = other, state = TransportState.PLAYING, nowMs = 16_000L) + w.poll(trackUri = other, state = TransportState.STOPPED, nowMs = 20_000L) + w.poll(trackUri = other, state = TransportState.STOPPED, nowMs = 20_000L) + // Held in a val: a var mutated inside a lambda can't be smart-cast. + val onNewTrack = w.poll(trackUri = other, state = TransportState.STOPPED, nowMs = 20_000L) + assertIs(onNewTrack) + assertEquals(1, onNewTrack.attempt) + } + + /** + * A track that stalls, recovers and stalls again later gets the full + * budget the second time — otherwise one bad patch early in a long track + * would leave it defenceless for the rest. + */ + @Test + fun `recovering to playing restores the attempt budget`() { + val w = RemoteStallWatchdog() + assertIs(w.stopFor(3, nowMs = 0L)) + w.poll(state = TransportState.PLAYING, positionMs = 60_000L, nowMs = 6_000L) + + val again = w.stopFor(3, nowMs = 30_000L) + assertIs(again) + assertEquals(1, again.attempt) + assertEquals(60_000L, again.resumeAtMs) + } + + @Test + fun `reset forgets everything`() { + val w = RemoteStallWatchdog() + assertIs(w.stopFor(3, nowMs = 0L)) + w.reset() + val afterReset = w.stopFor(3, nowMs = 1_000L) + assertIs(afterReset) + assertEquals(1, afterReset.attempt) + assertTrue(afterReset.resumeAtMs == 0L) + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/playlists/data/PlaylistTrackAvailabilityTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/playlists/data/PlaylistTrackAvailabilityTest.kt new file mode 100644 index 00000000..318e7e05 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/playlists/data/PlaylistTrackAvailabilityTest.kt @@ -0,0 +1,71 @@ +package com.fabledsword.minstrel.playlists.data + +import com.fabledsword.minstrel.models.PlaylistTrackRef +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * A playlist row can outlive its audio two ways — the track was removed + * from the library, or the track is still there but its file is missing + * (#2527). Both must be unplayable, and neither may be silently dropped + * from the list the user curated. + */ +class PlaylistTrackAvailabilityTest { + + private fun row( + position: Int = 0, + trackId: String? = "t-$position", + streamUrl: String? = "/api/tracks/t-$position/stream", + unavailable: Boolean = false, + ) = PlaylistTrackRef( + position = position, + trackId = trackId, + title = "Track $position", + albumId = "a-1", + albumTitle = "Album", + artistId = "ar-1", + artistName = "Artist", + durationSec = 137, + streamUrl = streamUrl, + unavailable = unavailable, + ) + + @Test + fun `a playable row is available`() { + assertTrue(row().isAvailable) + } + + @Test + fun `a removed track is unavailable`() { + assertFalse(row(trackId = null, streamUrl = null).isAvailable) + } + + @Test + fun `a missing file is unavailable even though the track id survives`() { + assertFalse(row(unavailable = true, streamUrl = null).isAvailable) + } + + /** + * The flag has to stand on its own. The server also withholds the + * stream URL, but a cached detail fetched before the file went missing + * can still carry a stale URL, and that must not resurrect the row. + */ + @Test + fun `the flag disqualifies a row that still has a stream url`() { + assertFalse(row(unavailable = true).isAvailable) + } + + @Test + fun `queue building drops both kinds of dead row and keeps the rest`() { + val queue = listOf( + row(position = 0), + row(position = 1, trackId = null, streamUrl = null), + row(position = 2, unavailable = true, streamUrl = null), + row(position = 3), + ).toPlayableTrackRefs() + + assertEquals(listOf("t-0", "t-3"), queue.map { it.id }) + } +} diff --git a/ci-requirements.md b/ci-requirements.md index cbf3592d..52bf57ca 100644 --- a/ci-requirements.md +++ b/ci-requirements.md @@ -15,11 +15,16 @@ git.fabledsword.com/bvandeusen/ci-android:36 - `ci-go:1.26` — Go server tests (`.gitea/workflows/test-go.yml`), web SPA tests (`.gitea/workflows/test-web.yml`), and the release container build (`release.yml`'s `image-release` job). - `ci-android:36` — native Kotlin/Compose client: ktlint + detekt + unit tests + debug APK (`.gitea/workflows/android.yml`), and the signed release APK (`release.yml`'s `android-release` job). -**`ci-flutter` is no longer consumed.** The M8 rewrite replaced the Flutter -client with the native Android app and `flutter.yml` was removed; `ci-android` -took its place. `flutter_client/` is still in the tree but nothing builds it. -CI-Runner still publishes `ci-flutter` and will retire it once that directory -goes — so if the Flutter client is ever revived, say so there first. +**`ci-flutter` is no longer consumed, and `ci-flutter` can now be retired.** +The M8 rewrite replaced the Flutter client with the native Android app and +`flutter.yml` was removed; `ci-android` took its place. `flutter_client/` +itself was deleted on 2026-08-16, which was the condition CI-Runner was +waiting on before dropping the image — nothing in this repo needs a Flutter +toolchain any more. + +Note this does **not** mean the `flutter-ci` runner *label* goes: the Android +jobs still schedule on it while pulling `ci-android:36`, per the label/image +split below. The label is a scheduling handle, not a toolchain assertion. ## Image deps used diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index b7f1248e..669f6f88 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -26,6 +26,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" "git.fabledsword.com/bvandeusen/minstrel/internal/logging" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" + "git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition" "git.fabledsword.com/bvandeusen/minstrel/internal/recsettings" "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble" "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" @@ -269,6 +270,28 @@ func run() error { lidarrReconciler := lidarrrequests.NewReconciler(pool, lidarrCfg, lidarrClientFn, logger.With("component", "lidarr"), bus) go lidarrReconciler.Run(ctx) + // Missing-file re-acquisition (milestone #290). Turns albums whose files + // have been gone longer than the grace window into Lidarr requests, on an + // exponential per-album backoff. Hourly tick — the shortest meaningful + // backoff is measured in hours, so waking more often would only re-read + // settings and find nothing due. + // + // A settings-load failure is logged, not fatal: NewSettingsService always + // returns a usable service holding the defaults, and running on defaults + // is far better than dropping the feature because the database hiccuped + // during boot. + reacqSettings, reacqErr := reacquisition.NewSettingsService( + ctx, pool, logger.With("component", "reacquisition")) + if reacqErr != nil { + logger.Warn("reacquisition: using default settings", "err", reacqErr) + } + go reacquisition.NewSweeper( + pool, + reacqSettings, + lidarrrequests.NewService(pool, lidarrCfg, lidarrClientFn, nil), + logger.With("component", "reacquisition"), + ).Run(ctx) + // library_changes compactor (#357 follow-up). Daily tick; deletes // rows older than the configured retention so the change-log table // doesn't grow unbounded. Clients that drop offline longer than diff --git a/flutter_client/.gitignore b/flutter_client/.gitignore deleted file mode 100644 index 7545eaf9..00000000 --- a/flutter_client/.gitignore +++ /dev/null @@ -1,49 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.build/ -.buildlog/ -.history -.svn/ -.swiftpm/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins-dependencies -.pub-cache/ -.pub/ -/build/ -/coverage/ - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release - -# drift codegen output (regenerated by build_runner; CI runs build_runner) -*.g.dart - diff --git a/flutter_client/.metadata b/flutter_client/.metadata deleted file mode 100644 index 9e9a413a..00000000 --- a/flutter_client/.metadata +++ /dev/null @@ -1,33 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "02085feb3f5d8a8156e5e28512b9d99351d510c0" - channel: "stable" - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0 - base_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0 - - platform: android - create_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0 - base_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0 - - platform: ios - create_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0 - base_revision: 02085feb3f5d8a8156e5e28512b9d99351d510c0 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/flutter_client/README.md b/flutter_client/README.md deleted file mode 100644 index 19c5e0d6..00000000 --- a/flutter_client/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# Minstrel mobile client - -Flutter (iOS + Android) sibling of the SvelteKit web SPA. v1 first -slice ships: scaffold, auth, library browse (home / artist / album), -likes, player with background audio + lock-screen controls. - -## Setup - -```bash -cd flutter_client -flutter pub get -./tool/sync_shared.sh # copy tokens / error-copy / placeholders from ../web -dart run tool/gen_tokens.dart # regen lib/theme/tokens.dart from shared/fabledsword.tokens.json -flutter run -``` - -`sync_shared.sh` is idempotent. CI runs it on every build; rerun locally -whenever `web/src/lib/styles/tokens.json`, -`web/src/lib/styles/error-copy.json`, or -`web/static/placeholders/album-fallback.svg` changes. - -## Project layout - -See `docs/superpowers/specs/2026-05-02-flutter-mobile-foundation-design.md` -section 3. Briefly: - -- `lib/api/` — dio client, ApiError, error-copy loader, per-surface endpoints. -- `lib/auth/` — server URL screen, login screen, AuthController + secure storage. -- `lib/library/` — home, artist detail, album detail, providers, widgets. -- `lib/likes/` — LikeButton + optimistic toggle controller. -- `lib/player/` — audio handler, player provider, mini PlayerBar, NowPlaying. -- `lib/theme/` — FabledSword token Dart class (generated), ThemeExtension, ThemeData. -- `lib/shared/` — routing (go_router shell), version gate, connection error banner. - -`shared/fabledsword.tokens.json` and `assets/error-copy.json` are -synced from web/. Don't edit them directly — edit `web/src/lib/styles/` -and re-run `./tool/sync_shared.sh`. - -## Architecture - -- **State:** Riverpod 2 (`AsyncValue`, `AsyncNotifier`, family providers). -- **HTTP:** dio 5 with a Bearer-auth interceptor; 401 clears the session - and the router redirects to login. -- **Audio:** just_audio wrapped by audio_service for background - playback + lock-screen / notification controls. -- **Auth:** Bearer tokens in flutter_secure_storage. Server already - supports both cookie (web SPA) and Bearer (`internal/auth/session.go`). -- **Routing:** go_router with a ShellRoute so the PlayerBar persists - across navigation. Cold launch flow: no server-url → `/server-url`, - url set / no token → `/login`, token present → `/home`. - -## CI - -`.gitea/workflows/flutter.yml` runs on push to dev/main, tag pushes, -PR to main, and manual dispatch — path-filtered to `flutter_client/**` -plus the shared web inputs. Steps: sync, analyze, test, build APK. -Debug APKs upload as artifacts; release APKs attach to the Gitea -release on tag. - -## Versioning - -`pubspec.yaml` `version` mirrors the server tag (e.g. server `v0.1.0` -→ Flutter `0.1.0+1`). The server's `/healthz` returns -`min_client_version`; old clients show an Update Required modal and -refuse to operate. Bump `internal/server/version.go` `MinClientVersion` -when a server-side change requires a paired client update. - -## Distribution - -- **Android:** APK on the Gitea release page. No Play Store for v1. -- **iOS:** TestFlight on tag (manual upload v1, automate later). - -## Out-of-scope for slice 1 - -Search, discover, requests, settings beyond server URL, admin, -listening history, playlists, offline cache (#357). Those land in -subsequent slices with their own brainstorm/spec/plan cycles. Per the -operator's M7 ordering decision (2026-05-02), slice 2+ pauses to ship -the missing web features first (#352 playlists, #362 theme toggle, -#363 queue UI, #364 listening history, etc.) before mirroring on -Flutter. diff --git a/flutter_client/analysis_options.yaml b/flutter_client/analysis_options.yaml deleted file mode 100644 index b91a43a1..00000000 --- a/flutter_client/analysis_options.yaml +++ /dev/null @@ -1,12 +0,0 @@ -include: package:flutter_lints/flutter.yaml - -analyzer: - exclude: - - build/** - - lib/theme/tokens.dart # generated; allow magic numbers - -linter: - rules: - avoid_print: true - prefer_const_constructors: true - sort_pub_dependencies: false diff --git a/flutter_client/android/.gitignore b/flutter_client/android/.gitignore deleted file mode 100644 index be3943c9..00000000 --- a/flutter_client/android/.gitignore +++ /dev/null @@ -1,14 +0,0 @@ -gradle-wrapper.jar -/.gradle -/captures/ -/gradlew -/gradlew.bat -/local.properties -GeneratedPluginRegistrant.java -.cxx/ - -# Remember to never publicly share your keystore. -# See https://flutter.dev/to/reference-keystore -key.properties -**/*.keystore -**/*.jks diff --git a/flutter_client/android/app/build.gradle.kts b/flutter_client/android/app/build.gradle.kts deleted file mode 100644 index 54f8d478..00000000 --- a/flutter_client/android/app/build.gradle.kts +++ /dev/null @@ -1,71 +0,0 @@ -plugins { - id("com.android.application") - id("kotlin-android") - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id("dev.flutter.flutter-gradle-plugin") -} - -android { - namespace = "com.fabledsword.minstrel" - compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.fabledsword.minstrel" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion - targetSdk = flutter.targetSdkVersion - versionCode = flutter.versionCode - versionName = flutter.versionName - } - - // Real release signing config — populated only when CI exports - // ANDROID_KEYSTORE_PATH (decoded from a base64 secret) plus the - // matching password/alias env vars. Without these (local - // `flutter build apk --release` runs), the release build falls - // through to the debug keystore so local builds still work. - // - // Why this matters: Flutter's default `flutter run --release` and - // CI's earlier setup both signed with the per-machine debug - // keystore (~/.android/debug.keystore). Every CI runner generated - // its own debug key, so consecutive release APKs had different - // signatures and Android refused to upgrade an existing install. - val keystorePath: String? = System.getenv("ANDROID_KEYSTORE_PATH") - if (keystorePath != null && keystorePath.isNotEmpty()) { - signingConfigs { - create("release") { - storeFile = file(keystorePath) - storePassword = System.getenv("ANDROID_STORE_PASSWORD") - keyAlias = System.getenv("ANDROID_KEY_ALIAS") - keyPassword = System.getenv("ANDROID_KEY_PASSWORD") - } - } - } - - buildTypes { - release { - signingConfig = if (keystorePath != null && keystorePath.isNotEmpty()) { - signingConfigs.getByName("release") - } else { - // Local-only fallback so `flutter run --release` works - // without the keystore. CI must export ANDROID_KEYSTORE_PATH. - signingConfigs.getByName("debug") - } - } - } -} - -flutter { - source = "../.." -} diff --git a/flutter_client/android/app/src/debug/AndroidManifest.xml b/flutter_client/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 399f6981..00000000 --- a/flutter_client/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/flutter_client/android/app/src/main/AndroidManifest.xml b/flutter_client/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index cfa07960..00000000 --- a/flutter_client/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,89 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/flutter_client/android/app/src/main/kotlin/com/fabledsword/minstrel/MainActivity.kt b/flutter_client/android/app/src/main/kotlin/com/fabledsword/minstrel/MainActivity.kt deleted file mode 100644 index 0be662c1..00000000 --- a/flutter_client/android/app/src/main/kotlin/com/fabledsword/minstrel/MainActivity.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.fabledsword.minstrel - -import android.content.Intent -import androidx.core.content.FileProvider -import com.ryanheise.audioservice.AudioServiceActivity -import io.flutter.embedding.engine.FlutterEngine -import io.flutter.plugin.common.MethodChannel -import java.io.File - -// Must extend AudioServiceActivity (not FlutterActivity) so the -// audio_service plugin can wire its FlutterEngine through. Without this -// the platform call from AudioService.init() throws PlatformException -// "The Activity class declared in your AndroidManifest.xml is wrong", -// which on first start cascades into a flutter_cache_manager sqlite -// EXCLUSIVE-lock crash because the engine partially re-initialises. -class MainActivity : AudioServiceActivity() { - - companion object { - private const val INSTALLER_CHANNEL = "com.fabledsword.minstrel/installer" - } - - override fun configureFlutterEngine(flutterEngine: FlutterEngine) { - super.configureFlutterEngine(flutterEngine) - - // In-app update channel (#397). Dart calls install(path) with - // the cache-dir APK path; we hand it to Android's - // PackageInstaller via FileProvider + ACTION_VIEW. - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, INSTALLER_CHANNEL) - .setMethodCallHandler { call, result -> - when (call.method) { - "install" -> { - val path = call.argument("path") - if (path == null) { - result.error("missing_path", "path argument required", null) - return@setMethodCallHandler - } - try { - installApk(path) - result.success(null) - } catch (e: Exception) { - result.error("install_failed", e.message, null) - } - } - else -> result.notImplemented() - } - } - } - - private fun installApk(path: String) { - val file = File(path) - if (!file.exists()) { - throw IllegalStateException("apk not found at $path") - } - val uri = FileProvider.getUriForFile( - this, - "${packageName}.fileprovider", - file - ) - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, "application/vnd.android.package-archive") - // NEW_TASK: PackageInstaller runs in its own task. - // GRANT_READ_URI_PERMISSION: hands the content:// URI to - // the installer process, which lacks our app's read perms - // by default. - flags = Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_GRANT_READ_URI_PERMISSION - } - startActivity(intent) - } -} diff --git a/flutter_client/android/app/src/main/res/drawable-v21/launch_background.xml b/flutter_client/android/app/src/main/res/drawable-v21/launch_background.xml deleted file mode 100644 index f74085f3..00000000 --- a/flutter_client/android/app/src/main/res/drawable-v21/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/flutter_client/android/app/src/main/res/drawable/ic_stat_favorite.xml b/flutter_client/android/app/src/main/res/drawable/ic_stat_favorite.xml deleted file mode 100644 index 9c9aca56..00000000 --- a/flutter_client/android/app/src/main/res/drawable/ic_stat_favorite.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - diff --git a/flutter_client/android/app/src/main/res/drawable/ic_stat_favorite_border.xml b/flutter_client/android/app/src/main/res/drawable/ic_stat_favorite_border.xml deleted file mode 100644 index 96ecf90f..00000000 --- a/flutter_client/android/app/src/main/res/drawable/ic_stat_favorite_border.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/flutter_client/android/app/src/main/res/drawable/launch_background.xml b/flutter_client/android/app/src/main/res/drawable/launch_background.xml deleted file mode 100644 index 304732f8..00000000 --- a/flutter_client/android/app/src/main/res/drawable/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/flutter_client/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/flutter_client/android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index db77bb4b..00000000 Binary files a/flutter_client/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/flutter_client/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/flutter_client/android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 17987b79..00000000 Binary files a/flutter_client/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/flutter_client/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/flutter_client/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 09d43914..00000000 Binary files a/flutter_client/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/flutter_client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/flutter_client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index d5f1c8d3..00000000 Binary files a/flutter_client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/flutter_client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/flutter_client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 4d6372ee..00000000 Binary files a/flutter_client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/flutter_client/android/app/src/main/res/values-night/styles.xml b/flutter_client/android/app/src/main/res/values-night/styles.xml deleted file mode 100644 index 06952be7..00000000 --- a/flutter_client/android/app/src/main/res/values-night/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/flutter_client/android/app/src/main/res/values/styles.xml b/flutter_client/android/app/src/main/res/values/styles.xml deleted file mode 100644 index cb1ef880..00000000 --- a/flutter_client/android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/flutter_client/android/app/src/main/res/xml/file_paths.xml b/flutter_client/android/app/src/main/res/xml/file_paths.xml deleted file mode 100644 index 4e45bfed..00000000 --- a/flutter_client/android/app/src/main/res/xml/file_paths.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - diff --git a/flutter_client/android/app/src/main/res/xml/network_security_config.xml b/flutter_client/android/app/src/main/res/xml/network_security_config.xml deleted file mode 100644 index b600a598..00000000 --- a/flutter_client/android/app/src/main/res/xml/network_security_config.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - 127.0.0.1 - - diff --git a/flutter_client/android/app/src/profile/AndroidManifest.xml b/flutter_client/android/app/src/profile/AndroidManifest.xml deleted file mode 100644 index 399f6981..00000000 --- a/flutter_client/android/app/src/profile/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/flutter_client/android/build.gradle.kts b/flutter_client/android/build.gradle.kts deleted file mode 100644 index dbee657b..00000000 --- a/flutter_client/android/build.gradle.kts +++ /dev/null @@ -1,24 +0,0 @@ -allprojects { - repositories { - google() - mavenCentral() - } -} - -val newBuildDir: Directory = - rootProject.layout.buildDirectory - .dir("../../build") - .get() -rootProject.layout.buildDirectory.value(newBuildDir) - -subprojects { - val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) - project.layout.buildDirectory.value(newSubprojectBuildDir) -} -subprojects { - project.evaluationDependsOn(":app") -} - -tasks.register("clean") { - delete(rootProject.layout.buildDirectory) -} diff --git a/flutter_client/android/gradle.properties b/flutter_client/android/gradle.properties deleted file mode 100644 index fbee1d8c..00000000 --- a/flutter_client/android/gradle.properties +++ /dev/null @@ -1,2 +0,0 @@ -org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -android.useAndroidX=true diff --git a/flutter_client/android/gradle/wrapper/gradle-wrapper.properties b/flutter_client/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index e4ef43fb..00000000 --- a/flutter_client/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/flutter_client/android/settings.gradle.kts b/flutter_client/android/settings.gradle.kts deleted file mode 100644 index ca7fe065..00000000 --- a/flutter_client/android/settings.gradle.kts +++ /dev/null @@ -1,26 +0,0 @@ -pluginManagement { - val flutterSdkPath = - run { - val properties = java.util.Properties() - file("local.properties").inputStream().use { properties.load(it) } - val flutterSdkPath = properties.getProperty("flutter.sdk") - require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } - flutterSdkPath - } - - includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") - - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -plugins { - id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.11.1" apply false - id("org.jetbrains.kotlin.android") version "2.2.20" apply false -} - -include(":app") diff --git a/flutter_client/assets/error-copy.json b/flutter_client/assets/error-copy.json deleted file mode 100644 index 9a29973f..00000000 --- a/flutter_client/assets/error-copy.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "unknown": "Something went wrong.", - "unauthenticated": "Your session has ended. Please sign in again.", - "auth_required": "You need to sign in to do that.", - "forbidden": "You don't have permission to do that.", - "not_authorized": "You don't have permission to do that.", - "invalid_credentials": "Wrong username or password.", - "wrong_password": "Current password is incorrect.", - "password_too_short": "Password must be at least 8 characters.", - "username_invalid": "That username isn't valid.", - "username_taken": "That username is already taken.", - "email_invalid": "Enter a valid email address.", - "email_taken": "That email is already in use.", - "invalid_token": "That link has expired or already been used. Request a new one.", - "invite_invalid": "That invite has expired or already been used.", - "invite_required": "An invite is required to register on this server.", - "last_admin": "Can't demote or delete the last admin.", - "no_email_on_file": "No email is associated with that account.", - "not_configured": "This integration isn't set up yet.", - "validation": "Some fields aren't valid. Check and try again.", - "missing_fields": "Required fields are missing.", - "missing_query": "Search needs at least one keyword.", - "invalid_id": "Invalid identifier.", - "invalid_body": "Couldn't read the request.", - "bad_request": "Invalid request.", - "bad_body": "Couldn't read the request.", - "bad_kind": "Invalid request type.", - "bad_paging": "Invalid page size or offset.", - "bad_reason": "Invalid quarantine reason.", - "mbid_required": "An MBID is required for this lookup.", - "system_playlist_readonly": "System playlists can't be edited directly.", - "connection_refused": "Couldn't reach the server. Check the URL and try again.", - "lidarr_unreachable": "Lidarr is unreachable right now. Try again, or check Admin → Integrations.", - "lidarr_disabled": "Lidarr integration is not enabled.", - "lidarr_auth_failed": "Lidarr authentication failed.", - "lidarr_defaults_incomplete": "Lidarr is missing a default quality profile or root folder. Set them in Admin → Integrations.", - "lidarr_server_error": "Lidarr returned an error. Check Lidarr's logs for the cause.", - "lidarr_rejected": "Lidarr rejected the request. Check the server logs for the field-level reason.", - "lidarr_album_lookup_failed": "Lidarr doesn't recognize this album. Try Resolve or Delete file instead.", - "album_mbid_missing": "This track has no Lidarr album to remove.", - "request_not_pending": "This request is no longer pending.", - "request_not_found": "That request no longer exists.", - "track_not_found": "That track no longer exists.", - "album_not_found": "That album no longer exists.", - "artist_not_found": "That artist no longer exists.", - "playlist_not_found": "That playlist no longer exists.", - "user_not_found": "That user no longer exists." -} diff --git a/flutter_client/assets/svg/album-fallback.svg b/flutter_client/assets/svg/album-fallback.svg deleted file mode 100644 index 9720c751..00000000 --- a/flutter_client/assets/svg/album-fallback.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - diff --git a/flutter_client/ios/.gitignore b/flutter_client/ios/.gitignore deleted file mode 100644 index 7a7f9873..00000000 --- a/flutter_client/ios/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -**/dgph -*.mode1v3 -*.mode2v3 -*.moved-aside -*.pbxuser -*.perspectivev3 -**/*sync/ -.sconsign.dblite -.tags* -**/.vagrant/ -**/DerivedData/ -Icon? -**/Pods/ -**/.symlinks/ -profile -xcuserdata -**/.generated/ -Flutter/App.framework -Flutter/Flutter.framework -Flutter/Flutter.podspec -Flutter/Generated.xcconfig -Flutter/ephemeral/ -Flutter/app.flx -Flutter/app.zip -Flutter/flutter_assets/ -Flutter/flutter_export_environment.sh -ServiceDefinitions.json -Runner/GeneratedPluginRegistrant.* - -# Exceptions to above rules. -!default.mode1v3 -!default.mode2v3 -!default.pbxuser -!default.perspectivev3 diff --git a/flutter_client/ios/Flutter/AppFrameworkInfo.plist b/flutter_client/ios/Flutter/AppFrameworkInfo.plist deleted file mode 100644 index 391a902b..00000000 --- a/flutter_client/ios/Flutter/AppFrameworkInfo.plist +++ /dev/null @@ -1,24 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - App - CFBundleIdentifier - io.flutter.flutter.app - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - - diff --git a/flutter_client/ios/Flutter/Debug.xcconfig b/flutter_client/ios/Flutter/Debug.xcconfig deleted file mode 100644 index 592ceee8..00000000 --- a/flutter_client/ios/Flutter/Debug.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "Generated.xcconfig" diff --git a/flutter_client/ios/Flutter/Release.xcconfig b/flutter_client/ios/Flutter/Release.xcconfig deleted file mode 100644 index 592ceee8..00000000 --- a/flutter_client/ios/Flutter/Release.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "Generated.xcconfig" diff --git a/flutter_client/ios/Runner.xcodeproj/project.pbxproj b/flutter_client/ios/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index e8759b3f..00000000 --- a/flutter_client/ios/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,620 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 54; - objects = { - -/* Begin PBXBuildFile section */ - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 97C146E61CF9000F007C117D /* Project object */; - proxyType = 1; - remoteGlobalIDString = 97C146ED1CF9000F007C117D; - remoteInfo = Runner; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 9705A1C41CF9048500538489 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; - 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 97C146EB1CF9000F007C117D /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 331C8082294A63A400263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C807B294A618700263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; - 9740EEB11CF90186004384FC /* Flutter */ = { - isa = PBXGroup; - children = ( - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 9740EEB31CF90195004384FC /* Generated.xcconfig */, - ); - name = Flutter; - sourceTree = ""; - }; - 97C146E51CF9000F007C117D = { - isa = PBXGroup; - children = ( - 9740EEB11CF90186004384FC /* Flutter */, - 97C146F01CF9000F007C117D /* Runner */, - 97C146EF1CF9000F007C117D /* Products */, - 331C8082294A63A400263BE5 /* RunnerTests */, - ); - sourceTree = ""; - }; - 97C146EF1CF9000F007C117D /* Products */ = { - isa = PBXGroup; - children = ( - 97C146EE1CF9000F007C117D /* Runner.app */, - 331C8081294A63A400263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 97C146F01CF9000F007C117D /* Runner */ = { - isa = PBXGroup; - children = ( - 97C146FA1CF9000F007C117D /* Main.storyboard */, - 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, - 97C147021CF9000F007C117D /* Info.plist */, - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, - 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, - ); - path = Runner; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 331C8080294A63A400263BE5 /* RunnerTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; - buildPhases = ( - 331C807D294A63A400263BE5 /* Sources */, - 331C807F294A63A400263BE5 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 331C8086294A63A400263BE5 /* PBXTargetDependency */, - ); - name = RunnerTests; - productName = RunnerTests; - productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 97C146ED1CF9000F007C117D /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 9740EEB61CF901F6004384FC /* Run Script */, - 97C146EA1CF9000F007C117D /* Sources */, - 97C146EB1CF9000F007C117D /* Frameworks */, - 97C146EC1CF9000F007C117D /* Resources */, - 9705A1C41CF9048500538489 /* Embed Frameworks */, - 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Runner; - productName = Runner; - productReference = 97C146EE1CF9000F007C117D /* Runner.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 97C146E61CF9000F007C117D /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1510; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 331C8080294A63A400263BE5 = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 97C146ED1CF9000F007C117D; - }; - 97C146ED1CF9000F007C117D = { - CreatedOnToolsVersion = 7.3.1; - LastSwiftMigration = 1100; - }; - }; - }; - buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 97C146E51CF9000F007C117D; - productRefGroup = 97C146EF1CF9000F007C117D /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 97C146ED1CF9000F007C117D /* Runner */, - 331C8080294A63A400263BE5 /* RunnerTests */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 331C807F294A63A400263BE5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EC1CF9000F007C117D /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - 9740EEB61CF901F6004384FC /* Run Script */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 331C807D294A63A400263BE5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EA1CF9000F007C117D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, - 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 97C146ED1CF9000F007C117D /* Runner */; - targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 97C146FA1CF9000F007C117D /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C146FB1CF9000F007C117D /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 249021D3217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Profile; - }; - 249021D4217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.fabledsword.minstrel; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Profile; - }; - 331C8088294A63A400263BE5 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.fabledsword.minstrel.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Debug; - }; - 331C8089294A63A400263BE5 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.fabledsword.minstrel.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Release; - }; - 331C808A294A63A400263BE5 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.fabledsword.minstrel.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Profile; - }; - 97C147031CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 97C147041CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 97C147061CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.fabledsword.minstrel; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Debug; - }; - 97C147071CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.fabledsword.minstrel; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 331C8088294A63A400263BE5 /* Debug */, - 331C8089294A63A400263BE5 /* Release */, - 331C808A294A63A400263BE5 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147031CF9000F007C117D /* Debug */, - 97C147041CF9000F007C117D /* Release */, - 249021D3217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147061CF9000F007C117D /* Debug */, - 97C147071CF9000F007C117D /* Release */, - 249021D4217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 97C146E61CF9000F007C117D /* Project object */; -} diff --git a/flutter_client/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/flutter_client/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a6..00000000 --- a/flutter_client/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/flutter_client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/flutter_client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/flutter_client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/flutter_client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/flutter_client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c5..00000000 --- a/flutter_client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/flutter_client/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/flutter_client/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index e3773d42..00000000 --- a/flutter_client/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/flutter_client/ios/Runner.xcworkspace/contents.xcworkspacedata b/flutter_client/ios/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 1d526a16..00000000 --- a/flutter_client/ios/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/flutter_client/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/flutter_client/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d98100..00000000 --- a/flutter_client/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/flutter_client/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/flutter_client/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c5..00000000 --- a/flutter_client/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/flutter_client/ios/Runner/AppDelegate.swift b/flutter_client/ios/Runner/AppDelegate.swift deleted file mode 100644 index c30b367e..00000000 --- a/flutter_client/ios/Runner/AppDelegate.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Flutter -import UIKit - -@main -@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } - - func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { - GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) - } -} diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index d36b1fab..00000000 --- a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index dc9ada47..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index 7353c41e..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index 797d452e..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index 6ed2d933..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index 4cd7b009..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index fe730945..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index 321773cd..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index 797d452e..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index 502f463a..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index 0ec30343..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index 0ec30343..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index e9f5fea2..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index 84ac32ae..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png deleted file mode 100644 index 8953cba0..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index 0467bf12..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json deleted file mode 100644 index 0bedcf2f..00000000 --- a/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "LaunchImage.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png deleted file mode 100644 index 9da19eac..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png deleted file mode 100644 index 9da19eac..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png deleted file mode 100644 index 9da19eac..00000000 Binary files a/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and /dev/null differ diff --git a/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md deleted file mode 100644 index 89c2725b..00000000 --- a/flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Launch Screen Assets - -You can customize the launch screen with your own desired assets by replacing the image files in this directory. - -You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/flutter_client/ios/Runner/Base.lproj/LaunchScreen.storyboard b/flutter_client/ios/Runner/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index f2e259c7..00000000 --- a/flutter_client/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/flutter_client/ios/Runner/Base.lproj/Main.storyboard b/flutter_client/ios/Runner/Base.lproj/Main.storyboard deleted file mode 100644 index f3c28516..00000000 --- a/flutter_client/ios/Runner/Base.lproj/Main.storyboard +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/flutter_client/ios/Runner/Info.plist b/flutter_client/ios/Runner/Info.plist deleted file mode 100644 index ca00e585..00000000 --- a/flutter_client/ios/Runner/Info.plist +++ /dev/null @@ -1,74 +0,0 @@ - - - - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Minstrel - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - minstrel - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneClassName - UIWindowScene - UISceneConfigurationName - flutter - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - - - - UIApplicationSupportsIndirectInputEvents - - UIBackgroundModes - - audio - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/flutter_client/ios/Runner/Runner-Bridging-Header.h b/flutter_client/ios/Runner/Runner-Bridging-Header.h deleted file mode 100644 index 308a2a56..00000000 --- a/flutter_client/ios/Runner/Runner-Bridging-Header.h +++ /dev/null @@ -1 +0,0 @@ -#import "GeneratedPluginRegistrant.h" diff --git a/flutter_client/ios/Runner/SceneDelegate.swift b/flutter_client/ios/Runner/SceneDelegate.swift deleted file mode 100644 index b9ce8ea2..00000000 --- a/flutter_client/ios/Runner/SceneDelegate.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Flutter -import UIKit - -class SceneDelegate: FlutterSceneDelegate { - -} diff --git a/flutter_client/ios/RunnerTests/RunnerTests.swift b/flutter_client/ios/RunnerTests/RunnerTests.swift deleted file mode 100644 index 86a7c3b1..00000000 --- a/flutter_client/ios/RunnerTests/RunnerTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Flutter -import UIKit -import XCTest - -class RunnerTests: XCTestCase { - - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. - } - -} diff --git a/flutter_client/lib/admin/admin_landing_screen.dart b/flutter_client/lib/admin/admin_landing_screen.dart deleted file mode 100644 index 79d0a03c..00000000 --- a/flutter_client/lib/admin/admin_landing_screen.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../shared/widgets/main_app_bar_actions.dart'; -import '../theme/theme_extension.dart'; -import 'admin_providers.dart'; -import 'widgets/admin_section_card.dart'; - -class AdminLandingScreen extends ConsumerWidget { - const AdminLandingScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final counts = ref.watch(adminCountsProvider); - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - title: Text('Admin', style: TextStyle(color: fs.parchment)), - actions: const [MainAppBarActions(currentRoute: '/admin')], - ), - body: SafeArea( - child: counts.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Text('$e', style: TextStyle(color: fs.error)), - ), - data: (c) => RefreshIndicator( - onRefresh: () async => ref.refresh(adminCountsProvider.future), - child: ListView( - children: [ - AdminSectionCard( - key: const Key('admin_card_requests'), - icon: LucideIcons.inbox, - title: 'Requests', - subtitle: 'Approve or reject Lidarr requests', - count: c.requests, - onTap: () => context.push('/admin/requests'), - ), - AdminSectionCard( - key: const Key('admin_card_quarantine'), - icon: LucideIcons.triangle_alert, - title: 'Quarantine', - subtitle: 'Resolve scan failures', - count: c.quarantine, - onTap: () => context.push('/admin/quarantine'), - ), - AdminSectionCard( - key: const Key('admin_card_users'), - icon: LucideIcons.users, - title: 'Users', - subtitle: 'Manage accounts and invites', - count: c.users, - onTap: () => context.push('/admin/users'), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/flutter_client/lib/admin/admin_providers.dart b/flutter_client/lib/admin/admin_providers.dart deleted file mode 100644 index dcc220e6..00000000 --- a/flutter_client/lib/admin/admin_providers.dart +++ /dev/null @@ -1,237 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/admin_invites.dart'; -import '../api/endpoints/admin_quarantine.dart'; -import '../api/endpoints/admin_requests.dart'; -import '../api/endpoints/admin_users.dart'; -import '../library/library_providers.dart' show dioProvider; -import '../models/admin_quarantine_item.dart'; -import '../models/admin_request.dart'; -import '../models/admin_user.dart'; -import '../models/invite.dart'; - -final adminRequestsApiProvider = FutureProvider((ref) async { - return AdminRequestsApi(await ref.watch(dioProvider.future)); -}); - -final adminQuarantineApiProvider = - FutureProvider((ref) async { - return AdminQuarantineApi(await ref.watch(dioProvider.future)); -}); - -final adminUsersApiProvider = FutureProvider((ref) async { - return AdminUsersApi(await ref.watch(dioProvider.future)); -}); - -final adminInvitesApiProvider = FutureProvider((ref) async { - return AdminInvitesApi(await ref.watch(dioProvider.future)); -}); - -class AdminCounts { - const AdminCounts({ - required this.requests, - required this.quarantine, - required this.users, - }); - final int requests; - final int quarantine; - final int users; -} - -/// Fans out to the three list endpoints in parallel and rolls them up -/// into `{requests, quarantine, users}` for the landing screen badges. -final adminCountsProvider = FutureProvider((ref) async { - final requestsApi = await ref.watch(adminRequestsApiProvider.future); - final quarantineApi = await ref.watch(adminQuarantineApiProvider.future); - final usersApi = await ref.watch(adminUsersApiProvider.future); - final results = await Future.wait([ - requestsApi.list(), - quarantineApi.list(), - usersApi.list(), - ]); - return AdminCounts( - requests: (results[0] as List).length, - quarantine: (results[1] as List).length, - users: (results[2] as List).length, - ); -}); - -class AdminRequestsController extends AsyncNotifier> { - @override - Future> build() async { - final api = await ref.watch(adminRequestsApiProvider.future); - return api.list(); - } - - Future approve(String id) async { - await _decide(id, (api) => api.approve(id)); - } - - Future reject(String id) async { - await _decide(id, (api) => api.reject(id)); - } - - Future _decide( - String id, - Future Function(AdminRequestsApi api) action, - ) async { - final api = await ref.read(adminRequestsApiProvider.future); - final current = state.value ?? const []; - state = AsyncData(current.where((r) => r.id != id).toList()); - try { - await action(api); - // Refresh landing badge. - ref.invalidate(adminCountsProvider); - } catch (e, st) { - state = AsyncData(current); - Error.throwWithStackTrace(e, st); - } - } -} - -final adminRequestsProvider = - AsyncNotifierProvider>( - AdminRequestsController.new); - -/// Read side originally defined for the Requests screen's -/// requester-UUID → username join. Mutation methods (setAdmin, -/// setAutoApprove, resetPassword, delete) added alongside the Users -/// screen in Slice 3. resetPassword is admin-supplies; the server -/// has no auto-generation mode. -class AdminUsersController extends AsyncNotifier> { - @override - Future> build() async { - final api = await ref.watch(adminUsersApiProvider.future); - return api.list(); - } - - /// Optimistic flip of `is_admin` on the user row; rolls back on - /// server error (including the last-admin guard 4xx). - Future setAdmin(String id, bool isAdmin) => - _patch(id, (u) => _copy(u, isAdmin: isAdmin), - (api) => api.setAdmin(id, isAdmin)); - - Future setAutoApprove(String id, bool autoApprove) => - _patch(id, (u) => _copy(u, autoApproveRequests: autoApprove), - (api) => api.setAutoApprove(id, autoApprove)); - - Future delete(String id) async { - final api = await ref.read(adminUsersApiProvider.future); - final current = state.value ?? const []; - state = AsyncData(current.where((u) => u.id != id).toList()); - try { - await api.delete(id); - ref.invalidate(adminCountsProvider); - } catch (e, st) { - state = AsyncData(current); - Error.throwWithStackTrace(e, st); - } - } - - Future resetPassword(String id, String newPassword) async { - final api = await ref.read(adminUsersApiProvider.future); - await api.resetPassword(id, newPassword); - } - - Future _patch( - String id, - AdminUser Function(AdminUser) mutate, - Future Function(AdminUsersApi api) action, - ) async { - final api = await ref.read(adminUsersApiProvider.future); - final current = state.value ?? const []; - state = AsyncData([ - for (final u in current) u.id == id ? mutate(u) : u, - ]); - try { - await action(api); - } catch (e, st) { - state = AsyncData(current); - Error.throwWithStackTrace(e, st); - } - } - - AdminUser _copy(AdminUser u, {bool? isAdmin, bool? autoApproveRequests}) => - AdminUser( - id: u.id, - username: u.username, - displayName: u.displayName, - isAdmin: isAdmin ?? u.isAdmin, - autoApproveRequests: autoApproveRequests ?? u.autoApproveRequests, - createdAt: u.createdAt, - ); -} - -final adminUsersProvider = - AsyncNotifierProvider>( - AdminUsersController.new); - -class AdminQuarantineController - extends AsyncNotifier> { - @override - Future> build() async { - final api = await ref.watch(adminQuarantineApiProvider.future); - return api.list(); - } - - Future resolve(String trackId) => - _act(trackId, (api) => api.resolve(trackId)); - Future deleteFile(String trackId) => - _act(trackId, (api) => api.deleteFile(trackId)); - Future deleteViaLidarr(String trackId) => - _act(trackId, (api) => api.deleteViaLidarr(trackId)); - - Future _act( - String trackId, - Future Function(AdminQuarantineApi api) action, - ) async { - final api = await ref.read(adminQuarantineApiProvider.future); - final current = state.value ?? const []; - state = AsyncData(current.where((q) => q.trackId != trackId).toList()); - try { - await action(api); - ref.invalidate(adminCountsProvider); - } catch (e, st) { - state = AsyncData(current); - Error.throwWithStackTrace(e, st); - } - } -} - -final adminQuarantineProvider = AsyncNotifierProvider>(AdminQuarantineController.new); - -class AdminInvitesController extends AsyncNotifier> { - @override - Future> build() async { - final api = await ref.watch(adminInvitesApiProvider.future); - return api.list(); - } - - /// Returns the freshly-minted invite so the screen can show the - /// token in a copy-once dialog. The new invite is also prepended - /// to the local list so it shows up without a refresh. - Future create({String? note}) async { - final api = await ref.read(adminInvitesApiProvider.future); - final invite = await api.create(note: note); - final current = state.value ?? const []; - state = AsyncData([invite, ...current]); - return invite; - } - - Future revoke(String token) async { - final api = await ref.read(adminInvitesApiProvider.future); - final current = state.value ?? const []; - state = AsyncData(current.where((i) => i.token != token).toList()); - try { - await api.revoke(token); - } catch (e, st) { - state = AsyncData(current); - Error.throwWithStackTrace(e, st); - } - } -} - -final adminInvitesProvider = - AsyncNotifierProvider>( - AdminInvitesController.new); diff --git a/flutter_client/lib/admin/admin_quarantine_screen.dart b/flutter_client/lib/admin/admin_quarantine_screen.dart deleted file mode 100644 index be77580a..00000000 --- a/flutter_client/lib/admin/admin_quarantine_screen.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../shared/live_events_provider.dart'; -import '../shared/widgets/main_app_bar_actions.dart'; -import '../theme/theme_extension.dart'; -import 'admin_providers.dart'; -import 'widgets/admin_quarantine_row.dart'; - -class AdminQuarantineScreen extends ConsumerWidget { - const AdminQuarantineScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - // #402 wire-up: any quarantine event (flag from a user / admin - // resolve / file delete / lidarr delete) refreshes the admin queue. - ref.listen>(liveEventsProvider, (_, next) { - final e = next.asData?.value; - if (e == null) return; - if (e.kind.startsWith('quarantine.')) { - ref.invalidate(adminQuarantineProvider); - } - }); - final items = ref.watch(adminQuarantineProvider); - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - title: Text('Quarantine', style: TextStyle(color: fs.parchment)), - actions: const [MainAppBarActions(currentRoute: '/admin/quarantine')], - ), - body: SafeArea( - child: items.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => - Center(child: Text('$e', style: TextStyle(color: fs.error))), - data: (rows) { - if (rows.isEmpty) { - return Center( - child: Text('No quarantined tracks.', - style: TextStyle(color: fs.ash)), - ); - } - final notifier = ref.read(adminQuarantineProvider.notifier); - return RefreshIndicator( - onRefresh: () async => - ref.refresh(adminQuarantineProvider.future), - child: ListView.builder( - itemCount: rows.length, - itemBuilder: (_, i) => AdminQuarantineRow( - key: Key('admin_quarantine_row_${rows[i].trackId}'), - item: rows[i], - onResolve: () => notifier.resolve(rows[i].trackId), - onDeleteFile: () => notifier.deleteFile(rows[i].trackId), - onDeleteViaLidarr: () => - notifier.deleteViaLidarr(rows[i].trackId), - ), - ), - ); - }, - ), - ), - ); - } -} diff --git a/flutter_client/lib/admin/admin_requests_screen.dart b/flutter_client/lib/admin/admin_requests_screen.dart deleted file mode 100644 index 5063265e..00000000 --- a/flutter_client/lib/admin/admin_requests_screen.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../models/admin_user.dart'; -import '../shared/live_events_provider.dart'; -import '../shared/widgets/main_app_bar_actions.dart'; -import '../theme/theme_extension.dart'; -import 'admin_providers.dart'; -import 'widgets/admin_request_row.dart'; - -class AdminRequestsScreen extends ConsumerWidget { - const AdminRequestsScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - // #402 wire-up: invalidate the admin requests list when a user - // creates/cancels/admin approves/rejects/reconciler completes — - // request.status_changed covers all of them. - ref.listen>(liveEventsProvider, (_, next) { - final e = next.asData?.value; - if (e?.kind == 'request.status_changed') { - ref.invalidate(adminRequestsProvider); - } - }); - final requests = ref.watch(adminRequestsProvider); - // Best-effort lookup for requester usernames. If the users provider - // hasn't loaded yet, valueOrNull is null and rows fall back to the - // UUID prefix; no blocking spinner. - final users = ref.watch(adminUsersProvider).value ?? const []; - final usersById = {for (final u in users) u.id: u}; - - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - title: Text('Requests', style: TextStyle(color: fs.parchment)), - actions: const [MainAppBarActions(currentRoute: '/admin/requests')], - ), - body: SafeArea( - child: requests.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => - Center(child: Text('$e', style: TextStyle(color: fs.error))), - data: (rows) { - if (rows.isEmpty) { - return Center( - child: Text('No pending requests.', - style: TextStyle(color: fs.ash)), - ); - } - final notifier = ref.read(adminRequestsProvider.notifier); - return RefreshIndicator( - onRefresh: () async => - ref.refresh(adminRequestsProvider.future), - child: ListView.builder( - itemCount: rows.length, - itemBuilder: (_, i) { - final req = rows[i]; - final user = usersById[req.userId]; - final display = user?.username ?? - (req.userId.length >= 8 - ? req.userId.substring(0, 8) - : req.userId); - return AdminRequestRow( - key: Key('admin_request_row_${req.id}'), - request: req, - requesterDisplay: display, - onApprove: () => notifier.approve(req.id), - onReject: () => notifier.reject(req.id), - ); - }, - ), - ); - }, - ), - ), - ); - } -} diff --git a/flutter_client/lib/admin/admin_users_screen.dart b/flutter_client/lib/admin/admin_users_screen.dart deleted file mode 100644 index 122a5891..00000000 --- a/flutter_client/lib/admin/admin_users_screen.dart +++ /dev/null @@ -1,201 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../shared/widgets/main_app_bar_actions.dart'; -import '../theme/theme_extension.dart'; -import 'admin_providers.dart'; -import 'widgets/admin_user_edit_sheet.dart'; -import 'widgets/admin_user_row.dart'; -import 'widgets/invite_row.dart'; - -class AdminUsersScreen extends ConsumerWidget { - const AdminUsersScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final users = ref.watch(adminUsersProvider); - final invites = ref.watch(adminInvitesProvider); - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - title: Text('Users', style: TextStyle(color: fs.parchment)), - actions: const [MainAppBarActions(currentRoute: '/admin/users')], - ), - body: SafeArea( - child: RefreshIndicator( - onRefresh: () async { - ref.invalidate(adminUsersProvider); - ref.invalidate(adminInvitesProvider); - }, - child: ListView( - children: [ - const _SectionHeader(text: 'Users'), - users.when( - loading: () => const Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ), - error: (e, _) => Padding( - padding: const EdgeInsets.all(16), - child: Text('$e', style: TextStyle(color: fs.error)), - ), - data: (rows) => Column( - children: rows - .map((u) => AdminUserRow( - key: Key('admin_user_row_${u.id}'), - user: u, - onTap: () => AdminUserEditSheet.show(context, u), - )) - .toList(), - ), - ), - const Divider(), - _SectionHeader( - text: 'Invites', - trailing: TextButton.icon( - key: const Key('invite_generate_button'), - onPressed: () => _showGenerateInvite(context, ref), - icon: Icon(LucideIcons.plus, color: fs.parchment), - label: Text('Generate', - style: TextStyle(color: fs.parchment)), - ), - ), - invites.when( - loading: () => const Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ), - error: (e, _) => Padding( - padding: const EdgeInsets.all(16), - child: Text('$e', style: TextStyle(color: fs.error)), - ), - data: (rows) => Column( - children: rows - .map((i) => InviteRow( - key: Key('invite_row_${i.token}'), - invite: i, - onRevoke: () => ref - .read(adminInvitesProvider.notifier) - .revoke(i.token), - )) - .toList(), - ), - ), - ], - ), - ), - ), - ); - } - - Future _showGenerateInvite(BuildContext context, WidgetRef ref) async { - final controller = TextEditingController(); - final note = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Generate invite'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Token expires in 24 hours.'), - const SizedBox(height: 12), - TextField( - controller: controller, - decoration: const InputDecoration( - labelText: 'Note (optional)', - helperText: 'e.g. "for alice"', - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, controller.text), - child: const Text('Generate'), - ), - ], - ), - ); - if (note == null || !context.mounted) return; - try { - final invite = await ref - .read(adminInvitesProvider.notifier) - .create(note: note.isEmpty ? null : note); - if (!context.mounted) return; - await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Invite created'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Share this token with the new user:'), - const SizedBox(height: 8), - SelectableText( - invite.token, - style: const TextStyle(fontFamily: 'JetBrainsMono'), - ), - ], - ), - actions: [ - TextButton( - onPressed: () { - Clipboard.setData(ClipboardData(text: invite.token)); - Navigator.pop(ctx); - }, - child: const Text('Copy'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Close'), - ), - ], - ), - ); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('$e'))); - } - } - } -} - -class _SectionHeader extends StatelessWidget { - const _SectionHeader({required this.text, this.trailing}); - final String text; - final Widget? trailing; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Row( - children: [ - Text( - text, - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 18, - ), - ), - const Spacer(), - if (trailing != null) trailing!, - ], - ), - ); - } -} diff --git a/flutter_client/lib/admin/widgets/admin_quarantine_row.dart b/flutter_client/lib/admin/widgets/admin_quarantine_row.dart deleted file mode 100644 index 8221b82b..00000000 --- a/flutter_client/lib/admin/widgets/admin_quarantine_row.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; - -import '../../models/admin_quarantine_item.dart'; -import '../../theme/theme_extension.dart'; -import 'typed_confirm_sheet.dart'; - -class AdminQuarantineRow extends StatelessWidget { - const AdminQuarantineRow({ - super.key, - required this.item, - required this.onResolve, - required this.onDeleteFile, - required this.onDeleteViaLidarr, - }); - - final AdminQuarantineItem item; - final VoidCallback onResolve; - final VoidCallback onDeleteFile; - final VoidCallback onDeleteViaLidarr; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return ExpansionTile( - key: Key('admin_quarantine_tile_${item.trackId}'), - title: Text( - item.trackTitle, - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 16, - ), - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('${item.artistName} · ${item.albumTitle}', - style: TextStyle(color: fs.ash)), - Text( - '${item.reportCount} ' - '${item.reportCount == 1 ? "report" : "reports"} · ${item.topReasonSummary}', - style: TextStyle(color: fs.error, fontSize: 12), - ), - ], - ), - trailing: PopupMenuButton( - key: Key('admin_quarantine_menu_${item.trackId}'), - icon: Icon(LucideIcons.ellipsis_vertical, color: fs.parchment), - onSelected: (action) async { - switch (action) { - case 'resolve': - onResolve(); - break; - case 'delete_file': - if (await TypedConfirmSheet.show( - context, - title: 'Delete file?', - message: - 'Permanently delete the local file for "${item.trackTitle}". ' - 'Cannot be undone.', - )) { - onDeleteFile(); - } - break; - case 'delete_lidarr': - if (await TypedConfirmSheet.show( - context, - title: 'Delete via Lidarr?', - message: - 'Ask Lidarr to delete the file for "${item.trackTitle}".', - )) { - onDeleteViaLidarr(); - } - break; - } - }, - itemBuilder: (_) => const [ - PopupMenuItem(value: 'resolve', child: Text('Resolve')), - PopupMenuItem(value: 'delete_file', child: Text('Delete file')), - PopupMenuItem( - value: 'delete_lidarr', child: Text('Delete via Lidarr')), - ], - ), - childrenPadding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), - children: [ - for (final report in item.reports) - ListTile( - dense: true, - title: Text( - '${report.username} — ${report.reason}', - style: TextStyle(color: fs.parchment, fontSize: 13), - ), - subtitle: report.notes != null - ? Text(report.notes!, - style: TextStyle(color: fs.ash, fontSize: 12)) - : null, - ), - ], - ); - } -} diff --git a/flutter_client/lib/admin/widgets/admin_request_row.dart b/flutter_client/lib/admin/widgets/admin_request_row.dart deleted file mode 100644 index f826556c..00000000 --- a/flutter_client/lib/admin/widgets/admin_request_row.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../models/admin_request.dart'; -import '../../theme/theme_extension.dart'; - -class AdminRequestRow extends StatelessWidget { - const AdminRequestRow({ - super.key, - required this.request, - required this.requesterDisplay, - required this.onApprove, - required this.onReject, - }); - - final AdminRequest request; - - /// Pre-resolved username (or UUID-prefix fallback) for the requester. - final String requesterDisplay; - - final VoidCallback onApprove; - final VoidCallback onReject; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final subtitle = - '${request.kind} · ${request.artistName} · requested by $requesterDisplay'; - return ListTile( - title: Text( - request.displayName, - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 16, - ), - ), - subtitle: Text(subtitle, style: TextStyle(color: fs.ash)), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - onPressed: onApprove, - style: TextButton.styleFrom(foregroundColor: fs.moss), - child: const Text('Approve'), - ), - TextButton( - onPressed: () async { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Reject request?'), - content: Text('Reject "${request.displayName}"?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, true), - style: TextButton.styleFrom(foregroundColor: fs.oxblood), - child: const Text('Reject'), - ), - ], - ), - ); - if (ok == true) onReject(); - }, - style: TextButton.styleFrom(foregroundColor: fs.oxblood), - child: const Text('Reject'), - ), - ], - ), - ); - } -} diff --git a/flutter_client/lib/admin/widgets/admin_section_card.dart b/flutter_client/lib/admin/widgets/admin_section_card.dart deleted file mode 100644 index 919441de..00000000 --- a/flutter_client/lib/admin/widgets/admin_section_card.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../theme/theme_extension.dart'; - -class AdminSectionCard extends StatelessWidget { - const AdminSectionCard({ - super.key, - required this.icon, - required this.title, - required this.subtitle, - required this.count, - required this.onTap, - }); - - final IconData icon; - final String title; - final String subtitle; - final int count; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Card( - color: fs.iron, - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: ListTile( - leading: Icon(icon, color: fs.parchment), - title: Text( - title, - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 18, - ), - ), - subtitle: Text(subtitle, style: TextStyle(color: fs.ash)), - trailing: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: fs.bronze, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - '$count', - style: TextStyle(color: fs.obsidian, fontWeight: FontWeight.w500), - ), - ), - onTap: onTap, - ), - ); - } -} diff --git a/flutter_client/lib/admin/widgets/admin_user_edit_sheet.dart b/flutter_client/lib/admin/widgets/admin_user_edit_sheet.dart deleted file mode 100644 index 870d4af4..00000000 --- a/flutter_client/lib/admin/widgets/admin_user_edit_sheet.dart +++ /dev/null @@ -1,167 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../models/admin_user.dart'; -import '../../theme/theme_extension.dart'; -import '../admin_providers.dart'; -import 'typed_confirm_sheet.dart'; - -class AdminUserEditSheet extends ConsumerWidget { - const AdminUserEditSheet({super.key, required this.user}); - - final AdminUser user; - - static Future show(BuildContext context, AdminUser user) => - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (_) => AdminUserEditSheet(user: user), - ); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final users = ref.read(adminUsersProvider.notifier); - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: Container( - color: fs.iron, - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - user.username, - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 20, - ), - ), - const SizedBox(height: 12), - SwitchListTile( - key: const Key('user_edit_is_admin'), - title: Text('Admin', style: TextStyle(color: fs.parchment)), - value: user.isAdmin, - onChanged: (v) async { - try { - await users.setAdmin(user.id, v); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('$e'))); - } - } - }, - ), - SwitchListTile( - key: const Key('user_edit_auto_approve'), - title: Text('Auto-approve requests', - style: TextStyle(color: fs.parchment)), - value: user.autoApproveRequests, - onChanged: (v) async { - try { - await users.setAutoApprove(user.id, v); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('$e'))); - } - } - }, - ), - const SizedBox(height: 12), - Row( - children: [ - TextButton( - key: const Key('user_edit_reset_password'), - onPressed: () => _resetPassword(context, users), - child: const Text('Reset password'), - ), - const Spacer(), - TextButton( - key: const Key('user_edit_delete'), - style: TextButton.styleFrom(foregroundColor: fs.oxblood), - onPressed: () => _deleteUser(context, users), - child: const Text('Delete'), - ), - ], - ), - ], - ), - ), - ); - } - - Future _resetPassword( - BuildContext context, AdminUsersController users) async { - final controller = TextEditingController(); - final newPw = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Reset password'), - content: TextField( - controller: controller, - obscureText: true, - decoration: const InputDecoration( - labelText: 'New password', - helperText: 'Minimum 8 characters', - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - if (controller.text.length >= 8) { - Navigator.pop(ctx, controller.text); - } - }, - child: const Text('Reset'), - ), - ], - ), - ); - if (newPw == null || !context.mounted) return; - try { - await users.resetPassword(user.id, newPw); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Password reset.')), - ); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('$e'))); - } - } - } - - Future _deleteUser( - BuildContext context, AdminUsersController users) async { - if (!await TypedConfirmSheet.show( - context, - title: 'Delete user?', - message: - 'Permanently delete user "${user.username}" and all their data. ' - 'Cannot be undone.', - )) { - return; - } - try { - await users.delete(user.id); - if (context.mounted) Navigator.pop(context); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('$e'))); - } - } - } -} diff --git a/flutter_client/lib/admin/widgets/admin_user_row.dart b/flutter_client/lib/admin/widgets/admin_user_row.dart deleted file mode 100644 index e4deee49..00000000 --- a/flutter_client/lib/admin/widgets/admin_user_row.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; - -import '../../models/admin_user.dart'; -import '../../theme/theme_extension.dart'; - -class AdminUserRow extends StatelessWidget { - const AdminUserRow({super.key, required this.user, required this.onTap}); - - final AdminUser user; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return ListTile( - title: Text( - user.username, - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 16, - ), - ), - subtitle: Wrap( - spacing: 6, - children: [ - if (user.isAdmin) _Badge(label: 'admin', color: fs.bronze), - if (user.autoApproveRequests) - _Badge(label: 'auto-approve', color: fs.moss), - ], - ), - trailing: Icon(LucideIcons.chevron_right, color: fs.ash), - onTap: onTap, - ); - } -} - -class _Badge extends StatelessWidget { - const _Badge({required this.label, required this.color}); - final String label; - final Color color; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - label, - style: TextStyle(color: fs.obsidian, fontSize: 11), - ), - ); - } -} diff --git a/flutter_client/lib/admin/widgets/invite_row.dart b/flutter_client/lib/admin/widgets/invite_row.dart deleted file mode 100644 index 5a9987dd..00000000 --- a/flutter_client/lib/admin/widgets/invite_row.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter/services.dart'; - -import '../../models/invite.dart'; -import '../../theme/theme_extension.dart'; - -class InviteRow extends StatelessWidget { - const InviteRow({ - super.key, - required this.invite, - required this.onRevoke, - }); - - final Invite invite; - final VoidCallback onRevoke; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return ListTile( - title: Row( - children: [ - Expanded( - child: SelectableText( - invite.token, - style: TextStyle( - color: fs.parchment, - fontFamily: 'JetBrainsMono', - fontSize: 13, - ), - ), - ), - IconButton( - icon: Icon(LucideIcons.copy, color: fs.ash, size: 18), - tooltip: 'Copy', - onPressed: () => - Clipboard.setData(ClipboardData(text: invite.token)), - ), - ], - ), - subtitle: Wrap( - spacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text('expires ${invite.expiresAt}', - style: TextStyle(color: fs.ash, fontSize: 12)), - if (invite.note != null) - Text('· ${invite.note}', - style: TextStyle(color: fs.ash, fontSize: 12)), - if (invite.isRedeemed) - Container( - padding: - const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - decoration: BoxDecoration( - color: fs.ash, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - 'redeemed', - style: TextStyle(color: fs.obsidian, fontSize: 10), - ), - ), - ], - ), - trailing: IconButton( - icon: Icon(LucideIcons.trash_2, color: fs.oxblood), - tooltip: 'Revoke', - onPressed: onRevoke, - ), - ); - } -} diff --git a/flutter_client/lib/admin/widgets/typed_confirm_sheet.dart b/flutter_client/lib/admin/widgets/typed_confirm_sheet.dart deleted file mode 100644 index 2904e7ae..00000000 --- a/flutter_client/lib/admin/widgets/typed_confirm_sheet.dart +++ /dev/null @@ -1,124 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../theme/theme_extension.dart'; - -/// Modal bottom sheet that requires the user to type [confirmWord] -/// (default "DELETE") before the confirm button enables. Used for -/// destructive actions like quarantine delete-file / delete-via-Lidarr -/// and user delete. -/// -/// Returns true if the user confirmed, false (or null → false) otherwise. -class TypedConfirmSheet extends StatefulWidget { - const TypedConfirmSheet({ - super.key, - required this.title, - required this.message, - this.confirmWord = 'DELETE', - this.confirmLabel = 'Delete', - }); - - final String title; - final String message; - final String confirmWord; - final String confirmLabel; - - static Future show( - BuildContext context, { - required String title, - required String message, - String confirmWord = 'DELETE', - String confirmLabel = 'Delete', - }) async { - final ok = await showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (_) => TypedConfirmSheet( - title: title, - message: message, - confirmWord: confirmWord, - confirmLabel: confirmLabel, - ), - ); - return ok ?? false; - } - - @override - State createState() => _TypedConfirmSheetState(); -} - -class _TypedConfirmSheetState extends State { - final _controller = TextEditingController(); - bool _enabled = false; - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: Container( - color: fs.iron, - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.title, - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 20, - ), - ), - const SizedBox(height: 12), - Text(widget.message, style: TextStyle(color: fs.ash)), - const SizedBox(height: 16), - Text( - 'Type ${widget.confirmWord} to confirm:', - style: TextStyle(color: fs.ash), - ), - const SizedBox(height: 8), - TextField( - key: const Key('typed_confirm_input'), - controller: _controller, - autofocus: true, - style: TextStyle(color: fs.parchment, fontFamily: 'JetBrainsMono'), - decoration: const InputDecoration(border: OutlineInputBorder()), - onChanged: (v) => - setState(() => _enabled = v == widget.confirmWord), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), - ), - const SizedBox(width: 8), - ElevatedButton( - key: const Key('typed_confirm_button'), - onPressed: - _enabled ? () => Navigator.pop(context, true) : null, - style: ElevatedButton.styleFrom( - backgroundColor: fs.oxblood, - foregroundColor: fs.parchment, - ), - child: Text(widget.confirmLabel), - ), - ], - ), - ], - ), - ), - ); - } -} diff --git a/flutter_client/lib/api/client.dart b/flutter_client/lib/api/client.dart deleted file mode 100644 index dd6363fa..00000000 --- a/flutter_client/lib/api/client.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:dio/dio.dart'; - -typedef TokenResolver = Future Function(); -typedef OnUnauthenticated = Future Function(); - -class ApiClient { - /// Builds a dio instance pinned to [baseUrl] with a Bearer-auth - /// interceptor that pulls the current token from [tokenResolver] - /// on every request. Server already supports cookie OR bearer - /// (internal/auth/session.go); we use bearer to skip cookie-jar. - /// - /// [on401] fires when any response comes back 401. Use it to clear - /// stored credentials so the router redirect can route back to login. - static Dio buildDio({ - required String baseUrl, - required TokenResolver tokenResolver, - OnUnauthenticated? on401, - }) { - final d = Dio(BaseOptions( - baseUrl: baseUrl, - connectTimeout: const Duration(seconds: 8), - receiveTimeout: const Duration(seconds: 30), - contentType: Headers.jsonContentType, - responseType: ResponseType.json, - )); - d.interceptors.add(InterceptorsWrapper( - onRequest: (opts, h) async { - final t = await tokenResolver(); - if (t != null && t.isNotEmpty) opts.headers['Authorization'] = 'Bearer $t'; - h.next(opts); - }, - onError: (e, h) async { - if (e.response?.statusCode == 401 && on401 != null) { - await on401(); - } - h.next(e); - }, - )); - return d; - } -} diff --git a/flutter_client/lib/api/endpoints/admin_invites.dart b/flutter_client/lib/api/endpoints/admin_invites.dart deleted file mode 100644 index e5ab6142..00000000 --- a/flutter_client/lib/api/endpoints/admin_invites.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/invite.dart'; - -class AdminInvitesApi { - AdminInvitesApi(this._dio); - final Dio _dio; - - /// GET /api/admin/invites → `{"invites": [...]}`. Envelope unwrapped. - Future> list() async { - final r = await _dio.get>('/api/admin/invites'); - final raw = (r.data?['invites'] as List?) ?? const []; - return raw - .map((e) => Invite.fromJson((e as Map).cast())) - .toList(growable: false); - } - - /// POST /api/admin/invites — body is optional `{"note": "..."}`. - /// Server hardcodes the 24h TTL; admins cannot configure expiry. - /// Returns the bare invite (not enveloped). - Future create({String? note}) async { - final r = await _dio.post>( - '/api/admin/invites', - data: {if (note != null && note.isNotEmpty) 'note': note}, - ); - return Invite.fromJson(r.data ?? const {}); - } - - Future revoke(String token) async { - await _dio.delete('/api/admin/invites/$token'); - } -} diff --git a/flutter_client/lib/api/endpoints/admin_quarantine.dart b/flutter_client/lib/api/endpoints/admin_quarantine.dart deleted file mode 100644 index e72736c0..00000000 --- a/flutter_client/lib/api/endpoints/admin_quarantine.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/admin_quarantine_item.dart'; - -class AdminQuarantineApi { - AdminQuarantineApi(this._dio); - final Dio _dio; - - /// GET /api/admin/quarantine — flat list (no envelope) of aggregated - /// quarantine rows. - Future> list() async { - final r = await _dio.get>('/api/admin/quarantine'); - final raw = r.data ?? const []; - return raw - .map((e) => - AdminQuarantineItem.fromJson((e as Map).cast())) - .toList(growable: false); - } - - Future resolve(String trackId) async { - await _dio.post('/api/admin/quarantine/$trackId/resolve'); - } - - Future deleteFile(String trackId) async { - await _dio.post('/api/admin/quarantine/$trackId/delete-file'); - } - - Future deleteViaLidarr(String trackId) async { - await _dio.post('/api/admin/quarantine/$trackId/delete-via-lidarr'); - } -} diff --git a/flutter_client/lib/api/endpoints/admin_requests.dart b/flutter_client/lib/api/endpoints/admin_requests.dart deleted file mode 100644 index 519cd7bd..00000000 --- a/flutter_client/lib/api/endpoints/admin_requests.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/admin_request.dart'; - -class AdminRequestsApi { - AdminRequestsApi(this._dio); - final Dio _dio; - - /// GET /api/admin/requests — flat list of pending requests. - /// Server defaults to ?status=pending; we don't override. - Future> list() async { - final r = await _dio.get>('/api/admin/requests'); - final raw = r.data ?? const []; - return raw - .map((e) => AdminRequest.fromJson((e as Map).cast())) - .toList(growable: false); - } - - Future approve(String id) async { - await _dio.post('/api/admin/requests/$id/approve'); - } - - Future reject(String id) async { - await _dio.post('/api/admin/requests/$id/reject'); - } -} diff --git a/flutter_client/lib/api/endpoints/admin_users.dart b/flutter_client/lib/api/endpoints/admin_users.dart deleted file mode 100644 index 92252c57..00000000 --- a/flutter_client/lib/api/endpoints/admin_users.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/admin_user.dart'; - -class AdminUsersApi { - AdminUsersApi(this._dio); - final Dio _dio; - - /// GET /api/admin/users → `{"users": [...]}`. Envelope unwrapped here. - Future> list() async { - final r = await _dio.get>('/api/admin/users'); - final raw = (r.data?['users'] as List?) ?? const []; - return raw - .map((e) => AdminUser.fromJson((e as Map).cast())) - .toList(growable: false); - } - - Future setAdmin(String id, bool isAdmin) async { - await _dio.put( - '/api/admin/users/$id/admin', - data: {'is_admin': isAdmin}, - ); - } - - /// Server's body field is `auto_approve` (NOT `auto_approve_requests`) - /// — different from the response field name. Verified against - /// internal/api/admin_users.go `adminAutoApproveReq` (May 2026). - Future setAutoApprove(String id, bool autoApprove) async { - await _dio.put( - '/api/admin/users/$id/auto-approve', - data: {'auto_approve': autoApprove}, - ); - } - - /// Admin supplies the new password; server returns 204. There is no - /// server-generated-password mode. - Future resetPassword(String id, String newPassword) async { - await _dio.post( - '/api/admin/users/$id/reset-password', - data: {'password': newPassword}, - ); - } - - Future delete(String id) async { - await _dio.delete('/api/admin/users/$id'); - } -} diff --git a/flutter_client/lib/api/endpoints/auth.dart b/flutter_client/lib/api/endpoints/auth.dart deleted file mode 100644 index 0f5b2983..00000000 --- a/flutter_client/lib/api/endpoints/auth.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'dart:convert'; - -import 'package:dio/dio.dart'; - -import '../../models/user.dart'; - -class AuthApi { - AuthApi(this._dio); - final Dio _dio; - - /// Returns (token, user, rawUserJson) on success. Server emits - /// LoginResponse{token, user{id, username, is_admin}}. - Future<({String token, User user, String rawUserJson})> login({ - required String username, - required String password, - }) async { - final r = await _dio.post>( - '/api/auth/login', - data: {'username': username, 'password': password}, - ); - final body = r.data!; - final userMap = (body['user'] as Map).cast(); - return ( - token: body['token'] as String, - user: User.fromJson(userMap), - rawUserJson: jsonEncode(userMap), - ); - } - - Future logout() async { - await _dio.post('/api/auth/logout'); - } -} diff --git a/flutter_client/lib/api/endpoints/discover.dart b/flutter_client/lib/api/endpoints/discover.dart deleted file mode 100644 index 1a6d2f4f..00000000 --- a/flutter_client/lib/api/endpoints/discover.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/artist_suggestion.dart'; -import '../../models/lidarr.dart'; - -class DiscoverApi { - DiscoverApi(this._dio); - final Dio _dio; - - /// GET /api/discover/suggestions — out-of-library artist suggestions - /// (ListenBrainz-derived; image_url resolved on-demand from Lidarr, - /// may be empty). The server already filters in-library and - /// non-terminal-request candidates. - Future> listSuggestions() async { - final r = await _dio.get>('/api/discover/suggestions'); - final raw = r.data ?? const []; - return raw - .map((e) => - ArtistSuggestion.fromJson((e as Map).cast())) - .toList(growable: false); - } - - /// GET /api/lidarr/search?q=...&kind=artist|album|track. Server has a - /// 60s LRU cache for repeat queries so re-typing the same string in - /// quick succession is cheap. - Future> search( - String query, - LidarrRequestKind kind, - ) async { - final r = await _dio.get>( - '/api/lidarr/search', - queryParameters: {'q': query, 'kind': kind.wire}, - ); - final raw = r.data ?? const []; - return raw - .map((e) => - LidarrSearchResult.fromJson((e as Map).cast())) - .toList(growable: false); - } - - /// POST /api/requests. Returns the newly-created LidarrRequest body - /// — we don't expose a typed wrapper for that yet on mobile (admin - /// reviews requests; user just kicks them off), so we discard the - /// response and surface success/failure to the caller. - Future createRequest({ - required LidarrRequestKind kind, - required String artistMbid, - required String artistName, - String? albumMbid, - String? albumTitle, - String? trackMbid, - String? trackTitle, - }) async { - final body = { - 'kind': kind.wire, - 'lidarr_artist_mbid': artistMbid, - 'artist_name': artistName, - }; - if (albumMbid != null && albumMbid.isNotEmpty) { - body['lidarr_album_mbid'] = albumMbid; - } - if (trackMbid != null && trackMbid.isNotEmpty) { - body['lidarr_track_mbid'] = trackMbid; - } - if (albumTitle != null && albumTitle.isNotEmpty) { - body['album_title'] = albumTitle; - } - if (trackTitle != null && trackTitle.isNotEmpty) { - body['track_title'] = trackTitle; - } - await _dio.post>('/api/requests', data: body); - } -} diff --git a/flutter_client/lib/api/endpoints/events.dart b/flutter_client/lib/api/endpoints/events.dart deleted file mode 100644 index c5ab0fc0..00000000 --- a/flutter_client/lib/api/endpoints/events.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:dio/dio.dart'; - -/// Thin client for POST /api/events — the play-event lifecycle the -/// server uses for history, recommendation scoring, ListenBrainz -/// scrobbles, and (since #415) system-playlist rotation. -/// -/// Mirrors the web events dispatcher's three calls. Best-effort by -/// contract: callers swallow errors — a missed event is acceptable -/// per the server spec's v1 stance, and the server's -/// auto-close-prior-open keeps history sane even if an ended/skipped -/// is lost. -class EventsApi { - EventsApi(this._dio); - final Dio _dio; - - /// POST play_started. Returns the server's play_event_id (used to - /// close the row later), or null if the call failed / response was - /// malformed. `source` tags the originating system playlist - /// ('for_you' | 'discover') so the server advances that rotation; - /// omit for library / user-playlist / radio plays. - Future playStarted({ - required String trackId, - required String clientId, - String? source, - }) async { - final r = await _dio.post>( - '/api/events', - data: { - 'type': 'play_started', - 'track_id': trackId, - 'client_id': clientId, - if (source != null && source.isNotEmpty) 'source': source, - }, - ); - return (r.data ?? const {})['play_event_id'] as String?; - } - - Future playEnded({ - required String playEventId, - required int durationPlayedMs, - }) async { - await _dio.post( - '/api/events', - data: { - 'type': 'play_ended', - 'play_event_id': playEventId, - 'duration_played_ms': durationPlayedMs, - }, - ); - } - - Future playSkipped({ - required String playEventId, - required int positionMs, - }) async { - await _dio.post( - '/api/events', - data: { - 'type': 'play_skipped', - 'play_event_id': playEventId, - 'position_ms': positionMs, - }, - ); - } - - /// Replays a complete play that happened offline / on a flaky - /// connection (#426 part B). One call: the server records start+end - /// from `atIso` (the original play-start time) + durationPlayedMs, - /// applies the canonical skip rule, and advances #415 rotation when - /// source is a system playlist. Driven by the offline mutation - /// queue, never the live path. - Future playOffline({ - required String trackId, - required String clientId, - required String atIso, - required int durationPlayedMs, - String? source, - }) async { - await _dio.post( - '/api/events', - data: { - 'type': 'play_offline', - 'track_id': trackId, - 'client_id': clientId, - 'at': atIso, - 'duration_played_ms': durationPlayedMs, - if (source != null && source.isNotEmpty) 'source': source, - }, - ); - } -} diff --git a/flutter_client/lib/api/endpoints/health.dart b/flutter_client/lib/api/endpoints/health.dart deleted file mode 100644 index 5f43b117..00000000 --- a/flutter_client/lib/api/endpoints/health.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:dio/dio.dart'; - -class HealthApi { - HealthApi(this._dio); - final Dio _dio; - - /// Returns {status, min_client_version}. Hits the unauthenticated - /// `/healthz` endpoint at the configured base URL. - Future> check() async { - final r = await _dio.get>('/healthz'); - return (r.data ?? const {}) - .map((k, v) => MapEntry(k, v.toString())); - } -} diff --git a/flutter_client/lib/api/endpoints/library.dart b/flutter_client/lib/api/endpoints/library.dart deleted file mode 100644 index 53895b7f..00000000 --- a/flutter_client/lib/api/endpoints/library.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/album.dart'; -import '../../models/artist.dart'; -import '../../models/home_data.dart'; -import '../../models/home_index.dart'; -import '../../models/track.dart'; - -/// LibraryApi wraps the server's native /api/* library surface. -/// -/// Response shapes (verified against internal/api/library.go, -/// internal/api/library_albums.go, internal/api/home.go): -/// -/// GET /api/home → HomePayload (flat sections) -/// GET /api/artists/{id} → ArtistDetail (ArtistRef fields + "albums") -/// GET /api/artists/{id}/tracks → flat []TrackRef array (NOT enveloped) -/// GET /api/albums/{id} → AlbumDetail (AlbumRef fields + "tracks") -/// -/// The artist-tracks endpoint deviates from the plan-text starter: the -/// server emits a bare JSON array, not `{"tracks": [...]}`. We parse the -/// top-level response as `List` accordingly. -class LibraryApi { - LibraryApi(this._dio); - final Dio _dio; - - Future getHome() async { - final r = await _dio.get>('/api/home'); - return HomeData.fromJson(r.data ?? const {}); - } - - /// GET /api/home/index — per-item rendering variant. Returns just IDs - /// per section; client hydrates each tile via the per-entity - /// endpoints. ~10× smaller than /api/home on populated libraries so - /// the cold-visit round-trip is correspondingly short. - Future getHomeIndex() async { - final r = await _dio.get>('/api/home/index'); - return HomeIndex.fromJson(r.data ?? const {}); - } - - /// GET /api/tracks/{id}. Returns the canonical TrackRef. Used by - /// the HydrationQueue to populate cached_tracks on a per-tile miss - /// — the existing endpoint already joins album + artist so the - /// response carries everything TrackRef needs. - Future getTrack(String id) async { - final r = await _dio.get>('/api/tracks/$id'); - return TrackRef.fromJson(r.data ?? const {}); - } - - /// GET /api/artists/{id}. Server returns ArtistDetail which embeds - /// ArtistRef inline; ArtistRef.fromJson already reads only the fields - /// it cares about, so passing the whole body is correct. - Future getArtist(String id) async { - final r = await _dio.get>('/api/artists/$id'); - return ArtistRef.fromJson(r.data ?? const {}); - } - - /// GET /api/artists/{id} — full response with the ArtistRef AND the - /// embedded album list, both parsed. Single round-trip variant used - /// by CacheFiller and other callers that want to populate both - /// cached_artists and cached_albums in one shot. The existing - /// getArtist / getArtistAlbums keep working for callers that only - /// need one half — they hit the same URL but the per-id Riverpod - /// caching layer dedupes. - Future<({ArtistRef artist, List albums})> getArtistDetail( - String id) async { - final r = await _dio.get>('/api/artists/$id'); - final body = r.data ?? const {}; - final artist = ArtistRef.fromJson(body); - final albums = ((body['albums'] as List?) ?? const []) - .map((e) => AlbumRef.fromJson((e as Map).cast())) - .toList(growable: false); - return (artist: artist, albums: albums); - } - - /// Pulls the "albums" array out of the same ArtistDetail body. Callers - /// that need both the artist and its albums should issue two provider - /// reads (artistProvider + artistAlbumsProvider) — both hit the same - /// underlying URL and dio's response is not memoized here, but the - /// Riverpod layer caches per-id so cost stays at one round-trip. - Future> getArtistAlbums(String id) async { - final r = await _dio.get>('/api/artists/$id'); - final raw = (r.data?['albums'] as List?) ?? const []; - return raw - .map((e) => AlbumRef.fromJson((e as Map).cast())) - .toList(growable: false); - } - - /// GET /api/library/shuffle?limit=N (#427 S4). N random library - /// tracks — the online source for "Shuffle all". Bare JSON array. - Future> shuffle({int limit = 100}) async { - final r = await _dio.get>( - '/api/library/shuffle', - queryParameters: {'limit': limit}, - ); - final raw = r.data ?? const []; - return raw - .map((e) => TrackRef.fromJson((e as Map).cast())) - .toList(growable: false); - } - - /// GET /api/artists/{id}/tracks. Server emits a bare JSON array, so we - /// type the response as `List` rather than a Map envelope. - Future> getArtistTracks(String id) async { - final r = await _dio.get>('/api/artists/$id/tracks'); - final raw = r.data ?? const []; - return raw - .map((e) => TrackRef.fromJson((e as Map).cast())) - .toList(growable: false); - } - - /// GET /api/albums/{id}. Returns the album ref alongside its track list - /// in a single record so screens can render both without a second fetch. - Future<({AlbumRef album, List tracks})> getAlbum(String id) async { - final r = await _dio.get>('/api/albums/$id'); - final body = r.data ?? const {}; - final tracks = ((body['tracks'] as List?) ?? const []) - .map((e) => TrackRef.fromJson((e as Map).cast())) - .toList(growable: false); - return (album: AlbumRef.fromJson(body), tracks: tracks); - } -} diff --git a/flutter_client/lib/api/endpoints/library_lists.dart b/flutter_client/lib/api/endpoints/library_lists.dart deleted file mode 100644 index 8b274791..00000000 --- a/flutter_client/lib/api/endpoints/library_lists.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/album.dart'; -import '../../models/artist.dart'; -import '../../models/page.dart'; - -/// Paged library browse endpoints. Distinct from LibraryApi (which -/// fetches Home + entity detail) so screens that just need a flat -/// list don't drag in detail-page providers. -class LibraryListsApi { - LibraryListsApi(this._dio); - final Dio _dio; - - Future> listArtists({int limit = 50, int offset = 0}) async { - // Server mounts the artists list at /api/artists (handleListArtists), - // not /api/library/artists. Albums use /api/library/albums for - // historical reasons; the paths aren't symmetric. - final r = await _dio.get>( - '/api/artists', - queryParameters: { - 'limit': limit, - 'offset': offset, - 'sort': 'alpha', - }, - ); - return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson); - } - - Future> listAlbums({int limit = 50, int offset = 0}) async { - final r = await _dio.get>( - '/api/library/albums', - queryParameters: {'limit': limit, 'offset': offset}, - ); - return Paged.fromJson(r.data ?? const {}, AlbumRef.fromJson); - } -} diff --git a/flutter_client/lib/api/endpoints/likes.dart b/flutter_client/lib/api/endpoints/likes.dart deleted file mode 100644 index e4732369..00000000 --- a/flutter_client/lib/api/endpoints/likes.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/album.dart'; -import '../../models/artist.dart'; -import '../../models/page.dart'; -import '../../models/track.dart'; - -enum LikeKind { artist, album, track } - -extension LikeKindPath on LikeKind { - String get path => switch (this) { - LikeKind.artist => 'artists', - LikeKind.album => 'albums', - LikeKind.track => 'tracks', - }; -} - -class LikesApi { - LikesApi(this._dio); - final Dio _dio; - - Future like(LikeKind kind, String id) async { - await _dio.post('/api/likes/${kind.path}/$id'); - } - - Future unlike(LikeKind kind, String id) async { - await _dio.delete('/api/likes/${kind.path}/$id'); - } - - /// GET /api/likes/tracks?limit=N&offset=N. Paged. - Future> listTracks({int limit = 50, int offset = 0}) async { - final r = await _dio.get>( - '/api/likes/tracks', - queryParameters: {'limit': limit, 'offset': offset}, - ); - return Paged.fromJson(r.data ?? const {}, TrackRef.fromJson); - } - - Future> listAlbums({int limit = 50, int offset = 0}) async { - final r = await _dio.get>( - '/api/likes/albums', - queryParameters: {'limit': limit, 'offset': offset}, - ); - return Paged.fromJson(r.data ?? const {}, AlbumRef.fromJson); - } - - Future> listArtists({int limit = 50, int offset = 0}) async { - final r = await _dio.get>( - '/api/likes/artists', - queryParameters: {'limit': limit, 'offset': offset}, - ); - return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson); - } - - /// Returns sets of {artists, albums, tracks} the user has liked. - /// Server response keys (verified in internal/api/likes.go - /// `likedIDsResponse`): `artist_ids`, `album_ids`, `track_ids`. - Future<({Set artists, Set albums, Set tracks})> - ids() async { - final r = await _dio.get>('/api/likes/ids'); - final body = r.data ?? const {}; - Set set(String key) => - ((body[key] as List?) ?? const []).map((e) => e.toString()).toSet(); - return ( - artists: set('artist_ids'), - albums: set('album_ids'), - tracks: set('track_ids'), - ); - } -} diff --git a/flutter_client/lib/api/endpoints/me.dart b/flutter_client/lib/api/endpoints/me.dart deleted file mode 100644 index 9d867c2e..00000000 --- a/flutter_client/lib/api/endpoints/me.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/history_event.dart'; -import '../../models/quarantine_mine.dart'; -import '../../models/system_playlists_status.dart'; - -/// /api/me/* endpoints — caller-scoped data (history, profile, etc.). -class MeApi { - MeApi(this._dio); - final Dio _dio; - - /// GET /api/me/history. Paginated via offset/limit; server emits - /// `{events, has_more}` rather than the standard `Page` envelope. - Future history({int limit = 50, int offset = 0}) async { - final r = await _dio.get>( - '/api/me/history', - queryParameters: {'limit': limit, 'offset': offset}, - ); - return HistoryPage.fromJson(r.data ?? const {}); - } - - /// GET /api/quarantine/mine — flat list (no envelope). - Future> quarantineMine() async { - final r = await _dio.get>('/api/quarantine/mine'); - final raw = r.data ?? const []; - return raw - .map((e) => - QuarantineMineRow.fromJson((e as Map).cast())) - .toList(growable: false); - } - - /// GET /api/me/system-playlists-status. Returns the caller's most - /// recent system-playlist build state. Used by the home Playlists - /// row to choose between real and placeholder cards. - Future systemPlaylistsStatus() async { - final r = await _dio.get>( - '/api/me/system-playlists-status', - ); - return SystemPlaylistsStatus.fromJson(r.data ?? const {}); - } - - /// PUT /api/me/timezone — submit the device's IANA timezone. The - /// scheduler uses this to fire the user's daily playlist build at - /// 03:00 in their local time. See AuthController._sendTimezoneIfStale - /// for the weekly cadence trigger. - Future putTimezone(String timezone) async { - await _dio.put('/api/me/timezone', data: {'timezone': timezone}); - } -} diff --git a/flutter_client/lib/api/endpoints/playlists.dart b/flutter_client/lib/api/endpoints/playlists.dart deleted file mode 100644 index 3455aaed..00000000 --- a/flutter_client/lib/api/endpoints/playlists.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/playlist.dart'; - -/// Wire shape for GET /api/playlists. Server splits owned vs. public so -/// the UI can present them as different sections; we preserve that split -/// to give the integrations page room to grow. -class PlaylistsList { - const PlaylistsList({required this.owned, required this.public}); - final List owned; - final List public; - - factory PlaylistsList.empty() => - const PlaylistsList(owned: [], public: []); - - /// Concatenated view for callers that don't care about the split. - List get all => [...owned, ...public]; -} - -class PlaylistsApi { - PlaylistsApi(this._dio); - final Dio _dio; - - /// GET /api/playlists?kind=user|system|all. Server returns - /// `{"owned": [...], "public": [...]}`. Owned is the caller's own - /// playlists, filtered by the kind param (default "user"). Public - /// is other users' shared playlists; not filtered by kind. - Future list({String kind = 'user'}) async { - final r = await _dio.get>( - '/api/playlists', - queryParameters: {'kind': kind}, - ); - final body = r.data ?? const {}; - List parse(String key) { - final raw = (body[key] as List?) ?? const []; - return raw - .map((e) => Playlist.fromJson((e as Map).cast())) - .toList(growable: false); - } - return PlaylistsList(owned: parse('owned'), public: parse('public')); - } - - Future get(String id) async { - final r = await _dio.get>('/api/playlists/$id'); - return PlaylistDetail.fromJson(r.data ?? const {}); - } - - /// GET /api/playlists/system/{kind}/shuffle (#415 / #411 R2). - /// Same shape as get() but tracks are server-ordered rotation-aware - /// (unplayed-this-rotation first; resets when exhausted). {kind} is - /// the raw system_variant. Intentionally uncached — varies per play. - Future systemShuffle(String variant) async { - final r = await _dio.get>( - '/api/playlists/system/$variant/shuffle', - ); - return PlaylistDetail.fromJson(r.data ?? const {}); - } - - /// POST /api/playlists/{id}/tracks. Owner only; server returns the - /// playlist detail with the new rows. - Future appendTracks(String playlistId, List trackIds) async { - await _dio.post( - '/api/playlists/$playlistId/tracks', - data: {'track_ids': trackIds}, - ); - } - - /// POST /api/playlists/system/{kind}/refresh (#411 R2). Rebuilds - /// the caller's system playlists and returns the named kind's new - /// playlist id, or null when the library is empty. {kind} is the - /// raw system_variant — the server routes generically off the - /// kind registry, no hyphen mapping. - Future refreshSystem(String variant) async { - final r = await _dio.post>( - '/api/playlists/system/$variant/refresh', - data: const {}, - ); - return (r.data ?? const {})['playlist_id'] as String?; - } -} diff --git a/flutter_client/lib/api/endpoints/quarantine.dart b/flutter_client/lib/api/endpoints/quarantine.dart deleted file mode 100644 index 5568e222..00000000 --- a/flutter_client/lib/api/endpoints/quarantine.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:dio/dio.dart'; - -/// /api/quarantine — flag a track (with reason + optional notes) and -/// unflag it. Both endpoints are user-scoped: callers can only flag -/// their own quarantine entries; admins use a separate /admin/quarantine -/// surface. -class QuarantineApi { - QuarantineApi(this._dio); - final Dio _dio; - - /// POST /api/quarantine. Server returns 201 with the row. - /// Reason values: bad_rip | wrong_file | wrong_tags | duplicate | other. - Future flag(String trackId, String reason, {String notes = ''}) async { - await _dio.post('/api/quarantine', data: { - 'track_id': trackId, - 'reason': reason, - 'notes': notes, - }); - } - - /// DELETE /api/quarantine/{track_id}. Server returns 204. - Future unflag(String trackId) async { - await _dio.delete('/api/quarantine/$trackId'); - } -} diff --git a/flutter_client/lib/api/endpoints/radio.dart b/flutter_client/lib/api/endpoints/radio.dart deleted file mode 100644 index b8e4ca5a..00000000 --- a/flutter_client/lib/api/endpoints/radio.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/track.dart'; - -/// `GET /api/radio?seed_track=&limit=`. Returns the seed -/// at index 0 followed by up to `limit-1` weighted-shuffle picks -/// scored by the server's recommendation engine. The shape matches -/// what `playerActions.playTracks` expects, so a radio start is just -/// one fetch + one playTracks call. -class RadioApi { - RadioApi(this._dio); - final Dio _dio; - - Future> seedTrack(String trackId, {int? limit}) async { - final r = await _dio.get>( - '/api/radio', - queryParameters: { - 'seed_track': trackId, - if (limit != null) 'limit': limit, - }, - ); - final raw = (r.data?['tracks'] as List?) ?? const []; - return raw - .map((e) => TrackRef.fromJson((e as Map).cast())) - .toList(growable: false); - } -} diff --git a/flutter_client/lib/api/endpoints/requests.dart b/flutter_client/lib/api/endpoints/requests.dart deleted file mode 100644 index 219668fc..00000000 --- a/flutter_client/lib/api/endpoints/requests.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/admin_request.dart'; - -/// User-side requests API — `/api/requests`. Server scopes results to -/// the caller; admins see only their own here, not all users'. The -/// admin-cross-user view lives in `/api/admin/requests` (AdminRequestsApi). -/// -/// Wire shape is identical to the admin endpoint, so the same -/// AdminRequest model is reused. -class RequestsApi { - RequestsApi(this._dio); - final Dio _dio; - - /// GET /api/requests — caller's own requests, all statuses. - Future> listMine() async { - final r = await _dio.get>('/api/requests'); - final raw = r.data ?? const []; - return raw - .map((e) => AdminRequest.fromJson((e as Map).cast())) - .toList(growable: false); - } - - /// DELETE /api/requests/{id} — cancel a pending request. Server - /// returns the cancelled row body (not 204) so the caller can patch - /// local state without a refetch. - Future cancel(String id) async { - final r = await _dio.delete>('/api/requests/$id'); - return AdminRequest.fromJson(r.data ?? const {}); - } -} diff --git a/flutter_client/lib/api/endpoints/search.dart b/flutter_client/lib/api/endpoints/search.dart deleted file mode 100644 index 2ba84df1..00000000 --- a/flutter_client/lib/api/endpoints/search.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/search_response.dart'; - -/// SearchApi wraps GET /api/search. The server runs three facets (artists, -/// albums, tracks) sharing one limit/offset pair. Each facet returns its -/// own total reflecting the full match count. -class SearchApi { - SearchApi(this._dio); - final Dio _dio; - - /// Empty / whitespace-only query is the caller's responsibility to - /// guard against — the server returns 400 bad_request for it. - Future search( - String query, { - int limit = 20, - int offset = 0, - }) async { - final r = await _dio.get>( - '/api/search', - queryParameters: { - 'q': query, - 'limit': limit, - 'offset': offset, - }, - ); - return SearchResponse.fromJson(r.data ?? const {}); - } -} diff --git a/flutter_client/lib/api/endpoints/settings.dart b/flutter_client/lib/api/endpoints/settings.dart deleted file mode 100644 index 70cd1e57..00000000 --- a/flutter_client/lib/api/endpoints/settings.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../../models/my_profile.dart'; - -class SettingsApi { - SettingsApi(this._dio); - final Dio _dio; - - Future getProfile() async { - final r = await _dio.get>('/api/me'); - return MyProfile.fromJson(r.data ?? const {}); - } - - /// Pass only the fields you want to change; server merges. Empty - /// strings clear; null leaves the existing value alone. - Future updateProfile({String? displayName, String? email}) async { - final body = {}; - if (displayName != null) body['display_name'] = displayName; - if (email != null) body['email'] = email; - final r = await _dio.put>( - '/api/me/profile', - data: body, - ); - return MyProfile.fromJson(r.data ?? const {}); - } - - Future changePassword({ - required String current, - required String next, - }) async { - await _dio.put('/api/me/password', data: { - 'current_password': current, - 'new_password': next, - }); - } - - Future getListenBrainz() async { - final r = await _dio.get>('/api/me/listenbrainz'); - return ListenBrainzStatus.fromJson(r.data ?? const {}); - } - - Future setListenBrainzToken(String token) async { - final r = await _dio.put>( - '/api/me/listenbrainz', - data: {'token': token}, - ); - return ListenBrainzStatus.fromJson(r.data ?? const {}); - } - - Future setListenBrainzEnabled(bool enabled) async { - final r = await _dio.put>( - '/api/me/listenbrainz', - data: {'enabled': enabled}, - ); - return ListenBrainzStatus.fromJson(r.data ?? const {}); - } -} diff --git a/flutter_client/lib/api/error_copy.dart b/flutter_client/lib/api/error_copy.dart deleted file mode 100644 index 7ff65bba..00000000 --- a/flutter_client/lib/api/error_copy.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:convert'; -import 'package:flutter/services.dart' show rootBundle; - -class ErrorCopy { - ErrorCopy._(this._table); - final Map _table; - - static ErrorCopy? _instance; - - static Future load() async { - if (_instance != null) return _instance!; - final raw = await rootBundle.loadString('assets/error-copy.json'); - final m = (jsonDecode(raw) as Map).cast().map( - (k, v) => MapEntry(k, v.toString()), - ); - _instance = ErrorCopy._(m); - return _instance!; - } - - String forCode(String code) => - _table[code] ?? _table['unknown'] ?? 'Something went wrong.'; -} diff --git a/flutter_client/lib/api/errors.dart b/flutter_client/lib/api/errors.dart deleted file mode 100644 index 7283c350..00000000 --- a/flutter_client/lib/api/errors.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:dio/dio.dart'; - -class ApiError implements Exception { - ApiError({required this.code, required this.message, required this.status}); - - final String code; - final String message; - final int status; - - factory ApiError.fromDio(DioException e) { - if (e.type == DioExceptionType.connectionError || - e.type == DioExceptionType.connectionTimeout) { - return ApiError(code: 'connection_refused', message: 'Connection refused', status: 0); - } - final response = e.response; - final data = response?.data; - if (data is Map) { - final field = data['error']; - if (field is String) { - return ApiError(code: field, message: field, status: response?.statusCode ?? 0); - } - if (field is Map) { - final code = field['code']?.toString() ?? 'unknown'; - final msg = field['message']?.toString() ?? code; - return ApiError(code: code, message: msg, status: response?.statusCode ?? 0); - } - } - final status = response?.statusCode ?? 0; - if (status == 401) return ApiError(code: 'unauthenticated', message: 'unauthenticated', status: status); - if (status == 404) return ApiError(code: 'not_found', message: 'not_found', status: status); - return ApiError(code: 'unknown', message: e.message ?? 'unknown', status: status); - } -} diff --git a/flutter_client/lib/app.dart b/flutter_client/lib/app.dart deleted file mode 100644 index 40f4ce8c..00000000 --- a/flutter_client/lib/app.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:permission_handler/permission_handler.dart'; - -import 'cache/cache_filler.dart'; -import 'cache/metadata_prefetcher.dart'; -import 'cache/offline_provider.dart'; -import 'cache/mutation_queue.dart'; -import 'cache/prefetcher.dart'; -import 'cache/resume_controller.dart'; -import 'cache/sync_controller.dart'; -import 'player/play_events_reporter.dart'; -import 'player/playback_error_reporter.dart'; -import 'shared/live_events_dispatcher.dart'; -import 'shared/routing.dart'; -import 'theme/theme_data.dart'; -import 'theme/theme_mode_provider.dart'; - -class MinstrelApp extends ConsumerStatefulWidget { - const MinstrelApp({super.key}); - - @override - ConsumerState createState() => _MinstrelAppState(); -} - -class _MinstrelAppState extends ConsumerState { - @override - void initState() { - super.initState(); - // Activate offline-mode infrastructure once the first frame ships. - // SyncController.sync() is connectivity-aware (no-ops on no auth / - // no server URL), so calling it unconditionally is safe. - // Reading prefetcherProvider runs its constructor, which wires the - // queue + settings listeners. - WidgetsBinding.instance.addPostFrameCallback((_) { - // ignore: unawaited_futures - ref.read(syncControllerProvider.notifier).sync(); - ref.read(prefetcherProvider); - // Metadata prefetcher: when /api/home returns, fire background - // albumProvider/artistProvider reads for the top-N items in - // each section so subsequent taps are drift hits, not network - // round trips. - ref.read(metadataPrefetcherProvider); - // Live events (#392): subscribes to /api/events/stream and - // invalidates publicly-scoped providers when relevant events - // arrive. Also installs an AppLifecycleState observer for - // resume-time defensive invalidation. - ref.read(liveEventsDispatcherProvider); - // Cache filler: background sweeper that walks cached_artists - // / cached_albums for missing relations and fetches the - // per-entity detail so tapping an artist surfaces albums - // immediately (drift hit, no /api/artists/:id round-trip at - // tap time). First sweep 10s after launch; every 5 minutes - // thereafter. Throttled 200ms between requests so it never - // competes with user activity. - ref.read(cacheFillerProvider); - // Mutation replayer: drains the cached_mutations queue when - // connectivity comes back. Controllers (LikesController, - // MyQuarantineController, the add-to-playlist + request flows) - // enqueue on REST failure so user intent persists across - // network loss instead of getting rolled back. - ref.read(mutationReplayerProvider); - // Play-events reporter (#415): the Flutter client otherwise - // reports no plays at all — this feeds history, recommendation - // scoring, scrobbles, and system-playlist rotation, and is the - // path that carries the `source` tag for #415. - ref.read(playEventsReporterProvider); - // Resume-on-launch (#54): restores the last persisted session - // (paused) and persists queue/index/position on track change, - // pause, and app teardown. Pairs with the #52 idle teardown so a - // torn-down session is recoverable instead of lost. - ref.read(resumeControllerProvider); - // Playback-error reporter (#58): turns the handler's silent - // dead-track skips into a debounced/coalesced SnackBar so a - // vanished track isn't mysterious (and aids server/cache debug). - ref.read(playbackErrorReporterProvider); - // Offline marker (#427 S1): periodic /healthz reachability - // probe → offlineProvider. Read here to start the poller; S4 - // gates system-playlist play + Shuffle-all on it. - ref.read(offlineProvider); - // POST_NOTIFICATIONS (Android 13+) is denied-by-default until - // requested; without it the media notification is silently - // suppressed on physical devices. One-shot, post-first-frame so - // it never blocks launch; no-op on <13 / once already decided. - if (Platform.isAndroid) { - // ignore: unawaited_futures - Permission.notification.request(); - } - }); - } - - @override - Widget build(BuildContext context) { - final router = ref.watch(routerProvider); - final mode = ref.watch(themeModeProvider).value ?? AppThemeMode.system; - return MaterialApp.router( - title: 'Minstrel', - scaffoldMessengerKey: scaffoldMessengerKey, - theme: buildLightTheme(), - darkTheme: buildDarkTheme(), - themeMode: mode.materialMode, - routerConfig: router, - ); - } -} diff --git a/flutter_client/lib/auth/auth_provider.dart b/flutter_client/lib/auth/auth_provider.dart deleted file mode 100644 index 4ab61793..00000000 --- a/flutter_client/lib/auth/auth_provider.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_timezone/flutter_timezone.dart'; - -import '../api/endpoints/me.dart'; -import '../library/library_providers.dart' show dioProvider; -import '../models/user.dart'; - -const _kServerUrl = 'server_url'; -const _kSessionToken = 'session_token'; -const _kCurrentUser = 'current_user'; -const _kTzLastSentAt = 'tz_last_sent_at'; -const _weeklyMs = 7 * 24 * 60 * 60 * 1000; - -final secureStorageProvider = Provider( - (ref) => const FlutterSecureStorage(), -); - -final serverUrlProvider = FutureProvider((ref) async { - return ref.watch(secureStorageProvider).read(key: _kServerUrl); -}); - -final sessionTokenProvider = FutureProvider((ref) async { - return ref.watch(secureStorageProvider).read(key: _kSessionToken); -}); - -class AuthController extends AsyncNotifier { - late FlutterSecureStorage _storage; - - @override - Future build() async { - _storage = ref.watch(secureStorageProvider); - final raw = await _storage.read(key: _kCurrentUser); - if (raw == null) return null; - final user = User.fromJson(jsonDecode(raw) as Map); - // Fire-and-forget timezone send on app start with an existing - // session — no-op when last_sent_at is fresh. - // ignore: unawaited_futures - _sendTimezoneIfStale(); - return user; - } - - Future setServerUrl(String url) async { - await _storage.write(key: _kServerUrl, value: url); - ref.invalidate(serverUrlProvider); - } - - Future setSession({required String token, required String userJson}) async { - await _storage.write(key: _kSessionToken, value: token); - await _storage.write(key: _kCurrentUser, value: userJson); - ref.invalidate(sessionTokenProvider); - state = AsyncData(User.fromJson(jsonDecode(userJson) as Map)); - // ignore: unawaited_futures - _sendTimezoneIfStale(); - } - - Future clearSession() async { - await _storage.delete(key: _kSessionToken); - await _storage.delete(key: _kCurrentUser); - ref.invalidate(sessionTokenProvider); - state = const AsyncData(null); - } - - /// Sends the device's current IANA timezone to PUT /api/me/timezone - /// when the last successful send was >7 days ago (or never). - /// Cadence persists in flutter_secure_storage so it survives app - /// restarts. Failures are swallowed: the server keeps its previous - /// value (or 'UTC' default) until the next attempt. - Future _sendTimezoneIfStale() async { - try { - final lastStr = await _storage.read(key: _kTzLastSentAt); - final lastMs = lastStr == null ? 0 : int.tryParse(lastStr) ?? 0; - final nowMs = DateTime.now().millisecondsSinceEpoch; - if (nowMs - lastMs < _weeklyMs) return; - final tz = await FlutterTimezone.getLocalTimezone(); - if (tz.isEmpty) return; - final dio = await ref.read(dioProvider.future); - await MeApi(dio).putTimezone(tz); - await _storage.write(key: _kTzLastSentAt, value: nowMs.toString()); - } catch (_) { - // Non-fatal — server falls back to UTC or last-known value. - } - } -} - -final authControllerProvider = - AsyncNotifierProvider(AuthController.new); diff --git a/flutter_client/lib/auth/login_screen.dart b/flutter_client/lib/auth/login_screen.dart deleted file mode 100644 index c2fee1ed..00000000 --- a/flutter_client/lib/auth/login_screen.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../api/client.dart'; -import '../api/endpoints/auth.dart'; -import '../api/error_copy.dart'; -import '../api/errors.dart'; -import '../theme/theme_extension.dart'; -import 'auth_provider.dart'; - -class LoginScreen extends ConsumerStatefulWidget { - const LoginScreen({super.key}); - - @override - ConsumerState createState() => _LoginScreenState(); -} - -class _LoginScreenState extends ConsumerState { - final _user = TextEditingController(); - final _pass = TextEditingController(); - bool _busy = false; - String? _error; - - Future _submit() async { - setState(() { - _busy = true; - _error = null; - }); - try { - final url = await ref.read(serverUrlProvider.future); - if (url == null) { - if (!mounted) return; - context.go('/server-url'); - return; - } - final dio = ApiClient.buildDio( - baseUrl: url, - tokenResolver: () async => null, - ); - final res = await AuthApi(dio).login( - username: _user.text.trim(), - password: _pass.text, - ); - await ref.read(authControllerProvider.notifier).setSession( - token: res.token, - userJson: res.rawUserJson, - ); - if (!mounted) return; - context.go('/home'); - } on DioException catch (e) { - final code = ApiError.fromDio(e).code; - final copy = (await ErrorCopy.load()).forCode(code); - if (mounted) setState(() => _error = copy); - } finally { - if (mounted) setState(() => _busy = false); - } - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const SizedBox(height: 48), - Text('Sign in', style: TextStyle(fontFamily: fs.display.fontFamily, fontSize: 28)), - const SizedBox(height: 24), - TextField(controller: _user, decoration: const InputDecoration(labelText: 'Username')), - const SizedBox(height: 12), - TextField(controller: _pass, obscureText: true, decoration: const InputDecoration(labelText: 'Password')), - if (_error != null) Padding( - padding: const EdgeInsets.only(top: 8), - child: Text(_error!, style: TextStyle(color: fs.error)), - ), - const SizedBox(height: 16), - FilledButton( - onPressed: _busy ? null : _submit, - child: _busy - ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('Sign in'), - ), - TextButton( - onPressed: () => context.go('/server-url'), - child: const Text('Change server URL'), - ), - ]), - ), - ), - ); - } -} diff --git a/flutter_client/lib/auth/server_url_screen.dart b/flutter_client/lib/auth/server_url_screen.dart deleted file mode 100644 index 47d1d544..00000000 --- a/flutter_client/lib/auth/server_url_screen.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../api/endpoints/health.dart'; -import '../theme/theme_extension.dart'; -import 'auth_provider.dart'; - -class ServerUrlScreen extends ConsumerStatefulWidget { - const ServerUrlScreen({super.key}); - - @override - ConsumerState createState() => _ServerUrlScreenState(); -} - -class _ServerUrlScreenState extends ConsumerState { - final _ctrl = TextEditingController(); - bool _busy = false; - String? _error; - - Future _connect() async { - final url = _ctrl.text.trim(); - if (url.isEmpty) { - setState(() => _error = 'Enter a server URL.'); - return; - } - setState(() { - _busy = true; - _error = null; - }); - try { - final dio = Dio(BaseOptions(baseUrl: url, connectTimeout: const Duration(seconds: 5))); - final body = await HealthApi(dio).check(); - if (body['status'] != 'ok') { - throw StateError('unhealthy'); - } - await ref.read(authControllerProvider.notifier).setServerUrl(url); - if (!mounted) return; - context.go('/login'); - } on DioException catch (_) { - setState(() => _error = "Couldn't reach that server."); - } catch (_) { - setState(() => _error = "Couldn't reach that server."); - } finally { - if (mounted) setState(() => _busy = false); - } - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const SizedBox(height: 48), - Text('Connect to your Minstrel', style: TextStyle(fontFamily: fs.display.fontFamily, fontSize: 28)), - const SizedBox(height: 24), - TextField( - controller: _ctrl, - keyboardType: TextInputType.url, - decoration: const InputDecoration(labelText: 'Server URL', hintText: 'https://music.example.com'), - ), - if (_error != null) Padding( - padding: const EdgeInsets.only(top: 8), - child: Text(_error!, style: TextStyle(color: fs.error)), - ), - const SizedBox(height: 16), - FilledButton( - onPressed: _busy ? null : _connect, - child: _busy - ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('Connect'), - ), - ]), - ), - ), - ); - } -} diff --git a/flutter_client/lib/cache/adapters.dart b/flutter_client/lib/cache/adapters.dart deleted file mode 100644 index 11899469..00000000 --- a/flutter_client/lib/cache/adapters.dart +++ /dev/null @@ -1,133 +0,0 @@ -// Drift row → model adapters and reverse-write adapters for populating -// drift from REST responses (#357 plan C). -// -// The cache loses some server-derived fields: -// - ArtistRef.coverUrl (server computes from most-recent album) -// - AlbumRef.coverUrl (server-emitted derived path) -// - TrackRef.streamUrl (could be reconstructed but kept empty for clarity) -// UI already handles empty coverUrl/streamUrl gracefully. REST cold-cache -// fallback briefly shows the real values before drift takes over. - -import 'package:drift/drift.dart' as drift; - -import '../models/album.dart'; -import '../models/artist.dart'; -import '../models/playlist.dart'; -import '../models/track.dart'; -import 'db.dart'; - -extension CachedArtistAdapter on CachedArtist { - /// `coverAlbumId` lets the caller pass a representative album id so - /// the ArtistRef carries a reconstructed `/api/albums//cover` - /// URL. cached_artists doesn't store this directly — the server - /// derives it from the most-recent album at query time — so drift - /// readers that want the cover URL join cached_albums and pass the - /// first album's id through. Empty string yields empty coverUrl, - /// matching the server's behavior for endpoints that don't carry - /// the lookup (artist detail, search, raw cached_artists row reads). - ArtistRef toRef({String coverAlbumId = ''}) => ArtistRef( - id: id, - name: name, - sortName: sortName, - coverUrl: coverAlbumId.isNotEmpty - ? '/api/albums/$coverAlbumId/cover' - : '', - ); -} - -extension ArtistRefDriftWrite on ArtistRef { - CachedArtistsCompanion toDrift() => CachedArtistsCompanion.insert( - id: id, - name: name, - sortName: sortName.isNotEmpty ? sortName : name, - ); -} - -extension CachedAlbumAdapter on CachedAlbum { - /// `artistName` is supplied by the joined CachedArtists row at query time. - /// `coverUrl` is reconstructed deterministically from the album id — - /// the server emits the same shape (see internal/api/convert.go:69). - /// We don't need to persist it, so AlbumRef.coverUrl is non-empty - /// even when the row was populated from a sync that didn't carry the - /// derived URL. - AlbumRef toRef({String artistName = ''}) => AlbumRef( - id: id, - title: title, - sortTitle: sortTitle, - artistId: artistId, - artistName: artistName, - coverUrl: '/api/albums/$id/cover', - ); -} - -extension AlbumRefDriftWrite on AlbumRef { - CachedAlbumsCompanion toDrift() => CachedAlbumsCompanion.insert( - id: id, - artistId: artistId, - title: title, - sortTitle: sortTitle.isNotEmpty ? sortTitle : title, - ); -} - -extension CachedTrackAdapter on CachedTrack { - /// `artistName` and `albumTitle` come from joined rows. - TrackRef toRef({String artistName = '', String albumTitle = ''}) => TrackRef( - id: id, - title: title, - albumId: albumId, - albumTitle: albumTitle, - artistId: artistId, - artistName: artistName, - trackNumber: trackNumber, - discNumber: discNumber, - durationSec: durationMs ~/ 1000, - ); -} - -extension TrackRefDriftWrite on TrackRef { - CachedTracksCompanion toDrift() => CachedTracksCompanion.insert( - id: id, - albumId: albumId, - artistId: artistId, - title: title, - durationMs: drift.Value(durationSec * 1000), - trackNumber: drift.Value(trackNumber), - discNumber: drift.Value(discNumber), - ); -} - -extension CachedPlaylistAdapter on CachedPlaylist { - /// `ownerUsername` is server-derived; cache stores the userId only. - /// Pass empty unless a join supplies it. - /// `coverUrl` is reconstructed deterministically from the playlist - /// id — the server serves the cached collage at this path - /// (handleGetPlaylistCover). Mirrors the album-cover trick. When - /// the server hasn't built a collage yet (system playlists with no - /// tracks at build time), the endpoint 404s and PlaylistCard's - /// ServerImage falls back to its slate placeholder. - Playlist toRef({String ownerUsername = ''}) => Playlist( - id: id, - userId: userId, - name: name, - description: description, - isPublic: isPublic, - systemVariant: systemVariant, - trackCount: trackCount, - coverUrl: '/api/playlists/$id/cover', - ownerUsername: ownerUsername, - createdAt: '', - updatedAt: '', - ); -} - -extension PlaylistDriftWrite on Playlist { - CachedPlaylistsCompanion toDrift() => CachedPlaylistsCompanion.insert( - id: id, - userId: userId, - name: name, - description: drift.Value(description), - isPublic: drift.Value(isPublic), - trackCount: drift.Value(trackCount), - systemVariant: drift.Value(systemVariant), - ); -} diff --git a/flutter_client/lib/cache/audio_cache_manager.dart b/flutter_client/lib/cache/audio_cache_manager.dart deleted file mode 100644 index a26aa665..00000000 --- a/flutter_client/lib/cache/audio_cache_manager.dart +++ /dev/null @@ -1,296 +0,0 @@ -import 'dart:io'; - -import 'package:dio/dio.dart'; -import 'package:drift/drift.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:path_provider/path_provider.dart'; - -import '../library/library_providers.dart' show dioProvider; -import 'db.dart'; - -/// Per-bucket cache usage (#427 S2). Rolling includes orphan files — -/// partials written by LockCachingAudioSource that were never indexed -/// (skip-before-fully-buffered) — so the rolling cap actually bounds -/// disk. -typedef BucketUsage = ({int liked, int rolling}); - -/// Owns the audio cache directory + drift index. -/// -/// #427 S2: two storage buckets keyed by liked-ness, not by -/// CacheSource. A cached track currently in the user's liked set is -/// charged to (and evicted under) the Liked budget; everything else -/// is Rolling. This dedup is storage/eviction-only — it never filters -/// what the offline surfaces can play (S4). `CacheSource` is retained -/// on the API for callers but is now informational. -class AudioCacheManager { - AudioCacheManager({ - required AppDb db, - required Future Function() dioFactory, - Future Function()? cacheDirFactory, - }) : _db = db, - _dioFactory = dioFactory, - _cacheDirFactory = cacheDirFactory ?? getApplicationCacheDirectory; - - final AppDb _db; - final Future Function() _dioFactory; - final Future Function() _cacheDirFactory; - - Future _tracksDir() async { - final base = await _cacheDirFactory(); - final d = Directory('${base.path}/audio_cache'); - if (!await d.exists()) await d.create(recursive: true); - return d.path; - } - - Future isCached(String trackId) async { - final row = await (_db.select(_db.audioCacheIndex) - ..where((t) => t.trackId.equals(trackId))) - .getSingleOrNull(); - if (row == null) return false; - return File(row.path).existsSync(); - } - - Future pathFor(String trackId) async { - final row = await (_db.select(_db.audioCacheIndex) - ..where((t) => t.trackId.equals(trackId))) - .getSingleOrNull(); - if (row == null) return null; - return File(row.path).existsSync() ? row.path : null; - } - - /// Downloads the track's stream to disk and indexes it. Idempotent. - /// lastPlayedAt is set now — pinning is play-intent. - Future pin(String trackId, {required CacheSource source}) async { - final existing = await pathFor(trackId); - if (existing != null) { - await touch(trackId); - return existing; - } - final dir = await _tracksDir(); - final path = '$dir/$trackId.mp3'; - final dio = await _dioFactory(); - try { - await dio.download('/api/tracks/$trackId/stream', path); - } catch (_) { - final f = File(path); - if (f.existsSync()) await f.delete(); - return null; - } - final size = await File(path).length(); - await _db.into(_db.audioCacheIndex).insertOnConflictUpdate( - AudioCacheIndexCompanion.insert( - trackId: trackId, - path: path, - sizeBytes: size, - source: source, - lastPlayedAt: Value(DateTime.now()), - ), - ); - return path; - } - - /// Registers a file LockCachingAudioSource wrote itself. Called - /// once a track is fully buffered; it's being played, so stamp - /// lastPlayedAt. - Future registerStreamCache( - String trackId, - String path, - int sizeBytes, { - CacheSource source = CacheSource.incidental, - }) async { - await _db.into(_db.audioCacheIndex).insertOnConflictUpdate( - AudioCacheIndexCompanion.insert( - trackId: trackId, - path: path, - sizeBytes: sizeBytes, - source: source, - lastPlayedAt: Value(DateTime.now()), - ), - ); - } - - /// Bumps lastPlayedAt for an already-indexed track so the rolling - /// LRU + the offline "Recently played" view reflect real plays - /// (not just download time). No-op if not indexed. - Future touch(String trackId) async { - await (_db.update(_db.audioCacheIndex) - ..where((t) => t.trackId.equals(trackId))) - .write(AudioCacheIndexCompanion(lastPlayedAt: Value(DateTime.now()))); - } - - Future unpin(String trackId) async { - final row = await (_db.select(_db.audioCacheIndex) - ..where((t) => t.trackId.equals(trackId))) - .getSingleOrNull(); - if (row == null) return; - final f = File(row.path); - if (f.existsSync()) await f.delete(); - await (_db.delete(_db.audioCacheIndex) - ..where((t) => t.trackId.equals(trackId))) - .go(); - } - - /// Total bytes on disk (directory walk — authoritative; catches - /// orphan partials the index misses). - Future usageBytes() async { - final dir = Directory(await _tracksDir()); - if (!await dir.exists()) return 0; - var total = 0; - await for (final e in dir.list(followLinks: false)) { - if (e is File) { - try { - total += await e.length(); - } catch (_) {} - } - } - return total; - } - - /// Bytes per bucket. Indexed rows split by liked-ness; on-disk - /// files with no index row (orphan partials) count as Rolling so - /// the rolling cap genuinely bounds disk. - Future bucketUsage(Set liked) async { - final rows = await _db.select(_db.audioCacheIndex).get(); - final indexed = {}; - var likedB = 0; - var rollingB = 0; - for (final r in rows) { - indexed.add(r.trackId); - if (liked.contains(r.trackId)) { - likedB += r.sizeBytes; - } else { - rollingB += r.sizeBytes; - } - } - final dir = Directory(await _tracksDir()); - if (await dir.exists()) { - await for (final e in dir.list(followLinks: false)) { - if (e is! File) continue; - final name = e.uri.pathSegments.last; - final id = name.endsWith('.mp3') - ? name.substring(0, name.length - 4) - : name; - if (indexed.contains(id)) continue; - try { - rollingB += await e.length(); - } catch (_) {} - } - } - return (liked: likedB, rolling: rollingB); - } - - /// Enforces both budgets independently (0 = unlimited). - /// - /// Rolling: evict non-liked indexed rows LRU (oldest lastPlayedAt - /// /cachedAt first), then sweep orphan files oldest-by-mtime, until - /// rolling usage ≤ rollingCap. Liked: evict liked indexed rows LRU - /// until ≤ likedCap. Liked is only ever touched by its own (large) - /// cap, so normal use never evicts the user's liked library. - Future evictBuckets({ - required int likedCap, - required int rollingCap, - required Set liked, - }) async { - final usage = await bucketUsage(liked); - - if (rollingCap > 0 && usage.rolling > rollingCap) { - var over = usage.rolling - rollingCap; - final rolling = await (_db.select(_db.audioCacheIndex) - ..where((t) => t.trackId.isNotIn(liked.toList())) - ..orderBy([ - (t) => OrderingTerm.asc(t.lastPlayedAt), - (t) => OrderingTerm.asc(t.cachedAt), - ])) - .get(); - for (final r in rolling) { - if (over <= 0) break; - final f = File(r.path); - if (f.existsSync()) await f.delete(); - await (_db.delete(_db.audioCacheIndex) - ..where((t) => t.trackId.equals(r.trackId))) - .go(); - over -= r.sizeBytes; - } - if (over > 0) await _sweepOrphans(over); - } - - if (likedCap > 0 && usage.liked > likedCap) { - var over = usage.liked - likedCap; - final likedRows = await (_db.select(_db.audioCacheIndex) - ..where((t) => t.trackId.isIn(liked.toList())) - ..orderBy([ - (t) => OrderingTerm.asc(t.lastPlayedAt), - (t) => OrderingTerm.asc(t.cachedAt), - ])) - .get(); - for (final r in likedRows) { - if (over <= 0) break; - final f = File(r.path); - if (f.existsSync()) await f.delete(); - await (_db.delete(_db.audioCacheIndex) - ..where((t) => t.trackId.equals(r.trackId))) - .go(); - over -= r.sizeBytes; - } - } - } - - /// Deletes orphan files (on disk, no index row) oldest-mtime-first - /// until `over` bytes are reclaimed. These are unindexed partials, - /// always Rolling, always evict-first. - Future _sweepOrphans(int over) async { - final indexed = { - for (final r in await _db.select(_db.audioCacheIndex).get()) r.trackId - }; - final dir = Directory(await _tracksDir()); - if (!await dir.exists()) return; - final orphans = <({File f, int size, DateTime mtime})>[]; - await for (final e in dir.list(followLinks: false)) { - if (e is! File) continue; - final name = e.uri.pathSegments.last; - final id = - name.endsWith('.mp3') ? name.substring(0, name.length - 4) : name; - if (indexed.contains(id)) continue; - try { - final st = e.statSync(); - orphans.add((f: e, size: st.size, mtime: st.modified)); - } catch (_) {} - } - orphans.sort((a, b) => a.mtime.compareTo(b.mtime)); - var remaining = over; - for (final o in orphans) { - if (remaining <= 0) break; - try { - await o.f.delete(); - remaining -= o.size; - } catch (_) {} - } - } - - /// Clears EVERY row + EVERY file. Wired to "Clear cache". - Future clearAll() async { - final dir = await _tracksDir(); - final d = Directory(dir); - if (d.existsSync()) { - for (final e in d.listSync()) { - if (e is File) await e.delete(); - } - } - await _db.delete(_db.audioCacheIndex).go(); - } -} - -/// AppDb singleton. One per app run; ref.onDispose closes it. -final appDbProvider = Provider((ref) { - final db = AppDb(); - ref.onDispose(db.close); - return db; -}); - -final audioCacheManagerProvider = Provider((ref) { - final db = ref.watch(appDbProvider); - return AudioCacheManager( - db: db, - dioFactory: () async => ref.read(dioProvider.future), - ); -}); diff --git a/flutter_client/lib/cache/cache_filler.dart b/flutter_client/lib/cache/cache_filler.dart deleted file mode 100644 index 84f060d1..00000000 --- a/flutter_client/lib/cache/cache_filler.dart +++ /dev/null @@ -1,235 +0,0 @@ -// Background metadata sweeper that walks the drift cache for missing -// relations and fills them via /api/artists/:id + /api/albums/:id. -// Cover bytes for newly-filled albums are pre-warmed too so the -// per-tile display path doesn't have to wait on network. -// -// Why this layer on top of SyncController and HydrationQueue: -// - SyncController.sync only ingests entities the server emitted -// into the library_changes delta for this user. For a fresh -// install, that's everything; for an existing user, only recent -// changes. The per-artist album list and per-album track list -// are NOT in those deltas — they're derived at query time. -// - HydrationQueue fills these lazily on tile render. CacheFiller -// fills them proactively on a slow schedule so tapping an artist -// surfaces albums immediately instead of triggering a fresh -// /api/artists/:id at tap time. -// -// Pacing is conservative — 200ms between requests, max 200 entities -// per sweep, 5-minute interval. Designed to never compete with user -// activity. Wall time on a 1000-artist library: ~3-4 minutes spread -// over multiple sweeps. - -import 'dart:async'; - -import 'package:drift/drift.dart' show Variable; -import 'package:flutter/foundation.dart' show debugPrint; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../auth/auth_provider.dart' - show serverUrlProvider, sessionTokenProvider; -import '../cache/adapters.dart'; -import '../cache/audio_cache_manager.dart' show appDbProvider; -import '../cache/connectivity_provider.dart'; -import '../library/library_providers.dart' show libraryApiProvider; - -class CacheFiller { - CacheFiller(this._ref); - final Ref _ref; - - Timer? _initialTimer; - Timer? _intervalTimer; - bool _running = false; - bool _disposed = false; - - /// Delay between launch and the first sweep. Long enough for - /// SyncController to land its initial /api/library/sync so the - /// CacheFiller's "unfilled relations" query has meaningful work. - static const _initialDelay = Duration(seconds: 10); - - /// Cadence between sweeps. Once a sweep finds nothing to do (steady - /// state) the interval becomes a cheap drift query + early exit. - static const _interval = Duration(minutes: 5); - - /// Throttle between per-entity REST requests so the filler doesn't - /// saturate the server or compete with user-initiated playback. - static const _requestThrottle = Duration(milliseconds: 200); - - /// Per-sweep cap. Without this, a fresh install with thousands of - /// artists would tie up the network for many minutes in one go. - /// The next sweep continues where this one left off (the WHERE - /// NOT EXISTS query naturally skips already-filled rows). - static const _maxIdsPerSweep = 200; - - void start() { - _initialTimer = Timer(_initialDelay, _sweep); - _intervalTimer = Timer.periodic(_interval, (_) => _sweep()); - } - - Future _sweep() async { - if (_disposed || _running) return; - final online = await _ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true); - if (!online) return; - _running = true; - try { - await _fillArtists(); - if (_disposed) return; - await _fillAlbums(); - } catch (e, st) { - debugPrint('cache_filler: sweep failed: $e\n$st'); - } finally { - _running = false; - } - } - - /// Find artists with no albums in cache and fetch /api/artists/:id - /// for each (single round-trip yields artist + albums via the new - /// getArtistDetail API method). Newly-discovered album covers - /// land in flutter_cache_manager's disk cache too so the next - /// visit to the artist's albums grid paints from disk. - Future _fillArtists() async { - final db = _ref.read(appDbProvider); - final rows = await db.customSelect( - ''' - SELECT a.id FROM cached_artists a - WHERE NOT EXISTS ( - SELECT 1 FROM cached_albums b WHERE b.artist_id = a.id - ) - LIMIT ? - ''', - variables: [Variable.withInt(_maxIdsPerSweep)], - ).get(); - final ids = rows.map((r) => r.read('id')).toList(); - if (ids.isEmpty) return; - - final api = await _ref.read(libraryApiProvider.future); - final newAlbumIds = []; - - for (final id in ids) { - if (_disposed) return; - try { - final detail = await api.getArtistDetail(id); - await db.transaction(() async { - await db - .into(db.cachedArtists) - .insertOnConflictUpdate(detail.artist.toDrift()); - if (detail.albums.isNotEmpty) { - await db.batch((b) { - b.insertAllOnConflictUpdate( - db.cachedAlbums, - detail.albums.map((a) => a.toDrift()).toList(), - ); - }); - newAlbumIds.addAll(detail.albums.map((a) => a.id)); - } - }); - } catch (e) { - debugPrint('cache_filler: fillArtist($id) failed: $e'); - } - await Future.delayed(_requestThrottle); - } - - if (newAlbumIds.isNotEmpty) { - await _prewarmCovers(newAlbumIds); - } - } - - /// Find albums with no tracks in cache and fetch /api/albums/:id - /// for each. The bulk response carries the album's track list which - /// we persist into cached_tracks for instant album detail render - /// on the next visit. - Future _fillAlbums() async { - final db = _ref.read(appDbProvider); - final rows = await db.customSelect( - ''' - SELECT a.id FROM cached_albums a - WHERE NOT EXISTS ( - SELECT 1 FROM cached_tracks t WHERE t.album_id = a.id - ) - LIMIT ? - ''', - variables: [Variable.withInt(_maxIdsPerSweep)], - ).get(); - final ids = rows.map((r) => r.read('id')).toList(); - if (ids.isEmpty) return; - - final api = await _ref.read(libraryApiProvider.future); - for (final id in ids) { - if (_disposed) return; - try { - final result = await api.getAlbum(id); - if (result.tracks.isNotEmpty) { - await db.batch((b) { - b.insertAllOnConflictUpdate( - db.cachedTracks, - result.tracks.map((t) => t.toDrift()).toList(), - ); - }); - } - } catch (e) { - debugPrint('cache_filler: fillAlbum($id) failed: $e'); - } - await Future.delayed(_requestThrottle); - } - } - - /// Pre-warm album cover bytes via flutter_cache_manager so the - /// per-tile ServerImage / CachedNetworkImage paints from disk on - /// the user's first visit. Mirrors SyncController's prewarm - /// helper — duplicated here intentionally to keep this file self- - /// contained; a shared helper can come out of #357 follow-up if - /// the duplication grows. - Future _prewarmCovers(List albumIds) async { - final baseUrl = await _ref.read(serverUrlProvider.future); - if (baseUrl == null || baseUrl.isEmpty) return; - final token = await _ref.read(sessionTokenProvider.future); - final headers = (token != null && token.isNotEmpty) - ? {'Authorization': 'Bearer $token'} - : {}; - final base = baseUrl.endsWith('/') - ? baseUrl.substring(0, baseUrl.length - 1) - : baseUrl; - final mgr = DefaultCacheManager(); - - var idx = 0; - Future worker() async { - while (idx < albumIds.length) { - if (_disposed) return; - final i = idx++; - try { - await mgr.downloadFile( - '$base/api/albums/${albumIds[i]}/cover', - authHeaders: headers, - ); - } catch (_) { - // Best-effort prewarm — 404 (missing collage) and 401 - // (token refresh races) shouldn't abort the rest. - } - } - } - - // Concurrency 3 — same as SyncController's prewarm. Covers are - // small enough that more parallelism doesn't help much and - // burns radio. - await Future.wait(List.generate(3, (_) => worker())); - } - - void dispose() { - _disposed = true; - _initialTimer?.cancel(); - _intervalTimer?.cancel(); - } -} - -/// Read once at app start (from app.dart's postFrameCallback) to -/// activate the filler. Disposed via ref.onDispose when the provider -/// scope tears down; in practice the scope lives for the app's -/// lifetime so dispose only fires on uninstall / process death. -final cacheFillerProvider = Provider((ref) { - final filler = CacheFiller(ref); - ref.onDispose(filler.dispose); - filler.start(); - return filler; -}); diff --git a/flutter_client/lib/cache/cache_first.dart b/flutter_client/lib/cache/cache_first.dart deleted file mode 100644 index 274b285a..00000000 --- a/flutter_client/lib/cache/cache_first.dart +++ /dev/null @@ -1,115 +0,0 @@ -// Drift-first reactive read pattern with REST cold-cache fallback (#357 plan C). -// -// Subscribes to a drift watch() stream. On each emission: -// - non-empty → map to result type T and yield -// - empty + online → fetch via REST, populate drift, await re-emission -// - empty + offline → yield mapped empty result (UI shows empty state) -// -// With `alwaysRefresh: true`, also kicks off a one-shot REST refresh -// in the background after the first non-empty emission. Use for -// aggregate lists (playlists, etc.) where the server may have rows -// the local sync didn't pick up — yields cache immediately, refreshes -// silently, and drift watch() re-emits with whatever new rows landed. -// -// The pattern lets every read provider trust drift as the source of -// truth. SyncController keeps drift fresh in the background; widget -// rebuilds happen automatically as drift writes propagate via watch(). - -import 'dart:async'; - -import 'package:flutter/foundation.dart' show debugPrint; - -// `tag` parameter is preserved for future ad-hoc instrumentation. -// Normal operation only logs failure paths so the per-screen log -// noise stays low. - -/// Wraps the watch + cold-cache fallback pattern. Generic over: -/// D — the drift row type (or TypedResult for joins) -/// T — the result type the caller wants (e.g. `List`) -/// -/// `fetchAndPopulate` is invoked when drift is empty AND `isOnline()` -/// returns true. It must populate drift via its own side-effect; the -/// drift watch() stream will re-emit and this helper yields the -/// populated rows on the next iteration. -/// -/// REST failures are swallowed — the helper falls through to yielding -/// the empty result. Caller is responsible for surfacing errors via -/// toast etc. -Stream cacheFirst({ - required Stream> driftStream, - required Future Function() fetchAndPopulate, - required T Function(List) toResult, - required Future Function() isOnline, - bool alwaysRefresh = false, - String? tag, -}) async* { - // Tracks whether we've already kicked off a stale-while-revalidate - // refresh for this stream subscription, so we don't fire one on every - // drift re-emission (otherwise the populate cycles forever). - var revalidated = false; - - // Tracks whether we've already tried a cold-cache fetch on this - // subscription. Without this, providers can spin forever in the - // rows-empty branch when fetchAndPopulate writes rows that don't - // match THIS filter — e.g. three liked-tab streams sharing one - // populate: the track populate writes track-typed rows, drift - // watch fires for the cached_likes table, the album stream - // re-emits with rows-empty (no album likes), the rows-empty - // branch re-fires populate, repeats forever. Setting this guard - // makes the first fetch attempt also the last for any given - // subscription. - var coldFetchAttempted = false; - - await for (final rows in driftStream) { - if (rows.isNotEmpty) { - yield toResult(rows); - coldFetchAttempted = true; - // Stale-while-revalidate: yield cache immediately, then kick off - // a REST refresh in the background. Drift watch() picks up the - // resulting writes and re-emits via this same stream loop. - // Useful for aggregate lists (e.g. playlists) where the server - // may have rows the local sync didn't pick up. - if (alwaysRefresh && !revalidated && await isOnline()) { - revalidated = true; - unawaited(_safeFetch(fetchAndPopulate)); - } - continue; - } - // rows is empty. If we've already attempted a cold fetch and - // drift is still empty for this filter, yield empty so the UI - // shows the no-content state instead of spinning forever. - if (coldFetchAttempted) { - yield toResult(rows); - continue; - } - coldFetchAttempted = true; - if (await isOnline()) { - try { - await fetchAndPopulate(); - // Yield the current (still-empty) rows so the UI moves past - // loading even if populate was a no-op for this filter - // (server returned nothing matching, or all rows were - // already in drift via sync). If populate DID write rows - // matching this filter, the drift watch's next emission - // yields them via the rows.isNotEmpty branch — UI briefly - // shows empty then populates, instead of spinning. - yield toResult(rows); - } catch (e, st) { - if (tag != null) { - debugPrint('cacheFirst[$tag]: fetchAndPopulate failed: $e\n$st'); - } - yield toResult(rows); // empty result; caller surfaces error - } - } else { - yield toResult(rows); // empty result; offline - } - } -} - -Future _safeFetch(Future Function() fn) async { - try { - await fn(); - } catch (_) { - // Background revalidate — swallow; UI already showed cached state. - } -} diff --git a/flutter_client/lib/cache/cache_settings_provider.dart b/flutter_client/lib/cache/cache_settings_provider.dart deleted file mode 100644 index 517ab195..00000000 --- a/flutter_client/lib/cache/cache_settings_provider.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; - -import '../auth/auth_provider.dart' show secureStorageProvider; - -/// Operator-tunable cache settings. Persisted via -/// flutter_secure_storage on the same device. -/// -/// #427 S2: the single `capBytes` is replaced by two independent -/// budgets — Liked and Rolling-recent — each defaulting to 5GB. -/// Bucketing is by liked-ness (a cached track currently in the -/// user's liked set is charged to Liked; everything else to -/// Rolling), so the dedup is storage-only and never filters -/// playback. The old `cache_cap_bytes` key is intentionally not -/// migrated; defaults reapply (a one-time re-tune, not data loss). -class CacheSettings { - const CacheSettings({ - required this.likedCapBytes, - required this.rollingCapBytes, - required this.prefetchWindow, - required this.cacheLikedTracks, - }); - - /// Liked-bucket budget. 0 = unlimited. - final int likedCapBytes; - - /// Rolling (recently-played) budget. 0 = unlimited. - final int rollingCapBytes; - - /// 1..10. Number of next-tracks the prefetcher pre-downloads. - final int prefetchWindow; - - /// When true, every like_track event triggers a pin with source autoLiked. - final bool cacheLikedTracks; - - CacheSettings copyWith({ - int? likedCapBytes, - int? rollingCapBytes, - int? prefetchWindow, - bool? cacheLikedTracks, - }) => - CacheSettings( - likedCapBytes: likedCapBytes ?? this.likedCapBytes, - rollingCapBytes: rollingCapBytes ?? this.rollingCapBytes, - prefetchWindow: prefetchWindow ?? this.prefetchWindow, - cacheLikedTracks: cacheLikedTracks ?? this.cacheLikedTracks, - ); - - static const _fiveGiB = 5 * 1024 * 1024 * 1024; - - static const defaults = CacheSettings( - likedCapBytes: _fiveGiB, - rollingCapBytes: _fiveGiB, - prefetchWindow: 5, - cacheLikedTracks: true, - ); -} - -class CacheSettingsController extends AsyncNotifier { - static const _kLikedCap = 'cache_liked_cap_bytes'; - static const _kRollingCap = 'cache_rolling_cap_bytes'; - static const _kPrefetch = 'cache_prefetch_window'; - static const _kCacheLiked = 'cache_liked_tracks'; - - late FlutterSecureStorage _storage; - - @override - Future build() async { - _storage = ref.read(secureStorageProvider); - final likedCap = await _storage.read(key: _kLikedCap); - final rollingCap = await _storage.read(key: _kRollingCap); - final pre = await _storage.read(key: _kPrefetch); - final liked = await _storage.read(key: _kCacheLiked); - return CacheSettings( - likedCapBytes: likedCap == null - ? CacheSettings.defaults.likedCapBytes - : int.tryParse(likedCap) ?? CacheSettings.defaults.likedCapBytes, - rollingCapBytes: rollingCap == null - ? CacheSettings.defaults.rollingCapBytes - : int.tryParse(rollingCap) ?? CacheSettings.defaults.rollingCapBytes, - prefetchWindow: pre == null - ? CacheSettings.defaults.prefetchWindow - : (int.tryParse(pre) ?? 5).clamp(1, 10), - cacheLikedTracks: liked == null - ? CacheSettings.defaults.cacheLikedTracks - : liked == 'true', - ); - } - - Future setLikedCapBytes(int bytes) async { - await _storage.write(key: _kLikedCap, value: bytes.toString()); - state = AsyncData(state.value!.copyWith(likedCapBytes: bytes)); - } - - Future setRollingCapBytes(int bytes) async { - await _storage.write(key: _kRollingCap, value: bytes.toString()); - state = AsyncData(state.value!.copyWith(rollingCapBytes: bytes)); - } - - Future setPrefetchWindow(int n) async { - final clamped = n.clamp(1, 10); - await _storage.write(key: _kPrefetch, value: clamped.toString()); - state = AsyncData(state.value!.copyWith(prefetchWindow: clamped)); - } - - Future setCacheLikedTracks(bool on) async { - await _storage.write(key: _kCacheLiked, value: on.toString()); - state = AsyncData(state.value!.copyWith(cacheLikedTracks: on)); - } -} - -final cacheSettingsProvider = - AsyncNotifierProvider( - CacheSettingsController.new); diff --git a/flutter_client/lib/cache/connectivity_provider.dart b/flutter_client/lib/cache/connectivity_provider.dart deleted file mode 100644 index 06fa3e6c..00000000 --- a/flutter_client/lib/cache/connectivity_provider.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:connectivity_plus/connectivity_plus.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -/// Online if at least one connectivity result is non-none. -/// connectivity_plus reports the union (wifi, mobile, vpn, etc.) — operator -/// chose "no Wi-Fi gate" for #357, so any connection means "go ahead and -/// pull/cache". -/// -/// IMPORTANT: onConnectivityChanged only emits on *changes*, not on -/// subscription. Without seeding an initial value via checkConnectivity(), -/// any consumer using `ref.read(connectivityProvider.future)` would -/// block until the OS happened to report a connectivity flip — which -/// is exactly what made album/artist/playlist detail screens spin -/// forever for tiles tapped before the first event landed. -final connectivityProvider = StreamProvider((ref) async* { - final c = Connectivity(); - bool isOnline(List rs) => - rs.any((r) => r != ConnectivityResult.none); - - // checkConnectivity() goes over a platform channel and on some - // Android builds it can stall. Fall back to "assume online" after - // 2s so consumers waiting on .future never block forever — being - // wrong about connectivity costs at most one failed request, but - // being stuck spins the UI indefinitely. - try { - final initial = await c.checkConnectivity().timeout( - const Duration(seconds: 2), - onTimeout: () => const [ConnectivityResult.wifi], - ); - yield isOnline(initial); - } catch (_) { - yield true; - } - yield* c.onConnectivityChanged.map(isOnline); -}); diff --git a/flutter_client/lib/cache/db.dart b/flutter_client/lib/cache/db.dart deleted file mode 100644 index 82da7178..00000000 --- a/flutter_client/lib/cache/db.dart +++ /dev/null @@ -1,343 +0,0 @@ -// Drift database for Minstrel's offline cache (#357 plan B). -// -// Two cache layers in one database: -// - Metadata cache (CachedArtists, CachedAlbums, CachedTracks, -// CachedLikes, CachedPlaylists, CachedPlaylistTracks) — populated -// by SyncController via /api/library/sync -// - Audio cache index (AudioCacheIndex) — owned by AudioCacheManager -// -// Plus SyncMetadata holding the latest sync cursor. -// -// All entity ids are TEXT (server-side UUIDs serialized as strings). -// build_runner generates db.g.dart from this file; it's gitignored and -// regenerated in CI. - -import 'package:drift/drift.dart'; -import 'package:drift_flutter/drift_flutter.dart'; - -part 'db.g.dart'; - -class CachedArtists extends Table { - TextColumn get id => text()(); - TextColumn get name => text()(); - TextColumn get sortName => text()(); - TextColumn get mbid => text().nullable()(); - TextColumn get artistThumbPath => text().nullable()(); - TextColumn get artistFanartPath => text().nullable()(); - DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)(); - @override - Set get primaryKey => {id}; -} - -class CachedAlbums extends Table { - TextColumn get id => text()(); - TextColumn get artistId => text()(); - TextColumn get title => text()(); - TextColumn get sortTitle => text()(); - TextColumn get releaseDate => text().nullable()(); - TextColumn get coverPath => text().nullable()(); - TextColumn get mbid => text().nullable()(); - DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)(); - @override - Set get primaryKey => {id}; -} - -class CachedTracks extends Table { - TextColumn get id => text()(); - TextColumn get albumId => text()(); - TextColumn get artistId => text()(); - TextColumn get title => text()(); - IntColumn get durationMs => integer().withDefault(const Constant(0))(); - IntColumn get trackNumber => integer().nullable()(); - IntColumn get discNumber => integer().nullable()(); - TextColumn get filePath => text().nullable()(); - TextColumn get fileFormat => text().nullable()(); - TextColumn get genre => text().nullable()(); - DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)(); - @override - Set get primaryKey => {id}; -} - -class CachedLikes extends Table { - TextColumn get userId => text()(); - TextColumn get entityType => text()(); // 'track' | 'album' | 'artist' - TextColumn get entityId => text()(); - DateTimeColumn get likedAt => dateTime().withDefault(currentDateAndTime)(); - @override - Set get primaryKey => {userId, entityType, entityId}; -} - -class CachedPlaylists extends Table { - TextColumn get id => text()(); - TextColumn get userId => text()(); - TextColumn get name => text()(); - TextColumn get description => text().withDefault(const Constant(''))(); - BoolColumn get isPublic => boolean().withDefault(const Constant(false))(); - TextColumn get coverPath => text().nullable()(); - IntColumn get trackCount => integer().withDefault(const Constant(0))(); - IntColumn get durationSec => integer().withDefault(const Constant(0))(); - /// Server's system_variant: null for user playlists, "for_you" / - /// "songs_like_artist" / "discover" for system-generated mixes. - /// Added in schemaVersion 2 to let the add-to-playlist sheet filter - /// out system playlists locally without a REST round-trip. - TextColumn get systemVariant => text().nullable()(); - DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)(); - @override - Set get primaryKey => {id}; -} - -class CachedPlaylistTracks extends Table { - TextColumn get playlistId => text()(); - TextColumn get trackId => text()(); - IntColumn get position => integer().withDefault(const Constant(0))(); - @override - Set get primaryKey => {playlistId, trackId}; -} - -/// One row per fully-downloaded audio file. `source` drives tiered LRU -/// eviction; `incidental` evicts first, `manual` last. -class AudioCacheIndex extends Table { - TextColumn get trackId => text()(); - TextColumn get path => text()(); - IntColumn get sizeBytes => integer()(); - DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)(); - /// When the track was last played. Drives the offline "Recently - /// played" ordering and rolling-bucket LRU eviction. Distinct from - /// cachedAt (download time). Nullable for pre-schema-9 rows until - /// the next play touches them (migration backfills to cachedAt). - DateTimeColumn get lastPlayedAt => dateTime().nullable()(); - TextColumn get source => textEnum()(); - @override - Set get primaryKey => {trackId}; -} - -/// Single-row table holding the latest sync cursor. -class SyncMetadata extends Table { - IntColumn get id => integer().withDefault(const Constant(1))(); - IntColumn get cursor => integer().withDefault(const Constant(0))(); - DateTimeColumn get lastSyncAt => dateTime().nullable()(); - @override - Set get primaryKey => {id}; -} - -/// Single-row cache of the /api/me/history response. Mirrors the -/// CachedHomeSnapshot pattern: opening the History tab in the Library -/// screen yields the last-known page immediately while a fresh REST -/// pull lands underneath via SWR. Also makes basic offline scrollback -/// possible — the last fetched page survives both app restart and -/// loss of connectivity. Schema 4+. -class CachedHistorySnapshot extends Table { - IntColumn get id => integer().withDefault(const Constant(1))(); - TextColumn get json => text()(); - DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); - @override - Set get primaryKey => {id}; -} - -/// Single-row cache of the /api/me/system-playlists-status response. -/// The home Playlists row reads this every render to pick between -/// real cards and placeholder cards for For-You / Discover / Songs- -/// like slots, so a fresh-mount cold fetch produces a visible -/// "building / pending / failed" flicker. Storing the last result -/// as a JSON blob means the home renders with the prior status -/// instantly, then SWR refreshes underneath. Schema 7+. -class CachedSystemPlaylistsStatus extends Table { - IntColumn get id => integer().withDefault(const Constant(1))(); - TextColumn get json => text()(); - DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); - @override - Set get primaryKey => {id}; -} - -/// Section→position→entity-id index for the home screen, populated -/// from the per-item discovery endpoint `/api/home/index`. Each row -/// pins one tile slot to an entity; the actual entity data lives in -/// cached_albums / cached_artists / cached_tracks. The home screen -/// reads this table to know the layout, then hydrates each tile -/// against the entity tables (per-item rendering). Schema 6+. -class CachedHomeIndex extends Table { - /// One of: 'recently_added_albums', 'rediscover_albums', - /// 'rediscover_artists', 'most_played_tracks', 'last_played_artists'. - /// Mirrors the keys /api/home and /api/home/index emit. - TextColumn get section => text()(); - IntColumn get position => integer()(); - /// One of: 'album', 'artist', 'track'. Used to dispatch hydration - /// to the right per-entity endpoint. - TextColumn get entityType => text()(); - TextColumn get entityId => text()(); - DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)(); - @override - Set get primaryKey => {section, position}; -} - -/// Outbound mutation queue for offline-resilient REST calls. Likes, -/// quarantine flag/unflag, playlist appendTracks, Lidarr request -/// create/cancel all enqueue here on REST failure so the user's -/// intent persists across network loss. MutationReplayer drains on -/// connectivity transitions and a 1-minute periodic tick; entries -/// with attempts >= 5 are skipped (the server treats them as -/// permanent failures and library_changes sync will correct any -/// resulting drift drift on next sync). Schema 8+. -class CachedMutations extends Table { - IntColumn get id => integer().autoIncrement()(); - /// Stable kind string (see mutation_queue.dart constants). The - /// replayer looks up the handler in a kind→Function map; unknown - /// kinds are dropped to avoid getting stuck. - TextColumn get kind => text()(); - /// JSON-encoded payload — args needed to replay the REST call. - /// Shape depends on kind; see mutation_queue.dart. - TextColumn get payload => text()(); - DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); - /// When the last drain attempt fired against this row. Null until - /// the first attempt. Useful for diagnostic queries. - DateTimeColumn get lastAttemptAt => dateTime().nullable()(); - IntColumn get attempts => integer().withDefault(const Constant(0))(); -} - -/// Caller-scoped quarantine ("hidden") rows. Mirrors the wire shape of -/// /api/quarantine/mine — fully denormalized (track + album + artist -/// fields inline) because the Hidden tab renders straight from this row -/// without joining other tables. Columnar (vs JSON blob) so flag/unflag -/// can do row-level INSERT/DELETE; the drift watch() emission feeds -/// MyQuarantineController state directly. Schema 5+. -class CachedQuarantineMine extends Table { - TextColumn get trackId => text()(); - TextColumn get reason => text()(); - TextColumn get notes => text().nullable()(); - TextColumn get createdAt => text()(); - TextColumn get trackTitle => text()(); - IntColumn get trackDurationMs => integer().withDefault(const Constant(0))(); - TextColumn get albumId => text()(); - TextColumn get albumTitle => text()(); - TextColumn get albumCoverArtPath => text().nullable()(); - TextColumn get artistId => text()(); - TextColumn get artistName => text()(); - DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)(); - @override - Set get primaryKey => {trackId}; -} - -/// Single-row snapshot of the last playback session — queue (TrackRef -/// JSON), current index, position, and #415 source. Lets a torn-down -/// session (the #52 idle/dismissed teardown) resume on next launch; -/// without it the headset / lock-screen play button has nothing to -/// resume. Mirrors the CachedHomeSnapshot single-row JSON pattern. -/// Schema 10+. -class CachedResumeState extends Table { - IntColumn get id => integer().withDefault(const Constant(1))(); - TextColumn get json => text()(); - DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); - @override - Set get primaryKey => {id}; -} - -enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental } - -@DriftDatabase(tables: [ - CachedArtists, - CachedAlbums, - CachedTracks, - CachedLikes, - CachedPlaylists, - CachedPlaylistTracks, - AudioCacheIndex, - SyncMetadata, - CachedHistorySnapshot, - CachedQuarantineMine, - CachedHomeIndex, - CachedSystemPlaylistsStatus, - CachedMutations, - CachedResumeState, -]) -class AppDb extends _$AppDb { - AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache')); - - @override - int get schemaVersion => 11; - - @override - MigrationStrategy get migration => MigrationStrategy( - onCreate: (m) => m.createAll(), - onUpgrade: (m, from, to) async { - if (from < 2) { - // Schema 2: add CachedPlaylists.systemVariant. Existing - // rows get null and the next sync rebuilds them with the - // server's system_variant value. - await m.addColumn(cachedPlaylists, cachedPlaylists.systemVariant); - // Reset cursor so the next /api/library/sync re-emits all - // playlists; otherwise the new column would stay null on - // pre-existing rows until they happen to change server-side. - await customStatement('UPDATE sync_metadata SET cursor = 0'); - } - if (from < 3) { - // Schema 3: cached_home_snapshot (legacy drift-first home). - // The table + its homeProvider/JSON encoders were removed - // in #406; recreated here as raw SQL so this historical - // step still compiles without the generated table symbol. - // The from<11 step below drops it. - await customStatement( - 'CREATE TABLE IF NOT EXISTS "cached_home_snapshot" ' - '("id" INTEGER NOT NULL DEFAULT 1, "json" TEXT, ' - '"updated_at" INTEGER, PRIMARY KEY ("id"));', - ); - } - if (from < 4) { - // Schema 4: cached_history_snapshot for drift-first - // History tab. Same pattern as cached_home_snapshot — - // empty on upgrade; first /api/me/history fetch populates. - await m.createTable(cachedHistorySnapshot); - } - if (from < 5) { - // Schema 5: cached_quarantine_mine for drift-first Hidden - // tab. Empty on upgrade; first /api/quarantine/mine fetch - // populates. Optimistic flag/unflag also writes here. - await m.createTable(cachedQuarantineMine); - } - if (from < 6) { - // Schema 6: cached_home_index for per-item rendering. - // Empty on upgrade; first /api/home/index fetch populates. - // cached_home_snapshot stays in place for now — older - // builds still read from it as a fallback path. - await m.createTable(cachedHomeIndex); - } - if (from < 7) { - // Schema 7: cached_system_playlists_status. Single-row - // snapshot of /api/me/system-playlists-status used by the - // home Playlists row. Empty on upgrade; first fetch - // populates. - await m.createTable(cachedSystemPlaylistsStatus); - } - if (from < 8) { - // Schema 8: cached_mutations outbound queue. Empty on - // upgrade; populated by controllers when REST calls fail. - await m.createTable(cachedMutations); - } - if (from < 9) { - // Schema 9 (#427 S2): two-bucket cache. lastPlayedAt - // gives the offline "Recently played" view a real - // recency signal (cachedAt is download time, not play - // time). Nullable — backfilled to cachedAt so existing - // rows order sensibly until next play touches them. - await m.addColumn(audioCacheIndex, audioCacheIndex.lastPlayedAt); - await customStatement( - 'UPDATE audio_cache_index SET last_played_at = cached_at', - ); - } - if (from < 10) { - // Schema 10 (#54): cached_resume_state — single-row last- - // session snapshot for resume-on-launch. Empty on upgrade; - // first persist populates it. - await m.createTable(cachedResumeState); - } - if (from < 11) { - // Schema 11 (#406): drop the legacy cached_home_snapshot - // table. The home screen now renders solely from the - // per-item cached_home_index path; the legacy homeProvider - // + its JSON encoders were removed. - await customStatement( - 'DROP TABLE IF EXISTS "cached_home_snapshot";', - ); - } - }, - ); -} diff --git a/flutter_client/lib/cache/hydration_queue.dart b/flutter_client/lib/cache/hydration_queue.dart deleted file mode 100644 index a16c614d..00000000 --- a/flutter_client/lib/cache/hydration_queue.dart +++ /dev/null @@ -1,139 +0,0 @@ -// Per-item hydration coordinator for the home/playlist/liked surfaces. -// -// Tile widgets are reactive to their per-entity drift row. On first -// build, if drift has no row for the requested id, the tile asks this -// queue to hydrate it. The queue dispatches one /api//:id -// request, writes the result to drift, and the drift watch() on the -// tile re-emits with the populated row. -// -// Concurrency cap: HydrationQueue limits the number of in-flight -// requests so a 50-tile home screen doesn't fire 50 parallel /api/ -// calls and stall the auth-token-bound dio pool. In-flight dedup -// (keyed by `:`) ensures a single hydration when multiple -// tiles share an entity (rare on home, common when several screens -// reference the same album). -// -// Failures are swallowed — a single failed hydration leaves the tile -// in skeleton state. A future retry-on-visit pass can layer on top -// without changing the queue's contract. - -import 'dart:async'; -import 'dart:collection'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../cache/adapters.dart'; -import '../cache/audio_cache_manager.dart' show appDbProvider; -import '../library/library_providers.dart' show libraryApiProvider; - -/// Bounded-concurrency request queue for per-entity hydration. One -/// instance per Riverpod scope (provider singleton below). -class HydrationQueue { - HydrationQueue(this._ref); - final Ref _ref; - - /// Concurrent slot count. 4 keeps the dio token pool from saturating - /// while still pulling tiles down faster than a serial loop. Bump - /// if profiling shows the queue idle while the network is healthy. - static const _maxConcurrent = 4; - - final Queue<_HydrationRequest> _pending = Queue(); - final Set _inFlightKeys = {}; - int _inFlight = 0; - - /// Request hydration for (entityType, entityId). Idempotent — second - /// call for the same key while the first is queued or in flight is - /// dropped, so multiple tile rebuilds during a fast scroll don't - /// inflate the queue. - void enqueue({required String entityType, required String entityId}) { - if (entityId.isEmpty) return; - final key = '$entityType:$entityId'; - if (_inFlightKeys.contains(key)) return; - if (_pending.any((r) => r.key == key)) return; - _pending.add(_HydrationRequest( - entityType: entityType, entityId: entityId, key: key)); - _pump(); - } - - void _pump() { - while (_inFlight < _maxConcurrent && _pending.isNotEmpty) { - final req = _pending.removeFirst(); - _inFlightKeys.add(req.key); - _inFlight++; - // Unawaited on purpose — _pump returns immediately so further - // enqueues can fill remaining slots while this one runs. - unawaited(_execute(req).whenComplete(() { - _inFlight--; - _inFlightKeys.remove(req.key); - _pump(); - })); - } - } - - Future _execute(_HydrationRequest req) async { - try { - switch (req.entityType) { - case 'album': - await _hydrateAlbum(req.entityId); - case 'artist': - await _hydrateArtist(req.entityId); - case 'track': - await _hydrateTrack(req.entityId); - } - } catch (_) { - // Swallow — tile stays in skeleton. A retry-on-visit pass can - // layer on top later without changing this contract. - } - } - - Future _hydrateAlbum(String id) async { - final api = await _ref.read(libraryApiProvider.future); - final result = await api.getAlbum(id); - final db = _ref.read(appDbProvider); - // Album + tracks come bundled in the same response; persist both - // so opening the album detail later is also a drift hit. - await db.transaction(() async { - await db - .into(db.cachedAlbums) - .insertOnConflictUpdate(result.album.toDrift()); - if (result.tracks.isNotEmpty) { - await db.batch((b) { - b.insertAllOnConflictUpdate( - db.cachedTracks, result.tracks.map((t) => t.toDrift()).toList()); - }); - } - }); - } - - Future _hydrateArtist(String id) async { - final api = await _ref.read(libraryApiProvider.future); - final fresh = await api.getArtist(id); - final db = _ref.read(appDbProvider); - await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift()); - } - - Future _hydrateTrack(String id) async { - final api = await _ref.read(libraryApiProvider.future); - final fresh = await api.getTrack(id); - final db = _ref.read(appDbProvider); - await db.into(db.cachedTracks).insertOnConflictUpdate(fresh.toDrift()); - } -} - -class _HydrationRequest { - _HydrationRequest({ - required this.entityType, - required this.entityId, - required this.key, - }); - final String entityType; - final String entityId; - final String key; -} - -/// Singleton queue scoped to the Riverpod container. Kept as a plain -/// Provider (not StateProvider) since the queue's internal state is -/// pump-driven, not reactively observed. -final hydrationQueueProvider = Provider((ref) { - return HydrationQueue(ref); -}); diff --git a/flutter_client/lib/cache/metadata_prefetcher.dart b/flutter_client/lib/cache/metadata_prefetcher.dart deleted file mode 100644 index bafbc3fe..00000000 --- a/flutter_client/lib/cache/metadata_prefetcher.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../library/library_providers.dart'; -import '../models/home_index.dart'; - -/// Pre-warms the drift cache for likely-tap targets. Conservative on -/// purpose: only warms artistProvider rows (single row, single round -/// trip per id) and only ever fires once per id per session. Album -/// detail is NOT prewarmed — the albumProvider auto-fetches its track -/// list when missing, and pre-warming N albums fans out N parallel -/// "fetch tracks" round trips that compete with the user's actual -/// playback request for bandwidth. -/// -/// Driven off the per-item home path (homeIndexProvider). Only the -/// artist-typed sections (rediscover / last-played artists) carry -/// artist ids directly; album/track tiles hydrate their own artist on -/// render via the per-item path, so warming those here is unnecessary. -class MetadataPrefetcher { - MetadataPrefetcher(this._ref) { - _ref.listen>(homeIndexProvider, (_, next) { - next.whenData(_warmIndex); - }); - } - - final Ref _ref; - - /// Per-session dedupe so re-rendering a screen (every UI rebuild - /// fires the data: callback) doesn't trigger N more fetches for - /// ids we've already warmed. - final Set _warmedArtists = {}; - - /// Cap warmed per emission. Covers what fits on screen without - /// scrolling. - static const _topN = 8; - - /// Pre-warm artists. Albums are intentionally not pre-warmed — - /// see class comment. - void warmArtists(Iterable ids) { - var n = 0; - for (final id in ids) { - if (id.isEmpty) continue; - if (!_warmedArtists.add(id)) continue; // already warmed - if (n++ >= _topN) break; - _swallow(_ref.read(artistProvider(id).future)); - } - } - - void _warmIndex(HomeIndex h) { - warmArtists({ - ...h.rediscoverArtists.take(_topN), - ...h.lastPlayedArtists.take(_topN), - }); - } - - /// Discards the return value and any error from a fire-and-forget - /// provider read. We don't care about the value here — we only want - /// the side effect of writing drift. - void _swallow(Future f) { - f.then((_) {}).onError((_, __) {}); - } -} - -/// Read once at app start to activate the prefetcher (e.g. wire it -/// from a top-level Consumer or main.dart container override). -final metadataPrefetcherProvider = Provider((ref) { - return MetadataPrefetcher(ref); -}); diff --git a/flutter_client/lib/cache/mutation_queue.dart b/flutter_client/lib/cache/mutation_queue.dart deleted file mode 100644 index 5077f892..00000000 --- a/flutter_client/lib/cache/mutation_queue.dart +++ /dev/null @@ -1,311 +0,0 @@ -// Outbound mutation queue for offline-resilient REST calls. -// -// Controllers (LikesController, MyQuarantineController, the add-to- -// playlist sheet, the Discover request flow, the Requests cancel -// action) write their optimistic local state to drift first and try -// the corresponding REST call. On failure they enqueue here. The -// replayer drains the queue when connectivity comes back; the user's -// intent persists across network loss without rolling back their -// visible action. -// -// Why this is separate from SyncController: sync ingests AUTHORITATIVE -// SERVER state into drift. The mutation queue carries USER INTENT -// outbound to the server. They flow in opposite directions and -// shouldn't be conflated — sync wins on conflict (server is the -// source of truth), and a mutation that fails forever (5 attempts) -// is dropped on the trust that next sync will reconcile drift to -// match the server's actual state. -// -// Drop semantics: 5 attempts then drop. The drop is intentional — -// holding onto a forever-failing mutation just delays sync's -// reconciliation. The user's optimistic drift state will diverge -// from server, and the next library_changes delta corrects it. - -import 'dart:async'; -import 'dart:convert'; - -import 'package:dio/dio.dart' show DioException, DioExceptionType; -import 'package:drift/drift.dart'; -import 'package:flutter/foundation.dart' show debugPrint; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/discover.dart'; -import '../api/endpoints/events.dart'; -import '../api/endpoints/likes.dart'; -import '../library/library_providers.dart' show dioProvider; -import '../likes/likes_provider.dart' show likesApiProvider; -import '../models/lidarr.dart' show LidarrRequestKind; -import '../playlists/playlists_provider.dart' show playlistsApiProvider; -import '../quarantine/quarantine_provider.dart' show quarantineApiProvider; -import '../requests/requests_provider.dart' show requestsApiProvider; -import 'audio_cache_manager.dart' show appDbProvider; -import 'connectivity_provider.dart'; -import 'db.dart'; - -/// Stable kind constants. Each controller emits one of these via -/// MutationQueue.enqueue; the replayer dispatches by kind. -class MutationKinds { - MutationKinds._(); - static const likeAdd = 'like.add'; - static const likeRemove = 'like.remove'; - static const quarantineFlag = 'quarantine.flag'; - static const quarantineUnflag = 'quarantine.unflag'; - static const playlistAppend = 'playlist.append'; - static const requestCreate = 'request.create'; - static const requestCancel = 'request.cancel'; - static const playOffline = 'play.offline'; -} - -class MutationQueue { - MutationQueue(this._ref); - final Ref _ref; - - /// Persist a mutation for replay. Caller has already done the - /// optimistic local mutation (drift write, in-memory state update) - /// — this only records the REST call that needs to fire eventually. - Future enqueue(String kind, Map payload) async { - final db = _ref.read(appDbProvider); - await db.into(db.cachedMutations).insert( - CachedMutationsCompanion.insert( - kind: kind, - payload: jsonEncode(payload), - ), - ); - // Nudge the replayer in case we're online right now. - unawaited(_ref.read(mutationReplayerProvider).drain()); - } - - /// Count of pending mutations (attempts < 5). Diagnostic / future - /// UI surface for a "syncing N pending changes" indicator. - Future pendingCount() async { - final db = _ref.read(appDbProvider); - final rows = await db.customSelect( - 'SELECT COUNT(*) AS c FROM cached_mutations WHERE attempts < 5', - ).get(); - return rows.first.read('c'); - } -} - -final mutationQueueProvider = - Provider((ref) => MutationQueue(ref)); - -class MutationReplayer { - MutationReplayer(this._ref); - final Ref _ref; - - Timer? _initialTimer; - Timer? _intervalTimer; - bool _running = false; - bool _disposed = false; - - /// Periodic poll cadence. The connectivity listener catches most - /// transitions; this is the belt-and-suspenders for cases where - /// connectivity_plus misses a state change (e.g. flaky Wi-Fi). - static const _interval = Duration(minutes: 1); - - /// Max replay attempts before we drop a mutation. 5 is enough to - /// span a few reconnection cycles without holding onto a forever- - /// broken request. See file header for the drop rationale. - static const _maxAttempts = 5; - - void start() { - // Drain shortly after launch in case there are queued mutations - // from a prior session that died offline. 3s lets the auth + - // server-url + dio providers finish their async warmup. - _initialTimer = Timer(const Duration(seconds: 3), drain); - // Periodic ticker is the only reconnect signal. We previously had - // a ref.listen(connectivityProvider, …) edge trigger here, but - // subscribing at start-time eagerly mounted the connectivity - // StreamProvider and leaked its initial-timeout Timer through - // tests that never reach the auth state. The drain() loop itself - // gates on connectivity via the .future read below. - _intervalTimer = Timer.periodic(_interval, (_) => drain()); - } - - /// Walk the queue oldest-first, replaying each mutation. Stops on - /// any transient network error (we'll retry on the next tick); a - /// permanent error (404 etc.) increments the attempt counter and - /// moves on to the next mutation. This is public so MutationQueue. - /// enqueue can trigger an immediate attempt after writing. - Future drain() async { - if (_disposed || _running) return; - final online = await _ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true); - if (!online) return; - _running = true; - try { - while (!_disposed) { - final db = _ref.read(appDbProvider); - final next = await (db.select(db.cachedMutations) - ..where((t) => t.attempts.isSmallerThanValue(_maxAttempts)) - ..orderBy([(t) => OrderingTerm.asc(t.createdAt)]) - ..limit(1)) - .getSingleOrNull(); - if (next == null) break; - - final handler = _handlers[next.kind]; - if (handler == null) { - debugPrint('mutation_replayer: unknown kind ${next.kind}, dropping'); - await (db.delete(db.cachedMutations) - ..where((t) => t.id.equals(next.id))) - .go(); - continue; - } - - Map payload; - try { - payload = jsonDecode(next.payload) as Map; - } catch (e) { - debugPrint('mutation_replayer: bad payload for ${next.kind}: $e'); - await (db.delete(db.cachedMutations) - ..where((t) => t.id.equals(next.id))) - .go(); - continue; - } - - try { - await handler(_ref, payload); - await (db.delete(db.cachedMutations) - ..where((t) => t.id.equals(next.id))) - .go(); - } on DioException catch (e) { - await (db.update(db.cachedMutations) - ..where((t) => t.id.equals(next.id))) - .write(CachedMutationsCompanion( - attempts: Value(next.attempts + 1), - lastAttemptAt: Value(DateTime.now()), - )); - if (_isTransient(e)) { - // Network is flaky again — bail and try the whole queue - // later. The current mutation will be retried next tick. - break; - } - // Permanent failure (4xx/5xx with a real response). Leave - // the row with bumped attempts and move on to the next so - // one bad mutation doesn't block the others. - } catch (e, st) { - debugPrint('mutation_replayer: unexpected error: $e\n$st'); - await (db.update(db.cachedMutations) - ..where((t) => t.id.equals(next.id))) - .write(CachedMutationsCompanion( - attempts: Value(next.attempts + 1), - lastAttemptAt: Value(DateTime.now()), - )); - } - } - } finally { - _running = false; - } - } - - bool _isTransient(DioException e) { - switch (e.type) { - case DioExceptionType.connectionError: - case DioExceptionType.connectionTimeout: - case DioExceptionType.sendTimeout: - case DioExceptionType.receiveTimeout: - return true; - default: - return false; - } - } - - void dispose() { - _disposed = true; - _initialTimer?.cancel(); - _intervalTimer?.cancel(); - } -} - -final mutationReplayerProvider = Provider((ref) { - final r = MutationReplayer(ref); - ref.onDispose(r.dispose); - r.start(); - return r; -}); - -/// Type signature for kind-specific replay handlers. -typedef _Handler = Future Function(Ref, Map); - -/// Kind → handler dispatch. Each handler decodes its payload and -/// re-fires the original REST call. Throws on failure (caught by the -/// replayer's drain loop above). -/// -/// Payload shapes (see also enqueue call sites for the writer side): -/// * like.add / like.remove: {'kind': 'track'|'album'|'artist', 'id': uuid} -/// * quarantine.flag: {'trackId': uuid, 'reason': str, 'notes': str} -/// * quarantine.unflag: {'trackId': uuid} -/// * playlist.append: {'playlistId': uuid, 'trackIds': [uuid, …]} -/// * request.create: {full createRequest args; see DiscoverApi} -/// * request.cancel: {'id': uuid} -/// * play.offline: {'trackId': uuid, 'clientId': str, 'at': iso8601, -/// 'durationPlayedMs': int, 'source'?: 'for_you'|'discover'} -final Map _handlers = { - MutationKinds.likeAdd: (ref, p) async { - final api = await ref.read(likesApiProvider.future); - await api.like(_likeKindFromString(p['kind'] as String), p['id'] as String); - }, - MutationKinds.likeRemove: (ref, p) async { - final api = await ref.read(likesApiProvider.future); - await api.unlike( - _likeKindFromString(p['kind'] as String), p['id'] as String); - }, - MutationKinds.quarantineFlag: (ref, p) async { - final api = await ref.read(quarantineApiProvider.future); - await api.flag( - p['trackId'] as String, - p['reason'] as String, - notes: (p['notes'] as String?) ?? '', - ); - }, - MutationKinds.quarantineUnflag: (ref, p) async { - final api = await ref.read(quarantineApiProvider.future); - await api.unflag(p['trackId'] as String); - }, - MutationKinds.playlistAppend: (ref, p) async { - final api = await ref.read(playlistsApiProvider.future); - final ids = (p['trackIds'] as List).cast(); - await api.appendTracks(p['playlistId'] as String, ids); - }, - MutationKinds.requestCreate: (ref, p) async { - final dio = await ref.read(dioProvider.future); - final api = DiscoverApi(dio); - await api.createRequest( - kind: _lidarrKindFromString(p['kind'] as String), - artistMbid: p['artistMbid'] as String, - artistName: p['artistName'] as String, - albumMbid: p['albumMbid'] as String?, - albumTitle: p['albumTitle'] as String?, - trackMbid: p['trackMbid'] as String?, - trackTitle: p['trackTitle'] as String?, - ); - }, - MutationKinds.requestCancel: (ref, p) async { - final api = await ref.read(requestsApiProvider.future); - await api.cancel(p['id'] as String); - }, - MutationKinds.playOffline: (ref, p) async { - final dio = await ref.read(dioProvider.future); - final api = EventsApi(dio); - await api.playOffline( - trackId: p['trackId'] as String, - clientId: p['clientId'] as String, - atIso: p['at'] as String, - durationPlayedMs: (p['durationPlayedMs'] as num).toInt(), - source: p['source'] as String?, - ); - }, -}; - -LikeKind _likeKindFromString(String s) => switch (s) { - 'album' => LikeKind.album, - 'artist' => LikeKind.artist, - _ => LikeKind.track, - }; - -LidarrRequestKind _lidarrKindFromString(String s) => switch (s) { - 'album' => LidarrRequestKind.album, - 'track' => LidarrRequestKind.track, - _ => LidarrRequestKind.artist, - }; diff --git a/flutter_client/lib/cache/offline_provider.dart b/flutter_client/lib/cache/offline_provider.dart deleted file mode 100644 index 0ef4bcc4..00000000 --- a/flutter_client/lib/cache/offline_provider.dart +++ /dev/null @@ -1,105 +0,0 @@ -// Reachability-based offline marker (#427 S1). -// -// `connectivityProvider` only knows whether a network *interface* is -// up — not whether the Minstrel server is actually reachable (captive -// portals, server down, DNS, VPN-only routes all read "online"). This -// is the single source of truth other features gate on: offline = -// the server failed its /healthz probe N times in a row; recovery on -// the first success. -// -// Deliberately NOT coupled to connectivityProvider: subscribing to -// that StreamProvider eagerly mounts its 2s checkConnectivity timeout -// and leaks a pending Timer through widget tests that never reach the -// auth state (the bug fixed for MutationReplayer). /healthz failing -// already covers interface-down — it just fails the probe. -// -// Optimistic: assume online until proven offline, so a cold launch -// behaves normally and only flips after sustained unreachability. - -import 'dart:async'; - -import 'package:dio/dio.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/health.dart'; -import '../library/library_providers.dart' show dioProvider; - -class OfflineMonitor extends Notifier { - Timer? _initial; - Timer? _periodic; - int _consecutiveFails = 0; - - /// Consecutive failed probes before we declare offline. Small - /// enough to react within ~half a minute, large enough that a - /// single dropped request doesn't flip the whole UI. - static const _threshold = 3; - - /// Probe cadence. Slower when believed-online (cheap heartbeat); - /// faster when offline so recovery is noticed quickly. - static const _onlineInterval = Duration(seconds: 30); - static const _offlineInterval = Duration(seconds: 10); - - /// Let auth + server-url + dio providers finish async warmup - /// before the first probe so a cold start doesn't false-positive. - static const _initialDelay = Duration(seconds: 5); - - @override - bool build() { - ref.onDispose(() { - _initial?.cancel(); - _periodic?.cancel(); - }); - _initial = Timer(_initialDelay, () { - _check(); - _arm(); - }); - return false; // optimistic until a probe says otherwise - } - - void _arm() { - _periodic?.cancel(); - _periodic = Timer.periodic( - state ? _offlineInterval : _onlineInterval, - (_) => _check(), - ); - } - - Future _check() async { - final Dio dio; - try { - // Not configured yet (no server URL) → don't flip; the app is - // still on the connect screen and "offline" is meaningless. - dio = await ref.read(dioProvider.future); - } catch (_) { - return; - } - try { - // dio's own connect/receive timeouts bound this — no extra - // Timer (which would leak in widget tests). - await HealthApi(dio).check(); - _consecutiveFails = 0; - _set(false); - } catch (_) { - _consecutiveFails++; - if (_consecutiveFails >= _threshold) { - _set(true); - } - } - } - - void _set(bool offline) { - if (state == offline) return; - state = offline; - _arm(); // cadence follows the new state - } - - /// Force an immediate probe — e.g. a user-initiated retry. Public - /// so S4's offline surfaces can offer a "try again" affordance. - Future recheck() => _check(); -} - -/// `true` when the server is unreachable. Watch this to gate -/// online-only affordances; read it once at app start to activate -/// the poller. -final offlineProvider = - NotifierProvider(OfflineMonitor.new); diff --git a/flutter_client/lib/cache/prefetcher.dart b/flutter_client/lib/cache/prefetcher.dart deleted file mode 100644 index 2abeaf8e..00000000 --- a/flutter_client/lib/cache/prefetcher.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'dart:async'; - -import 'package:audio_service/audio_service.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../likes/likes_provider.dart' show likedIdsProvider; -import '../player/album_color_extractor.dart'; -import '../player/player_provider.dart'; -import 'audio_cache_manager.dart'; -import 'cache_settings_provider.dart'; -import 'db.dart'; - -/// Listens to the player's currently-playing track. When it changes, -/// computes the next-N tracks ahead in the queue and pins them via -/// AudioCacheManager (source: autoPrefetch). After pinning, runs an -/// eviction pass against the operator-set cap. -/// -/// The window N comes from cacheSettingsProvider.prefetchWindow -/// (default 5, configurable in Settings). -class Prefetcher { - Prefetcher(this._ref) { - // The mediaItem stream changes whenever the active track changes - // (skipNext/skipPrev or natural progression). Settings changes (e.g. - // operator bumps prefetch window) also trigger a reconcile. - _ref.listen>(mediaItemProvider, (_, __) => _reconcile()); - _ref.listen>(cacheSettingsProvider, (_, __) => _reconcile()); - } - - final Ref _ref; - - Future _reconcile() async { - final settings = _ref.read(cacheSettingsProvider).value; - if (settings == null) return; - - final queue = _ref.read(queueProvider).value ?? const []; - final current = _ref.read(mediaItemProvider).value; - if (queue.isEmpty || current == null) return; - - final currentIdx = queue.indexWhere((m) => m.id == current.id); - if (currentIdx < 0) return; - - final endIdx = - (currentIdx + settings.prefetchWindow).clamp(0, queue.length - 1); - - final mgr = _ref.read(audioCacheManagerProvider); - final coverCache = _ref.read(albumCoverCacheProvider); - final colorCache = _ref.read(albumColorCacheProvider); - - // Walk the window. For each upcoming track we want THREE things - // ready when the player transitions into it: - // - // 1. Audio file on disk (otherwise playback stalls on stream - // load — the original prefetcher concern). - // 2. Cover bytes on disk under AlbumCoverCache (otherwise - // _toMediaItem's peekCached returns null, mediaItem - // broadcasts with artUri=null, and the now-playing screen - // stalls in _scheduleSwap awaiting precacheImage of a file - // that doesn't exist yet). - // 3. Palette color extracted and memoized in AlbumColorCache - // (otherwise the gradient backdrop has to wait for - // PaletteGenerator to run after the cover lands — - // visible as the cover snapping in before the gradient). - // - // Each call is idempotent (cache-aware): if already cached, it's - // a no-op. Everything fire-and-forget so the reconcile completes - // quickly even on a fresh queue. - for (var i = currentIdx; i <= endIdx; i++) { - final media = queue[i]; - final trackId = media.id; - final albumId = media.extras?['album_id'] as String?; - - if (!await mgr.isCached(trackId)) { - // ignore: unawaited_futures - mgr.pin(trackId, source: CacheSource.autoPrefetch); - } - - if (albumId != null && albumId.isNotEmpty) { - // Cover bytes: getOrFetch returns the file path; the side - // effect (writing to disk) is what we care about. - // ignore: unawaited_futures - coverCache.getOrFetch(albumId); - // Palette: getOrExtract chains off coverCache.getOrFetch so - // it'll wait for the cover before sampling — safe to call - // in parallel here. - // ignore: unawaited_futures - colorCache.getOrExtract(albumId); - } - } - - // Eviction pass after pinning new files. Per-bucket (#427 S2): - // liked-ness decides the bucket, so a track that's both liked - // and recently played is protected by the Liked cap, not the - // rolling LRU. - final liked = - _ref.read(likedIdsProvider).value?.tracks ?? const {}; - await mgr.evictBuckets( - likedCap: settings.likedCapBytes, - rollingCap: settings.rollingCapBytes, - liked: liked, - ); - } -} - -/// Read this provider once at app start to activate the prefetcher. -/// Constructor wires the listeners. -final prefetcherProvider = Provider((ref) { - return Prefetcher(ref); -}); diff --git a/flutter_client/lib/cache/resume_controller.dart b/flutter_client/lib/cache/resume_controller.dart deleted file mode 100644 index 80878dd5..00000000 --- a/flutter_client/lib/cache/resume_controller.dart +++ /dev/null @@ -1,183 +0,0 @@ -// Resume-on-launch (#54). The #52 teardown tears the audio_service -// session down (and clears mediaItem) when idle/dismissed, so the -// headset / lock-screen play button otherwise has nothing to resume. -// This controller persists the live queue + index + position + #415 -// source to a single-row drift snapshot on track change / pause / app -// teardown, and on construction restores the last snapshot into the -// handler PAUSED (the user sees their last track and continues with -// play / a media button — we never auto-blast on launch). -// -// Persist guard: when the handler clears (teardown → mediaItem null / -// empty queue) we deliberately do NOT write, so the last good snapshot -// survives for the next launch — that survival is the whole point. - -import 'dart:async'; -import 'dart:convert'; - -import 'package:drift/drift.dart' as drift; -import 'package:flutter/widgets.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../auth/auth_provider.dart'; -import '../models/track.dart'; -import '../player/player_provider.dart'; -import 'audio_cache_manager.dart' show appDbProvider; -import 'db.dart'; - -class ResumeController with WidgetsBindingObserver { - ResumeController(this._ref); - final Ref _ref; - - final _subs = >[]; - Timer? _debounce; - bool _disposed = false; - - Future start() async { - try { - // audioHandlerProvider throws until main() overrides it (the real - // app always does). In tests / no-audio environments there's - // nothing to resume — stay inert. - _ref.read(audioHandlerProvider); - } catch (_) { - return; - } - if (_disposed) return; - await _restore(); - if (_disposed) return; - final h = _ref.read(audioHandlerProvider); - // Media-button-when-torn-down hook (#448): play() invokes this when - // mediaItem is null so a headset/watch press resumes the snapshot. - h.setResumeHook(resumeFromMediaButton); - _subs.add(h.mediaItem.listen((_) => _schedulePersist())); - _subs.add(h.playbackState.listen((_) => _schedulePersist())); - WidgetsBinding.instance.addObserver(this); - } - - void _schedulePersist() { - if (_disposed) return; - _debounce?.cancel(); - _debounce = Timer(const Duration(seconds: 3), () { - unawaited(_persist()); - }); - } - - Future _persist() async { - if (_disposed) return; - try { - final h = _ref.read(audioHandlerProvider); - final tracks = h.queuedTracks; - final mi = h.mediaItem.value; - // Cleared by teardown — keep the last good snapshot for next - // launch rather than wiping it with an empty queue. - if (tracks.isEmpty || mi == null) return; - var idx = tracks.indexWhere((t) => t.id == mi.id); - if (idx < 0) idx = 0; - final blob = jsonEncode({ - 'v': 1, - 'source': h.queueSource, - 'index': idx, - 'position_ms': h.position.inMilliseconds, - 'tracks': tracks.map((t) => t.toJson()).toList(), - }); - final db = _ref.read(appDbProvider); - await db.into(db.cachedResumeState).insertOnConflictUpdate( - CachedResumeStateCompanion.insert( - json: blob, - updatedAt: drift.Value(DateTime.now()), - ), - ); - } catch (e, st) { - debugPrint('resume_controller: persist failed: $e\n$st'); - } - } - - /// Launch path: restore the last session PAUSED (no auto-blast). - Future _restore() async { - try { - await _loadAndRestore(); - } catch (e, st) { - debugPrint('resume_controller: restore failed: $e\n$st'); - } - } - - /// Media-button path (#448): the user pressed play on the headset / - /// watch / lock screen while the session was fully torn down (#52) and - /// nothing is loaded. Restore the snapshot, then START playback (they - /// asked to play). Registered as the handler's resume hook in start(). - /// No-op if there's nothing to resume. - Future resumeFromMediaButton() async { - try { - if (await _loadAndRestore()) { - await _ref.read(audioHandlerProvider).play(); - } - } catch (e, st) { - debugPrint('resume_controller: media-button resume failed: $e\n$st'); - } - } - - /// Shared loader. Restores the last persisted session PAUSED and - /// returns whether it actually restored a queue. Guards: an already- - /// active session (don't stomp), missing auth, no/empty snapshot. - Future _loadAndRestore() async { - final h = _ref.read(audioHandlerProvider); - if (h.mediaItem.value != null) return false; - final url = await _ref.read(serverUrlProvider.future); - final token = - await _ref.read(secureStorageProvider).read(key: 'session_token'); - if (url == null || url.isEmpty || token == null || token.isEmpty) { - return false; - } - final db = _ref.read(appDbProvider); - final row = await db.select(db.cachedResumeState).getSingleOrNull(); - if (row == null) return false; - final m = jsonDecode(row.json) as Map; - final rawTracks = (m['tracks'] as List?) ?? const []; - final tracks = rawTracks - .map((e) => TrackRef.fromJson(e as Map)) - .toList(); - if (tracks.isEmpty) return false; - final idx = (m['index'] as num?)?.toInt() ?? 0; - final posMs = (m['position_ms'] as num?)?.toInt() ?? 0; - final source = m['source'] as String?; - await _ref.read(playerActionsProvider).restoreQueue( - tracks, - initialIndex: idx, - position: Duration(milliseconds: posMs), - source: source, - ); - return true; - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - // App backgrounded / killed: persist durably right now (the debounce - // may not fire before teardown). - if (state == AppLifecycleState.paused || - state == AppLifecycleState.detached) { - _debounce?.cancel(); - unawaited(_persist()); - } - } - - void dispose() { - _disposed = true; - _debounce?.cancel(); - WidgetsBinding.instance.removeObserver(this); - for (final s in _subs) { - s.cancel(); - } - _subs.clear(); - } -} - -/// Read once at app start (app.dart postFrame). On construction it -/// restores the last persisted session (paused) then persists -/// queue/index/position on track change, pause, and app teardown. -/// Disposed via ref.onDispose when the scope tears down. -final resumeControllerProvider = Provider((ref) { - final c = ResumeController(ref); - ref.onDispose(c.dispose); - // ignore: unawaited_futures - c.start(); - return c; -}); diff --git a/flutter_client/lib/cache/shuffle_source.dart b/flutter_client/lib/cache/shuffle_source.dart deleted file mode 100644 index e42d9767..00000000 --- a/flutter_client/lib/cache/shuffle_source.dart +++ /dev/null @@ -1,104 +0,0 @@ -// Offline play sources over the local cache index (#427 S4). -// -// "Shuffle all" is always-present and degrades with reachability: -// online → GET /api/library/shuffle (random over the whole library) -// offline → a client shuffle over the entire local cache index -// -// Offline-only pools surfaced on Home beside the (disabled) system -// playlists: "Recently played" (cache by lastPlayedAt desc) and -// "Liked" (cache ∩ liked set). All of these are UNIONs over the -// cache regardless of storage bucket — liked AND recently-played -// both included. The two-bucket split (S2) is storage/eviction-only -// and never filters playback, which is exactly what this relies on. - -import 'dart:math'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../library/library_providers.dart' show libraryApiProvider; -import '../likes/likes_provider.dart' show likedIdsProvider; -import '../models/track.dart'; -import 'adapters.dart'; -import 'audio_cache_manager.dart' show appDbProvider; -import 'offline_provider.dart'; - -class ShuffleSource { - ShuffleSource(this._ref); - final Ref _ref; - - /// Shuffle-all. Online: server-random. Offline: every cached - /// track, shuffled. Empty offline → nothing cached yet. - Future> tracks({int limit = 100}) async { - if (_ref.read(offlineProvider)) { - final ids = await _cachedIdsByRecency(); - final list = await _refs(ids); - list.shuffle(Random()); - return list.length > limit ? list.sublist(0, limit) : list; - } - final api = await _ref.read(libraryApiProvider.future); - return api.shuffle(limit: limit); - } - - /// Cached tracks, most-recently-played first (liked included). - /// Cache-only — surfaced on Home only when offline. - Future> recentlyPlayed({int limit = 100}) async { - final ids = await _cachedIdsByRecency(); - final list = await _refs(ids); - return list.length > limit ? list.sublist(0, limit) : list; - } - - /// Cached tracks that are in the user's liked set. Cache-only — - /// surfaced on Home only when offline. - Future> liked({int limit = 100}) async { - final likedSet = - _ref.read(likedIdsProvider).value?.tracks ?? const {}; - final ids = - (await _cachedIdsByRecency()).where(likedSet.contains).toList(); - final list = await _refs(ids); - return list.length > limit ? list.sublist(0, limit) : list; - } - - /// Cached track ids ordered by lastPlayedAt desc (nulls last so - /// never-touched downloads sort after real plays). - Future> _cachedIdsByRecency() async { - final db = _ref.read(appDbProvider); - final rows = await db.select(db.audioCacheIndex).get(); - rows.sort((a, b) { - final av = a.lastPlayedAt, bv = b.lastPlayedAt; - if (av == null && bv == null) return 0; - if (av == null) return 1; - if (bv == null) return -1; - return bv.compareTo(av); - }); - return rows.map((r) => r.trackId).toList(); - } - - /// Materializes ordered track ids into TrackRefs from the cached - /// metadata tables, preserving the given order. - Future> _refs(List orderedIds) async { - if (orderedIds.isEmpty) return const []; - final db = _ref.read(appDbProvider); - final meta = await (db.select(db.cachedTracks) - ..where((t) => t.id.isIn(orderedIds))) - .get(); - if (meta.isEmpty) return const []; - final byId = {for (final t in meta) t.id: t}; - final artistName = { - for (final a in await db.select(db.cachedArtists).get()) a.id: a.name - }; - final albumTitle = { - for (final a in await db.select(db.cachedAlbums).get()) a.id: a.title - }; - return [ - for (final id in orderedIds) - if (byId[id] case final t?) - t.toRef( - artistName: artistName[t.artistId] ?? '', - albumTitle: albumTitle[t.albumId] ?? '', - ) - ]; - } -} - -final shuffleSourceProvider = - Provider((ref) => ShuffleSource(ref)); diff --git a/flutter_client/lib/cache/sync_controller.dart b/flutter_client/lib/cache/sync_controller.dart deleted file mode 100644 index b4f1f91a..00000000 --- a/flutter_client/lib/cache/sync_controller.dart +++ /dev/null @@ -1,360 +0,0 @@ -import 'dart:async'; - -import 'package:drift/drift.dart' as drift; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../auth/auth_provider.dart' show serverUrlProvider, sessionTokenProvider; -import '../library/library_providers.dart' show dioProvider; -import 'audio_cache_manager.dart' show appDbProvider; -import 'db.dart'; - -/// Counts returned from a sync operation. Surfaced to the operator- -/// facing "Sync now" button + Settings card. -class SyncResult { - const SyncResult({ - required this.upserts, - required this.deletes, - required this.cursor, - }); - final int upserts; - final int deletes; - final int cursor; -} - -/// Drives the delta-sync against the server (#357 Plan A endpoint). -/// Reads cursor from drift, calls `/api/library/sync?since={cursor}`, -/// applies upserts + deletes, advances cursor. -class SyncController extends AsyncNotifier { - @override - Future build() async => null; - - Future sync() async { - state = const AsyncLoading(); - try { - final db = ref.read(appDbProvider); - final dio = await ref.read(dioProvider.future); - - final meta = await db.select(db.syncMetadata).getSingleOrNull(); - final cursor = meta?.cursor ?? 0; - - final resp = await dio.get( - '/api/library/sync', - queryParameters: {'since': cursor}, - ); - - // 204 No Content — no changes since cursor. - if (resp.statusCode == 204) { - await db.into(db.syncMetadata).insertOnConflictUpdate( - SyncMetadataCompanion.insert( - lastSyncAt: drift.Value(DateTime.now()), - ), - ); - final r = SyncResult(upserts: 0, deletes: 0, cursor: cursor); - state = AsyncData(r); - return r; - } - - // 410 Gone — cursor too old, server has compacted past it. Reset - // local state and retry once with cursor=0. - if (resp.statusCode == 410) { - await db.transaction(() async { - await db.delete(db.cachedArtists).go(); - await db.delete(db.cachedAlbums).go(); - await db.delete(db.cachedTracks).go(); - await db.delete(db.cachedLikes).go(); - await db.delete(db.cachedPlaylists).go(); - await db.delete(db.cachedPlaylistTracks).go(); - await db.into(db.syncMetadata).insertOnConflictUpdate( - SyncMetadataCompanion.insert(cursor: const drift.Value(0)), - ); - }); - return sync(); - } - - final body = resp.data as Map; - final newCursor = (body['cursor'] as num).toInt(); - final upserts = body['upserts'] as Map? ?? {}; - final deletes = body['deletes'] as Map? ?? {}; - - var upsertCount = 0; - var deleteCount = 0; - // IDs of upserted entities whose covers we'll pre-warm after the - // transaction commits. Filled inside the loops; consumed by - // _prewarmCovers fire-and-forget below. Artist covers are - // server-derived from "most-recent album" and aren't - // reconstructible client-side — album pre-warm already covers an - // artist's primary visual via its detail-page header. - final albumCoverIds = []; - final playlistCoverIds = []; - - await db.transaction(() async { - // ---- Upserts ---- - for (final a in (upserts['artist'] as List? ?? const [])) { - await db.into(db.cachedArtists).insertOnConflictUpdate( - _artistFromJson(a as Map), - ); - upsertCount++; - } - for (final a in (upserts['album'] as List? ?? const [])) { - final m = a as Map; - await db.into(db.cachedAlbums).insertOnConflictUpdate( - _albumFromJson(m), - ); - final id = m['id']; - if (id is String && id.isNotEmpty) albumCoverIds.add(id); - upsertCount++; - } - for (final t in (upserts['track'] as List? ?? const [])) { - await db.into(db.cachedTracks).insertOnConflictUpdate( - _trackFromJson(t as Map), - ); - upsertCount++; - } - for (final l in (upserts['like_track'] as List? ?? const [])) { - final m = l as Map; - await db.into(db.cachedLikes).insertOnConflictUpdate( - CachedLikesCompanion.insert( - userId: m['user_id'] as String, - entityType: 'track', - entityId: m['track_id'] as String, - ), - ); - upsertCount++; - } - for (final l in (upserts['like_album'] as List? ?? const [])) { - final m = l as Map; - await db.into(db.cachedLikes).insertOnConflictUpdate( - CachedLikesCompanion.insert( - userId: m['user_id'] as String, - entityType: 'album', - entityId: m['album_id'] as String, - ), - ); - upsertCount++; - } - for (final l in (upserts['like_artist'] as List? ?? const [])) { - final m = l as Map; - await db.into(db.cachedLikes).insertOnConflictUpdate( - CachedLikesCompanion.insert( - userId: m['user_id'] as String, - entityType: 'artist', - entityId: m['artist_id'] as String, - ), - ); - upsertCount++; - } - for (final p in (upserts['playlist'] as List? ?? const [])) { - final m = p as Map; - await db.into(db.cachedPlaylists).insertOnConflictUpdate( - _playlistFromJson(m), - ); - final id = m['id']; - if (id is String && id.isNotEmpty) playlistCoverIds.add(id); - upsertCount++; - } - for (final pt in (upserts['playlist_track'] as List? ?? const [])) { - final m = pt as Map; - await db.into(db.cachedPlaylistTracks).insertOnConflictUpdate( - CachedPlaylistTracksCompanion.insert( - playlistId: m['playlist_id'] as String, - trackId: m['track_id'] as String, - ), - ); - upsertCount++; - } - - // ---- Deletes ---- - for (final id in (deletes['artist'] as List? ?? const [])) { - await (db.delete(db.cachedArtists) - ..where((t) => t.id.equals(id as String))) - .go(); - deleteCount++; - } - for (final id in (deletes['album'] as List? ?? const [])) { - await (db.delete(db.cachedAlbums) - ..where((t) => t.id.equals(id as String))) - .go(); - deleteCount++; - } - for (final id in (deletes['track'] as List? ?? const [])) { - await (db.delete(db.cachedTracks) - ..where((t) => t.id.equals(id as String))) - .go(); - deleteCount++; - } - for (final id in (deletes['like_track'] as List? ?? const [])) { - final parts = (id as String).split(':'); - if (parts.length != 2) continue; - await (db.delete(db.cachedLikes) - ..where((t) => - t.userId.equals(parts[0]) & - t.entityType.equals('track') & - t.entityId.equals(parts[1]))) - .go(); - deleteCount++; - } - for (final id in (deletes['like_album'] as List? ?? const [])) { - final parts = (id as String).split(':'); - if (parts.length != 2) continue; - await (db.delete(db.cachedLikes) - ..where((t) => - t.userId.equals(parts[0]) & - t.entityType.equals('album') & - t.entityId.equals(parts[1]))) - .go(); - deleteCount++; - } - for (final id in (deletes['like_artist'] as List? ?? const [])) { - final parts = (id as String).split(':'); - if (parts.length != 2) continue; - await (db.delete(db.cachedLikes) - ..where((t) => - t.userId.equals(parts[0]) & - t.entityType.equals('artist') & - t.entityId.equals(parts[1]))) - .go(); - deleteCount++; - } - for (final id in (deletes['playlist'] as List? ?? const [])) { - await (db.delete(db.cachedPlaylists) - ..where((t) => t.id.equals(id as String))) - .go(); - deleteCount++; - } - for (final id in (deletes['playlist_track'] as List? ?? const [])) { - final parts = (id as String).split(':'); - if (parts.length != 2) continue; - await (db.delete(db.cachedPlaylistTracks) - ..where((t) => - t.playlistId.equals(parts[0]) & - t.trackId.equals(parts[1]))) - .go(); - deleteCount++; - } - - // ---- Cursor + lastSyncAt ---- - await db.into(db.syncMetadata).insertOnConflictUpdate( - SyncMetadataCompanion.insert( - cursor: drift.Value(newCursor), - lastSyncAt: drift.Value(DateTime.now()), - ), - ); - }); - - // Fire-and-forget cover pre-warm so a cold-start scroll through - // the home grid paints from disk on the very first frame instead - // of firing one HTTP request per visible tile. Unawaited because - // the sync's "done" signal should fire as soon as the metadata - // delta is durable; cover downloads can finish in the background. - if (albumCoverIds.isNotEmpty || playlistCoverIds.isNotEmpty) { - unawaited(_prewarmCovers(albumCoverIds, playlistCoverIds)); - } - - final result = SyncResult( - upserts: upsertCount, - deletes: deleteCount, - cursor: newCursor, - ); - state = AsyncData(result); - return result; - } catch (e, st) { - state = AsyncError(e, st); - return null; - } - } - - /// Downloads cover bytes for each id into the shared flutter_cache_ - /// manager disk cache that cached_network_image reads from. - /// Best-effort: per-URL failures (404 collage-not-built-yet, 401 - /// during a token refresh race, network blip) are swallowed so one - /// missing cover doesn't abort the rest. - /// - /// Concurrency 3 balances disk-warming throughput against starving - /// foreground UI work — covers are ~30-200KB each, so three in - /// flight saturates most LAN connections without pinning the radio. - Future _prewarmCovers( - List albumIds, - List playlistIds, - ) async { - final baseUrl = await ref.read(serverUrlProvider.future); - if (baseUrl == null || baseUrl.isEmpty) return; - final token = await ref.read(sessionTokenProvider.future); - final headers = (token != null && token.isNotEmpty) - ? {'Authorization': 'Bearer $token'} - : {}; - final base = - baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl; - final urls = [ - for (final id in albumIds) '$base/api/albums/$id/cover', - for (final id in playlistIds) '$base/api/playlists/$id/cover', - ]; - final mgr = DefaultCacheManager(); - var index = 0; - Future worker() async { - while (index < urls.length) { - final i = index++; - try { - await mgr.downloadFile(urls[i], authHeaders: headers); - } catch (_) { - // Best-effort prewarm — failures are expected for system - // playlists whose collage hasn't been built yet, and for - // any transient auth race. Don't surface or abort. - } - } - } - - await Future.wait(List.generate(3, (_) => worker())); - } - - CachedArtistsCompanion _artistFromJson(Map j) => - CachedArtistsCompanion.insert( - id: j['id'] as String, - name: (j['name'] as String?) ?? '', - sortName: (j['sort_name'] as String?) ?? '', - mbid: drift.Value(j['mbid'] as String?), - artistThumbPath: drift.Value(j['artist_thumb_path'] as String?), - artistFanartPath: drift.Value(j['artist_fanart_path'] as String?), - ); - - CachedAlbumsCompanion _albumFromJson(Map j) => - CachedAlbumsCompanion.insert( - id: j['id'] as String, - artistId: j['artist_id'] as String, - title: (j['title'] as String?) ?? '', - sortTitle: (j['sort_title'] as String?) ?? '', - releaseDate: drift.Value(j['release_date'] as String?), - coverPath: drift.Value(j['cover_art_path'] as String?), - mbid: drift.Value(j['mbid'] as String?), - ); - - CachedTracksCompanion _trackFromJson(Map j) => - CachedTracksCompanion.insert( - id: j['id'] as String, - albumId: j['album_id'] as String, - artistId: j['artist_id'] as String, - title: (j['title'] as String?) ?? '', - durationMs: drift.Value((j['duration_ms'] as num?)?.toInt() ?? 0), - trackNumber: drift.Value((j['track_number'] as num?)?.toInt()), - discNumber: drift.Value((j['disc_number'] as num?)?.toInt()), - filePath: drift.Value(j['file_path'] as String?), - fileFormat: drift.Value(j['file_format'] as String?), - genre: drift.Value(j['genre'] as String?), - ); - - CachedPlaylistsCompanion _playlistFromJson(Map j) => - CachedPlaylistsCompanion.insert( - id: j['id'] as String, - userId: j['user_id'] as String, - name: (j['name'] as String?) ?? '', - description: drift.Value((j['description'] as String?) ?? ''), - isPublic: drift.Value((j['is_public'] as bool?) ?? false), - coverPath: drift.Value(j['cover_path'] as String?), - trackCount: drift.Value((j['track_count'] as num?)?.toInt() ?? 0), - durationSec: drift.Value((j['duration_sec'] as num?)?.toInt() ?? 0), - systemVariant: drift.Value(j['system_variant'] as String?), - ); -} - -final syncControllerProvider = - AsyncNotifierProvider(SyncController.new); diff --git a/flutter_client/lib/cache/tile_providers.dart b/flutter_client/lib/cache/tile_providers.dart deleted file mode 100644 index 6fb8e1fa..00000000 --- a/flutter_client/lib/cache/tile_providers.dart +++ /dev/null @@ -1,144 +0,0 @@ -// Per-entity tile providers for the per-item rendering architecture -// (see docs/superpowers/specs/2026-05-13-per-item-rendering-design.md). -// -// Each provider is a StreamProvider.family keyed -// by entity id. The stream: -// 1. Watches the entity's drift row -// 2. Yields the populated row on every emission -// 3. Yields null while the row is absent (UI shows skeleton) -// 4. On the first missing-row emission, enqueues a hydration via -// HydrationQueue so the row eventually lands -// -// The tile widget reacts to AsyncValue: -// - loading or data:null → skeleton -// - data:non-null → real card -// - error → error placeholder -// -// Subscriptions are per-tile. A 50-tile home screen creates 50 drift -// subscriptions; drift handles this fine in practice but worth -// measuring if a future surface scales to hundreds. - -import 'package:drift/drift.dart' as drift show OrderingTerm; -import 'package:drift/drift.dart' show leftOuterJoin; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../cache/adapters.dart'; -import '../cache/audio_cache_manager.dart' show appDbProvider; -import '../models/album.dart'; -import '../models/artist.dart'; -import '../models/track.dart'; -import 'hydration_queue.dart'; - -/// Watches the cached_albums row for [id]. Triggers background -/// hydration on miss; yields the row once it lands. -final albumTileProvider = - StreamProvider.family((ref, id) async* { - if (id.isEmpty) { - yield null; - return; - } - final db = ref.watch(appDbProvider); - final query = (db.select(db.cachedAlbums)..where((t) => t.id.equals(id))) - .join([ - leftOuterJoin(db.cachedArtists, - db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)), - ]); - var enqueued = false; - await for (final rows in query.watch()) { - if (rows.isEmpty) { - if (!enqueued) { - enqueued = true; - ref - .read(hydrationQueueProvider) - .enqueue(entityType: 'album', entityId: id); - } - yield null; - continue; - } - final r = rows.first; - final album = r.readTable(db.cachedAlbums); - final artist = r.readTableOrNull(db.cachedArtists); - yield album.toRef(artistName: artist?.name ?? ''); - } -}); - -/// Watches the cached_artists row for [id]. Triggers background -/// hydration on miss; yields the row once it lands. -/// -/// LEFT JOIN cached_albums ordered by sort_title so the first row per -/// artist carries a representative album id; toRef() reconstructs -/// `/api/albums//cover` from it. Without this join the ArtistRef -/// has an empty coverUrl and tiles fall back to the music-notes -/// placeholder even though the server has the cover available. -final artistTileProvider = - StreamProvider.family((ref, id) async* { - if (id.isEmpty) { - yield null; - return; - } - final db = ref.watch(appDbProvider); - final query = (db.select(db.cachedArtists)..where((t) => t.id.equals(id))) - .join([ - leftOuterJoin(db.cachedAlbums, - db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)), - ]) - ..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]); - var enqueued = false; - await for (final rows in query.watch()) { - if (rows.isEmpty) { - if (!enqueued) { - enqueued = true; - ref - .read(hydrationQueueProvider) - .enqueue(entityType: 'artist', entityId: id); - } - yield null; - continue; - } - // First row has the alphabetically-first album (or null if artist - // has no albums in drift yet — yields a coverless ArtistRef which - // the UI falls back to the placeholder icon for). - final artist = rows.first.readTable(db.cachedArtists); - final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums); - yield artist.toRef(coverAlbumId: firstAlbum?.id ?? ''); - } -}); - -/// Watches the cached_tracks row for [id]. Triggers background -/// hydration on miss; yields the row once it lands. -final trackTileProvider = - StreamProvider.family((ref, id) async* { - if (id.isEmpty) { - yield null; - return; - } - final db = ref.watch(appDbProvider); - final query = (db.select(db.cachedTracks)..where((t) => t.id.equals(id))) - .join([ - leftOuterJoin(db.cachedArtists, - db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)), - leftOuterJoin(db.cachedAlbums, - db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)), - ]); - var enqueued = false; - await for (final rows in query.watch()) { - if (rows.isEmpty) { - if (!enqueued) { - enqueued = true; - ref - .read(hydrationQueueProvider) - .enqueue(entityType: 'track', entityId: id); - } - yield null; - continue; - } - final r = rows.first; - final t = r.readTable(db.cachedTracks); - final a = r.readTableOrNull(db.cachedArtists); - final al = r.readTableOrNull(db.cachedAlbums); - yield t.toRef( - artistName: a?.name ?? '', - albumTitle: al?.title ?? '', - ); - } -}); diff --git a/flutter_client/lib/discover/discover_screen.dart b/flutter_client/lib/discover/discover_screen.dart deleted file mode 100644 index bee54a48..00000000 --- a/flutter_client/lib/discover/discover_screen.dart +++ /dev/null @@ -1,463 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../api/endpoints/discover.dart'; -import '../api/errors.dart'; -import '../cache/mutation_queue.dart'; -import '../library/library_providers.dart' show dioProvider; -import '../models/artist_suggestion.dart'; -import '../models/lidarr.dart'; -import '../shared/widgets/main_app_bar_actions.dart'; -import '../theme/theme_extension.dart'; - -final _discoverApiProvider = FutureProvider((ref) async { - return DiscoverApi(await ref.watch(dioProvider.future)); -}); - -class DiscoverScreen extends ConsumerStatefulWidget { - const DiscoverScreen({super.key}); - - @override - ConsumerState createState() => _DiscoverScreenState(); -} - -class _DiscoverScreenState extends ConsumerState { - final _ctrl = TextEditingController(); - LidarrRequestKind _kind = LidarrRequestKind.artist; - Future>? _resultsFuture; - // Default (empty-search) surface: LB-derived out-of-library artist - // suggestions, mirroring web's SuggestionFeed. - Future>? _suggestionsFuture; - final _requested = {}; - - @override - void initState() { - super.initState(); - _loadSuggestions(); - // Clearing the box returns to suggestions (web swaps live too). - _ctrl.addListener(() { - if (_ctrl.text.trim().isEmpty && _resultsFuture != null) { - setState(() => _resultsFuture = null); - } - }); - } - - @override - void dispose() { - _ctrl.dispose(); - super.dispose(); - } - - // Assigns the future only; callers trigger the rebuild (initState - // runs before first build, so setState here would be a no-op/warn). - void _loadSuggestions() { - _suggestionsFuture = ref - .read(_discoverApiProvider.future) - .then((api) => api.listSuggestions()); - } - - Future _requestSuggestion(ArtistSuggestion s) async { - final fs = Theme.of(context).extension()!; - final args = { - 'kind': LidarrRequestKind.artist.wire, - 'artistMbid': s.mbid, - 'artistName': s.name, - 'albumMbid': null, - 'albumTitle': null, - }; - try { - final api = await ref.read(_discoverApiProvider.future); - await api.createRequest( - kind: LidarrRequestKind.artist, - artistMbid: s.mbid, - artistName: s.name, - ); - if (mounted) { - // Reassign the future first; the setState below rebuilds and - // the FutureBuilder picks up the fresh fetch (server now - // filters this candidate out). _requested hides it meanwhile. - _loadSuggestions(); - setState(() => _requested.add(s.mbid)); - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Requested: ${s.name}'), - backgroundColor: fs.iron, - )); - } - } on DioException catch (_) { - await ref - .read(mutationQueueProvider) - .enqueue(MutationKinds.requestCreate, args); - if (mounted) { - setState(() => _requested.add(s.mbid)); - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Request queued: ${s.name}'), - backgroundColor: fs.iron, - )); - } - } - } - - void _runSearch() { - final q = _ctrl.text.trim(); - if (q.isEmpty) { - setState(() => _resultsFuture = null); - return; - } - setState(() { - _resultsFuture = ref - .read(_discoverApiProvider.future) - .then((api) => api.search(q, _kind)); - }); - } - - Future _request(LidarrSearchResult row) async { - final fs = Theme.of(context).extension()!; - final args = { - 'kind': _kind.wire, - 'artistMbid': - _kind == LidarrRequestKind.artist ? row.mbid : row.artistMbid, - 'artistName': - _kind == LidarrRequestKind.artist ? row.name : row.secondaryText, - 'albumMbid': _kind == LidarrRequestKind.album ? row.mbid : null, - 'albumTitle': _kind == LidarrRequestKind.album ? row.name : null, - }; - try { - final api = await ref.read(_discoverApiProvider.future); - await api.createRequest( - kind: _kind, - artistMbid: args['artistMbid'] as String, - artistName: args['artistName'] as String, - albumMbid: args['albumMbid'], - albumTitle: args['albumTitle'], - ); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Requested: ${row.name}'), - backgroundColor: fs.iron, - )); - // Re-run search to refresh the `requested` flag on the row. - _runSearch(); - } - } on DioException catch (_) { - // Queue for replay so the user's request persists across - // network loss. We don't have a drift table for in-flight - // requests yet, so the row won't show on the Requests screen - // until replay succeeds — accept that for v1. - await ref - .read(mutationQueueProvider) - .enqueue(MutationKinds.requestCreate, args); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Request queued: ${row.name}'), - backgroundColor: fs.iron, - )); - } - } - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - leading: IconButton( - icon: Icon(LucideIcons.arrow_left, color: fs.parchment), - onPressed: () => context.pop(), - ), - title: Text('Discover', style: TextStyle(color: fs.parchment)), - actions: const [MainAppBarActions(currentRoute: '/discover')], - ), - body: Column(children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), - child: Row(children: [ - Expanded( - child: TextField( - controller: _ctrl, - style: TextStyle(color: fs.parchment), - cursorColor: fs.accent, - onSubmitted: (_) => _runSearch(), - decoration: InputDecoration( - hintText: 'Search Lidarr for new music', - hintStyle: TextStyle(color: fs.ash), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: fs.iron), - ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide(color: fs.accent), - ), - suffixIcon: IconButton( - icon: Icon(LucideIcons.search, color: fs.ash), - onPressed: _runSearch, - ), - ), - ), - ), - ]), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: SegmentedButton( - segments: const [ - ButtonSegment(value: LidarrRequestKind.artist, label: Text('Artists')), - ButtonSegment(value: LidarrRequestKind.album, label: Text('Albums')), - ], - selected: {_kind}, - onSelectionChanged: (s) { - setState(() { - _kind = s.first; - if (_ctrl.text.trim().isNotEmpty) _runSearch(); - }); - }, - ), - ), - Expanded( - child: _resultsFuture == null - ? _buildSuggestions(fs) - : FutureBuilder>( - future: _resultsFuture, - builder: (ctx, snap) { - if (snap.connectionState != ConnectionState.done) { - return const Center(child: CircularProgressIndicator()); - } - if (snap.hasError) { - final err = snap.error; - final code = err is DioException - ? ApiError.fromDio(err).code - : 'unknown'; - return Center( - child: Text('Search failed: $code', - style: TextStyle(color: fs.error)), - ); - } - final rows = snap.data ?? const []; - if (rows.isEmpty) { - return Center( - child: Text('Nothing to add for that search yet.', - style: TextStyle(color: fs.ash), - textAlign: TextAlign.center), - ); - } - return ListView.separated( - itemCount: rows.length, - separatorBuilder: (_, __) => - Divider(height: 1, color: fs.iron), - itemBuilder: (ctx, i) => _ResultTile( - row: rows[i], - onRequest: () => _request(rows[i]), - ), - ); - }, - ), - ), - ]), - ); - } - - Widget _buildSuggestions(FabledSwordTheme fs) { - return FutureBuilder>( - future: _suggestionsFuture, - builder: (ctx, snap) { - if (snap.connectionState != ConnectionState.done) { - return const Center(child: CircularProgressIndicator()); - } - final items = (snap.data ?? const []) - .where((s) => !_requested.contains(s.mbid)) - .toList(growable: false); - return ListView( - padding: const EdgeInsets.only(bottom: 16), - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Suggested for you', - style: TextStyle( - color: fs.parchment, - fontSize: 20, - fontWeight: FontWeight.w500)), - const SizedBox(height: 2), - Text( - "Out-of-library artists drawn from what you've liked and played.", - style: TextStyle(color: fs.ash, fontSize: 13), - ), - ], - ), - ), - if (snap.hasError) - Padding( - padding: const EdgeInsets.all(16), - child: Text("Couldn't load suggestions.", - style: TextStyle(color: fs.ash)), - ) - else if (items.isEmpty) - Padding( - padding: const EdgeInsets.all(16), - child: Text( - 'Listen to something or like an artist to start getting suggestions.', - style: TextStyle(color: fs.ash), - ), - ) - else - ...items.map((s) => _SuggestionTile( - s: s, - onRequest: () => _requestSuggestion(s), - )), - ], - ); - }, - ); - } -} - -class _ResultTile extends StatelessWidget { - const _ResultTile({required this.row, required this.onRequest}); - final LidarrSearchResult row; - final VoidCallback onRequest; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final disabled = row.inLibrary || row.requested; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row(children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Container( - width: 56, - height: 56, - color: fs.slate, - child: row.imageUrl.isEmpty - ? Icon(LucideIcons.disc_3, color: fs.ash) - : CachedNetworkImage( - imageUrl: row.imageUrl, - fit: BoxFit.cover, - fadeInDuration: const Duration(milliseconds: 120), - fadeOutDuration: Duration.zero, - errorWidget: (_, __, ___) => - Icon(LucideIcons.disc_3, color: fs.ash), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(row.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14)), - if (row.secondaryText.isNotEmpty) - Text(row.secondaryText, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12)), - ], - ), - ), - const SizedBox(width: 8), - if (row.inLibrary) - _Pill(label: 'In library', color: fs.ash) - else if (row.requested) - _Pill(label: 'Requested', color: fs.ash) - else - FilledButton( - onPressed: disabled ? null : onRequest, - style: FilledButton.styleFrom( - backgroundColor: fs.accent, - foregroundColor: fs.parchment, - ), - child: const Text('Request'), - ), - ]), - ); - } -} - -class _Pill extends StatelessWidget { - const _Pill({required this.label, required this.color}); - final String label; - final Color color; - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: fs.iron, - borderRadius: BorderRadius.circular(4), - ), - child: Text(label, style: TextStyle(color: color, fontSize: 11)), - ); - } -} - -class _SuggestionTile extends StatelessWidget { - const _SuggestionTile({required this.s, required this.onRequest}); - final ArtistSuggestion s; - final VoidCallback onRequest; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row(children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Container( - width: 56, - height: 56, - color: fs.slate, - child: s.imageUrl.isEmpty - ? Icon(LucideIcons.user, color: fs.ash) - : CachedNetworkImage( - imageUrl: s.imageUrl, - fit: BoxFit.cover, - fadeInDuration: const Duration(milliseconds: 120), - fadeOutDuration: Duration.zero, - errorWidget: (_, __, ___) => - Icon(LucideIcons.user, color: fs.ash), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(s.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14)), - if (s.attributionText.isNotEmpty) - Text(s.attributionText, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12)), - ], - ), - ), - const SizedBox(width: 8), - FilledButton( - onPressed: onRequest, - style: FilledButton.styleFrom( - backgroundColor: fs.accent, - foregroundColor: fs.parchment, - ), - child: const Text('Request'), - ), - ]), - ); - } -} diff --git a/flutter_client/lib/library/album_detail_screen.dart b/flutter_client/lib/library/album_detail_screen.dart deleted file mode 100644 index 621a8fcd..00000000 --- a/flutter_client/lib/library/album_detail_screen.dart +++ /dev/null @@ -1,139 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/likes.dart'; -import '../models/album.dart'; -import '../likes/like_button.dart'; -import '../player/player_provider.dart'; -import '../shared/widgets/server_image.dart'; -import '../theme/theme_extension.dart'; -import 'library_providers.dart'; -import 'widgets/track_row.dart'; - -class AlbumDetailScreen extends ConsumerWidget { - const AlbumDetailScreen({required this.id, this.seed, super.key}); - final String id; - - /// Optional album reference passed by the caller (typically the - /// tile they tapped) so the header can render immediately while - /// the full provider loads tracks. Hydration only — provider data - /// still wins once it arrives. - final AlbumRef? seed; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final live = ref.watch(albumProvider(id)); - - // Pick the best header info available: live data > seed > nothing. - final headerTitle = live.value?.album.title.isNotEmpty == true - ? live.value!.album.title - : seed?.title ?? ''; - final headerArtist = live.value?.album.artistName.isNotEmpty == true - ? live.value!.album.artistName - : seed?.artistName ?? ''; - - Widget header() => Padding( - padding: const EdgeInsets.all(16), - child: Row(children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: SizedBox( - width: 96, - height: 96, - child: ServerImage( - url: '/api/albums/$id/cover', - fit: BoxFit.cover, - fallback: Container(color: fs.slate), - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - headerTitle, - style: TextStyle( - color: fs.parchment, - fontFamily: fs.display.fontFamily, - fontSize: 22, - ), - ), - if (headerArtist.isNotEmpty) - Text(headerArtist, style: TextStyle(color: fs.ash)), - ], - ), - ), - ]), - ); - - return Scaffold( - appBar: AppBar(), - backgroundColor: fs.obsidian, - body: live.when( - error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), - loading: () => seed != null - ? ListView(children: [ - header(), - const Padding( - padding: EdgeInsets.symmetric(vertical: 24), - child: Center(child: CircularProgressIndicator()), - ), - ]) - : const Center(child: CircularProgressIndicator()), - data: (r) => ListView(children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row(children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: SizedBox( - width: 96, - height: 96, - child: ServerImage( - url: '/api/albums/$id/cover', - fit: BoxFit.cover, - fallback: Container(color: fs.slate), - ), - ), - ), - const SizedBox(width: 16), - Expanded(child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(r.album.title, style: TextStyle( - color: fs.parchment, - fontFamily: fs.display.fontFamily, - fontSize: 22, - )), - Text(r.album.artistName, style: TextStyle(color: fs.ash)), - ], - )), - Container( - width: 48, height: 48, - decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle), - child: IconButton( - icon: Icon(LucideIcons.play, color: fs.parchment), - onPressed: () => ref.read(playerActionsProvider).playTracks(r.tracks), - ), - ), - LikeButton(kind: LikeKind.album, id: r.album.id, size: 28), - ]), - ), - for (final t in r.tracks) - TrackRow( - track: t, - onTap: () { - final start = r.tracks.indexOf(t); - ref.read(playerActionsProvider).playTracks(r.tracks, initialIndex: start); - }, - trailing: LikeButton(kind: LikeKind.track, id: t.id), - ), - ]), - ), - ); - } -} diff --git a/flutter_client/lib/library/artist_detail_screen.dart b/flutter_client/lib/library/artist_detail_screen.dart deleted file mode 100644 index f893c4fc..00000000 --- a/flutter_client/lib/library/artist_detail_screen.dart +++ /dev/null @@ -1,209 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../api/endpoints/likes.dart'; -import '../likes/like_button.dart'; -import '../models/album.dart'; -import '../models/artist.dart'; -import '../player/player_provider.dart'; -import '../shared/widgets/server_image.dart'; -import '../theme/theme_extension.dart'; -import 'library_providers.dart'; -import 'widgets/album_card.dart'; - -class ArtistDetailScreen extends ConsumerWidget { - const ArtistDetailScreen({required this.id, this.seed, super.key}); - final String id; - - /// Optional artist reference from the caller. Lets the screen render - /// the name + avatar immediately while albums load. - final ArtistRef? seed; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final artist = ref.watch(artistProvider(id)); - final albums = ref.watch(artistAlbumsProvider(id)); - - // Resolve which artist info to render in the header. Live wins - // when present and populated; seed fills the gap during the - // first frame after navigation. - final liveArtist = artist.value; - final hasLiveName = liveArtist != null && liveArtist.name.isNotEmpty; - final effective = hasLiveName ? liveArtist : (seed ?? liveArtist); - - return Scaffold( - appBar: AppBar(), - backgroundColor: fs.obsidian, - body: artist.when( - error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), - // While loading: render header from seed if available so the - // page isn't blank. - loading: () => seed == null - ? const Center(child: CircularProgressIndicator()) - : _artistBody(context, ref, seed!, albums, fs), - data: (_) => _artistBody(context, ref, effective ?? seed!, albums, fs), - ), - ); - } - - Widget _artistBody( - BuildContext context, - WidgetRef ref, - ArtistRef a, - AsyncValue> albums, - FabledSwordTheme fs, - ) => - ListView(children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row(children: [ - ClipOval( - child: SizedBox( - width: 96, - height: 96, - // Server derives artist cover from a representative - // album. Drift cache doesn't persist that pointer, so - // mirror the trick client-side: reuse the first album - // returned by artistAlbumsProvider. Falls back to - // slate while albums are loading or empty. - child: _ArtistAvatar( - serverCoverUrl: a.coverUrl, - albums: albums, - fs: fs, - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Text( - a.name, - style: TextStyle( - color: fs.parchment, - fontFamily: fs.display.fontFamily, - fontSize: 24, - ), - ), - ), - Container( - width: 48, height: 48, - decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle), - child: IconButton( - icon: Icon(LucideIcons.play, color: fs.parchment), - onPressed: () async { - try { - final tracks = - await ref.read(artistTracksProvider(id).future); - if (tracks.isEmpty) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'No tracks found for this artist yet.')), - ); - } - return; - } - final shuffled = [...tracks]..shuffle(); - await ref - .read(playerActionsProvider) - .playTracks(shuffled); - } catch (e) { - debugPrint('artist_detail: play failed: $e'); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Couldn't start playback: $e")), - ); - } - } - }, - ), - ), - LikeButton(kind: LikeKind.artist, id: a.id, size: 28), - ]), - ), - const Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('Albums', style: TextStyle(fontSize: 16)), - ), - albums.when( - error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), - loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator())), - data: (list) { - return LayoutBuilder(builder: (ctx, constraints) { - const cols = 3; - const sidePad = 8.0; - const gap = 8.0; - final cellW = - (constraints.maxWidth - sidePad * 2 - gap * (cols - 1)) / - cols; - // Card content: cover (cellW - 16) + 8 + title (≤2 lines - // ≈ 36) + slack. Artist line is suppressed in this - // grid (showArtist: false) since the page header already - // names the artist. Slack is generous on purpose — line- - // height variations would otherwise overflow by 1px. - final cellH = (cellW - 16) + 8 + 36 + 8; - return GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.fromLTRB(sidePad, 0, sidePad, 16), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: cols, - mainAxisExtent: cellH, - mainAxisSpacing: gap, - crossAxisSpacing: gap, - ), - itemCount: list.length, - itemBuilder: (_, i) { - final AlbumRef album = list[i]; - return AlbumCard( - album: album, - width: cellW, - titleMaxLines: 2, - showArtist: false, - onTap: () => - context.push('/albums/${album.id}', extra: album), - ); - }, - ); - }); - }, - ), - ]); -} - -/// Renders the artist's avatar. Server-emitted coverUrl wins when -/// non-empty; otherwise we mirror the server's "use the first album's -/// cover" rule client-side via the loaded album list. -class _ArtistAvatar extends StatelessWidget { - const _ArtistAvatar({ - required this.serverCoverUrl, - required this.albums, - required this.fs, - }); - final String serverCoverUrl; - final AsyncValue> albums; - final FabledSwordTheme fs; - - @override - Widget build(BuildContext context) { - if (serverCoverUrl.isNotEmpty) { - return ServerImage( - url: serverCoverUrl, - fit: BoxFit.cover, - fallback: Container(color: fs.slate), - ); - } - final firstId = albums.value?.isNotEmpty == true ? albums.value!.first.id : null; - if (firstId == null) { - return Container(color: fs.slate); - } - return ServerImage( - url: '/api/albums/$firstId/cover', - fit: BoxFit.cover, - fallback: Container(color: fs.slate), - ); - } -} diff --git a/flutter_client/lib/library/home_screen.dart b/flutter_client/lib/library/home_screen.dart deleted file mode 100644 index df488102..00000000 --- a/flutter_client/lib/library/home_screen.dart +++ /dev/null @@ -1,609 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../api/errors.dart'; -import '../cache/offline_provider.dart'; -import '../cache/shuffle_source.dart'; -import '../cache/tile_providers.dart'; -import '../models/playlist.dart'; -import '../models/system_playlists_status.dart'; -import '../models/track.dart'; -import '../player/player_provider.dart'; -import '../playlists/playlists_provider.dart'; -import '../playlists/widgets/playlist_card.dart'; -import '../playlists/widgets/playlist_placeholder_card.dart'; -import '../shared/widgets/connection_error_banner.dart'; -import '../shared/widgets/main_app_bar_actions.dart'; -import '../shared/widgets/skeletons.dart'; -import '../theme/theme_extension.dart'; -import 'library_providers.dart'; -import 'widgets/album_card.dart'; -import 'widgets/artist_card.dart'; -import 'widgets/compact_track_card.dart'; -import 'widgets/horizontal_scroll_row.dart'; - -/// Home screen. Per-item rendering: a tiny /api/home/index discovery -/// fetch returns just section→IDs, then each tile hydrates itself -/// against the per-entity drift tables (sync-populated) with REST -/// fallback via the HydrationQueue. Cold-visit dead air shrinks to a -/// single small round-trip; tiles materialize as their data lands. -class HomeScreen extends ConsumerWidget { - const HomeScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final index = ref.watch(homeIndexProvider); - final allPlaylists = ref.watch(playlistsListProvider('all')); - final status = ref.watch(systemPlaylistsStatusProvider); - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - title: Text('Minstrel', style: TextStyle(color: fs.parchment)), - actions: const [MainAppBarActions(currentRoute: '/home')], - ), - body: SafeArea( - child: index.when( - error: (e, _) { - final code = e is DioException ? ApiError.fromDio(e).code : 'unknown'; - if (code == 'connection_refused') { - return ConnectionErrorBanner( - onRetry: () => ref.refresh(homeIndexProvider), - ); - } - return Center(child: Text('$e', style: TextStyle(color: fs.error))); - }, - loading: () => _HomeSkeleton(fs: fs), - data: (h) => RefreshIndicator( - onRefresh: () async => ref.refresh(homeIndexProvider.future), - child: ListView( - physics: const ClampingScrollPhysics(), - children: [ - _PlaylistsSection( - playlists: allPlaylists.value?.owned ?? const [], - status: status.value ?? SystemPlaylistsStatus.empty(), - offline: ref.watch(offlineProvider), - ), - _RecentlyAddedSection(ids: h.recentlyAddedAlbums), - _RediscoverSection( - albumIds: h.rediscoverAlbums, - artistIds: h.rediscoverArtists, - ), - _MostPlayedSection(ids: h.mostPlayedTracks), - _LastPlayedSection(ids: h.lastPlayedArtists), - const SizedBox(height: 140), - ], - ), - ), - ), - ), - ); - } -} - -// ─── Per-tile widgets ──────────────────────────────────────────────── - -/// Duration of the skeleton→content cross-fade. 220ms reads as "tile -/// settled into place" — longer drags, shorter feels like a hard cut. -/// Each tile cross-fades independently when its data lands, so the -/// natural cascade from the hydration queue's bounded concurrency -/// produces a staged-reveal feel without any per-tile delay math. -const Duration _tileRevealDuration = Duration(milliseconds: 220); - -/// Album tile: skeleton until albumTileProvider yields a populated row. -class _AlbumTile extends ConsumerWidget { - const _AlbumTile({required this.id}); - final String id; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final asyncAlbum = ref.watch(albumTileProvider(id)); - final album = asyncAlbum.asData?.value; - return AnimatedSwitcher( - duration: _tileRevealDuration, - switchInCurve: Curves.easeOut, - child: album == null - ? const SkeletonAlbumTile(key: ValueKey('skeleton')) - : AlbumCard( - key: ValueKey('album-${album.id}'), - album: album, - onTap: () => - context.push('/albums/${album.id}', extra: album), - ), - ); - } -} - -/// Artist tile: skeleton until artistTileProvider yields a populated row. -class _ArtistTile extends ConsumerWidget { - const _ArtistTile({required this.id}); - final String id; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final asyncArtist = ref.watch(artistTileProvider(id)); - final artist = asyncArtist.asData?.value; - return AnimatedSwitcher( - duration: _tileRevealDuration, - switchInCurve: Curves.easeOut, - child: artist == null - ? const SkeletonArtistTile(key: ValueKey('skeleton')) - : ArtistCard( - key: ValueKey('artist-${artist.id}'), - artist: artist, - onTap: () => - context.push('/artists/${artist.id}', extra: artist), - ), - ); - } -} - -/// Track tile (used by Most-Played). On tap, gathers the currently- -/// hydrated TrackRefs for the whole section so playback flows like -/// it did with the bulk-loaded list. Tracks that haven't hydrated yet -/// are skipped from the play list — typically transient on a cold -/// visit since hydration runs in parallel and finishes quickly. -class _TrackTile extends ConsumerWidget { - const _TrackTile({required this.id, required this.sectionIds}); - final String id; - final List sectionIds; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final asyncTrack = ref.watch(trackTileProvider(id)); - final track = asyncTrack.asData?.value; - return AnimatedSwitcher( - duration: _tileRevealDuration, - switchInCurve: Curves.easeOut, - child: track == null - ? const _CompactTrackSkeleton(key: ValueKey('skeleton')) - : CompactTrackCard( - key: ValueKey('track-${track.id}'), - track: track, - sectionTracks: _resolveSectionTracks(ref, sectionIds), - index: - sectionIds.indexOf(id).clamp(0, sectionIds.length - 1), - ), - ); - } - - /// Snapshots the section's current track states. Used at tile - /// construction time — CompactTrackCard's onTap uses it as the play - /// queue. Tracks still hydrating are dropped; once they land, a - /// rebuild re-runs this lookup so the queue grows naturally. - static List _resolveSectionTracks(WidgetRef ref, List ids) { - final out = []; - for (final i in ids) { - final v = ref.read(trackTileProvider(i)).asData?.value; - if (v != null) out.add(v); - } - return out; - } -} - -/// Compact-track placeholder. 56dp cover + two text lines, matched to -/// CompactTrackCard so swapping in the real card doesn't shift the -/// row's height. -class _CompactTrackSkeleton extends StatelessWidget { - const _CompactTrackSkeleton({super.key}); - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Container( - width: 240, - margin: const EdgeInsets.symmetric(horizontal: 4), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Row(children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: fs.slate, - borderRadius: BorderRadius.circular(6), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container(width: 120, height: 12, color: fs.slate), - const SizedBox(height: 6), - Container(width: 80, height: 10, color: fs.slate), - ], - ), - ), - ]), - ); - } -} - -// ─── Sections ──────────────────────────────────────────────────────── - -class _PlaylistsSection extends StatelessWidget { - const _PlaylistsSection({ - required this.playlists, - required this.status, - required this.offline, - }); - final List playlists; - final SystemPlaylistsStatus status; - final bool offline; - - @override - Widget build(BuildContext context) { - final items = _buildPlaylistsRow(playlists, status); - final children = [ - // Offline: surface the cache-backed pools where the (now - // play-disabled) system playlists sit, so there's something - // to play. #427 S4b. - if (offline) ...const [ - _OfflinePoolCard( - label: 'Recently played', - icon: LucideIcons.history, - kind: _OfflinePoolKind.recentlyPlayed, - ), - _OfflinePoolCard( - label: 'Liked', - icon: LucideIcons.heart, - kind: _OfflinePoolKind.liked, - ), - ], - for (final item in items) - if (item is _RealPlaylist) - PlaylistCard(playlist: item.playlist) - else - PlaylistPlaceholderCard( - label: (item as _PlaceholderPlaylist).label, - variant: item.variant, - ), - ]; - return HorizontalScrollRow( - title: 'Playlists', - height: 220, - children: children, - ); - } -} - -enum _OfflinePoolKind { recentlyPlayed, liked } - -/// Home tile for an offline cache-backed pool. Tapping shuffles + -/// plays that pool from the local cache. Sized to match -/// PlaylistCard so the row stays visually consistent. -class _OfflinePoolCard extends ConsumerWidget { - const _OfflinePoolCard({ - required this.label, - required this.icon, - required this.kind, - }); - final String label; - final IconData icon; - final _OfflinePoolKind kind; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - return SizedBox( - width: 176, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () async { - final messenger = ScaffoldMessenger.of(context); - final src = ref.read(shuffleSourceProvider); - final refs = await switch (kind) { - _OfflinePoolKind.recentlyPlayed => src.recentlyPlayed(), - _OfflinePoolKind.liked => src.liked(), - }; - if (refs.isEmpty) { - messenger.showSnackBar( - SnackBar(content: Text('No cached $label tracks yet')), - ); - return; - } - await ref - .read(playerActionsProvider) - .playTracks(refs, shuffle: true); - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 144, - height: 144, - decoration: BoxDecoration( - color: fs.slate, - borderRadius: BorderRadius.circular(6), - ), - child: Icon(icon, color: fs.accent, size: 56), - ), - const SizedBox(height: 8), - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14), - ), - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - 'Offline', - style: TextStyle(color: fs.ash, fontSize: 11), - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -abstract class _PlaylistRowItem {} -class _RealPlaylist extends _PlaylistRowItem { - _RealPlaylist(this.playlist); - final Playlist playlist; -} -class _PlaceholderPlaylist extends _PlaylistRowItem { - _PlaceholderPlaylist(this.label, this.variant); - final String label; - final String variant; -} - -List<_PlaylistRowItem> _buildPlaylistsRow( - List ownedAll, - SystemPlaylistsStatus status, -) { - final out = <_PlaylistRowItem>[]; - - Playlist? findFirst(bool Function(Playlist) test) { - for (final p in ownedAll) { - if (test(p)) return p; - } - return null; - } - - final forYou = findFirst((p) => p.systemVariant == 'for_you'); - out.add(forYou != null - ? _RealPlaylist(forYou) - : _PlaceholderPlaylist('For You', _variantFor('for-you', status))); - - final discover = findFirst((p) => p.systemVariant == 'discover'); - out.add(discover != null - ? _RealPlaylist(discover) - : _PlaceholderPlaylist('Discover', _variantFor('discover', status))); - - final songsLike = ownedAll - .where((p) => p.systemVariant == 'songs_like_artist') - .take(3) - .toList(); - for (var i = 0; i < 3; i++) { - out.add(i < songsLike.length - ? _RealPlaylist(songsLike[i]) - : _PlaceholderPlaylist('Songs like…', _variantFor('songs-like', status))); - } - - for (final p in ownedAll.where((p) => p.systemVariant == null)) { - out.add(_RealPlaylist(p)); - } - - return out; -} - -String _variantFor(String slot, SystemPlaylistsStatus s) { - if (s.inFlight) return 'building'; - if (s.lastError != null) return 'failed'; - if (slot == 'songs-like' && s.lastRunAt != null) return 'seed-needed'; - return 'pending'; -} - -class _RecentlyAddedSection extends StatelessWidget { - const _RecentlyAddedSection({required this.ids}); - final List ids; - - @override - Widget build(BuildContext context) { - if (ids.isEmpty) { - return const _EmptySection( - title: 'Recently added', - message: "Nothing added yet. Scan a folder via the server's config.", - ); - } - final controller = ScrollController(); - final rows = _chunk(ids, 25); - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (var i = 0; i < rows.length; i++) - HorizontalScrollRow( - title: i == 0 ? 'Recently added' : '', - controller: controller, - children: rows[i].map((id) => _AlbumTile(id: id)).toList(), - ), - ]); - } -} - -class _RediscoverSection extends StatelessWidget { - const _RediscoverSection({required this.albumIds, required this.artistIds}); - final List albumIds; - final List artistIds; - - @override - Widget build(BuildContext context) { - if (albumIds.isEmpty && artistIds.isEmpty) { - return const _EmptySection( - title: 'Rediscover', - message: - 'No forgotten favourites yet. Like some albums or artists to fill this in.', - ); - } - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (albumIds.isNotEmpty) - HorizontalScrollRow( - title: 'Rediscover', - children: albumIds.map((id) => _AlbumTile(id: id)).toList(), - ), - if (artistIds.isNotEmpty) - HorizontalScrollRow( - title: albumIds.isEmpty ? 'Rediscover' : '', - height: 168, - children: artistIds.map((id) => _ArtistTile(id: id)).toList(), - ), - ]); - } -} - -class _MostPlayedSection extends StatelessWidget { - const _MostPlayedSection({required this.ids}); - final List ids; - - @override - Widget build(BuildContext context) { - if (ids.isEmpty) { - return const _EmptySection( - title: 'Most played', - message: 'No plays to draw from. Listen to something.', - ); - } - final controller = ScrollController(); - final rows = _chunk(ids, 25); - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (var i = 0; i < rows.length; i++) - HorizontalScrollRow( - title: i == 0 ? 'Most played' : '', - height: 64, - controller: controller, - children: [ - for (final id in rows[i]) _TrackTile(id: id, sectionIds: ids), - ], - ), - ]); - } -} - -class _LastPlayedSection extends StatelessWidget { - const _LastPlayedSection({required this.ids}); - final List ids; - - @override - Widget build(BuildContext context) { - if (ids.isEmpty) { - return const _EmptySection( - title: 'Last played', - message: 'No recent plays.', - ); - } - return HorizontalScrollRow( - title: 'Last played', - height: 168, - children: ids.map((id) => _ArtistTile(id: id)).toList(), - ); - } -} - -class _EmptySection extends StatelessWidget { - const _EmptySection({required this.title, required this.message}); - final String title; - final String message; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: TextStyle( - fontFamily: fs.display.fontFamily, - fontSize: 18, - color: fs.parchment, - ), - ), - const SizedBox(height: 8), - Text(message, style: TextStyle(color: fs.ash)), - ]), - ); - } -} - -/// Cold-start skeleton — shown only between mount and the first -/// homeIndexProvider emission (typically a single drift query + small -/// REST round-trip). Each section then renders its own per-tile -/// skeletons internally, so this widget's role is just the very first -/// pre-discovery frame. -class _HomeSkeleton extends StatelessWidget { - const _HomeSkeleton({required this.fs}); - final FabledSwordTheme fs; - - @override - Widget build(BuildContext context) { - return ListView( - physics: const ClampingScrollPhysics(), - children: [ - _SkeletonSection(fs: fs, title: 'Playlists', cardWidth: 176), - _SkeletonSection(fs: fs, title: 'Recently added', cardWidth: 140), - _SkeletonSection(fs: fs, title: 'Rediscover', cardWidth: 140), - _SkeletonSection(fs: fs, title: 'Most played', cardWidth: 140), - _SkeletonSection(fs: fs, title: 'Last played', cardWidth: 140), - const SizedBox(height: 140), - ], - ); - } -} - -class _SkeletonSection extends StatelessWidget { - const _SkeletonSection({ - required this.fs, - required this.title, - required this.cardWidth, - }); - final FabledSwordTheme fs; - final String title; - final double cardWidth; - - @override - Widget build(BuildContext context) { - final coverSize = cardWidth - 16; - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Text( - title, - style: TextStyle( - fontFamily: fs.display.fontFamily, - fontSize: 18, - color: fs.parchment, - ), - ), - ), - SizedBox( - height: coverSize + 40, - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - physics: const NeverScrollableScrollPhysics(), - itemCount: 6, - itemBuilder: (_, __) => SkeletonAlbumTile(width: cardWidth), - ), - ), - ]); - } -} - -List> _chunk(List items, int size) { - final out = >[]; - for (var i = 0; i < items.length; i += size) { - out.add(items.sublist(i, i + size > items.length ? items.length : i + size)); - } - return out; -} diff --git a/flutter_client/lib/library/library_providers.dart b/flutter_client/lib/library/library_providers.dart deleted file mode 100644 index b97e5946..00000000 --- a/flutter_client/lib/library/library_providers.dart +++ /dev/null @@ -1,407 +0,0 @@ -import 'dart:async'; - -import 'package:dio/dio.dart'; -import 'package:drift/drift.dart' as drift; -import 'package:flutter/foundation.dart' show debugPrint; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/client.dart'; -import '../api/endpoints/library.dart'; -import '../auth/auth_provider.dart'; -import '../cache/adapters.dart'; -import '../cache/audio_cache_manager.dart' show appDbProvider; -import '../cache/cache_first.dart'; -import '../cache/connectivity_provider.dart'; -import '../cache/db.dart'; -import '../models/album.dart'; -import '../models/artist.dart'; -import '../models/home_index.dart'; -import '../models/track.dart'; - -/// Shared authenticated dio. This is the ONLY place tokenResolver is wired -/// to the secure-storage `session_token` read — every other endpoint -/// awaits `dioProvider.future` rather than constructing its own dio. -/// -/// Riverpod will invalidate any FutureProvider that watches this when -/// the server URL changes; auth state changes don't need to invalidate -/// the provider because the resolver re-reads the token on every request. -final dioProvider = FutureProvider((ref) async { - final url = await ref.watch(serverUrlProvider.future); - if (url == null || url.isEmpty) { - throw StateError('no server URL set'); - } - final storage = ref.watch(secureStorageProvider); - return ApiClient.buildDio( - baseUrl: url, - tokenResolver: () async => storage.read(key: 'session_token'), - on401: () async => ref.read(authControllerProvider.notifier).clearSession(), - ); -}); - -final libraryApiProvider = FutureProvider((ref) async { - return LibraryApi(await ref.watch(dioProvider.future)); -}); - -/// Drift-first per-item home index. Reads from cached_home_index -/// (populated by /api/home/index discovery) and yields a HomeIndex -/// the screen consumes to lay out section→tile slots. Each tile is -/// rendered by a per-entity tile provider that hydrates itself. -/// -/// Section keys mirror the server's response shape so the encode / -/// decode round-trip is straightforward — the table stores -/// (section, position, entityType, entityId), and toResult reassembles -/// the parallel ID lists. -final homeIndexProvider = StreamProvider((ref) { - final db = ref.watch(appDbProvider); - final query = (db.select(db.cachedHomeIndex) - ..orderBy([ - (t) => drift.OrderingTerm.asc(t.section), - (t) => drift.OrderingTerm.asc(t.position), - ])) - .watch(); - return cacheFirst( - driftStream: query, - fetchAndPopulate: () async { - final api = await ref.read(libraryApiProvider.future); - final fresh = await api.getHomeIndex(); - // Full replace in a transaction so the watch() sees exactly one - // post-fetch emission with the merged state. Section ordering is - // re-asserted on read (orderBy above) so the write order doesn't - // matter — letting us batch flat instead of section-by-section. - await db.transaction(() async { - await db.delete(db.cachedHomeIndex).go(); - await db.batch((b) { - void rows(String section, String entityType, List ids) { - for (var i = 0; i < ids.length; i++) { - b.insert( - db.cachedHomeIndex, - CachedHomeIndexCompanion.insert( - section: section, - position: i, - entityType: entityType, - entityId: ids[i], - ), - ); - } - } - - rows('recently_added_albums', 'album', fresh.recentlyAddedAlbums); - rows('rediscover_albums', 'album', fresh.rediscoverAlbums); - rows('rediscover_artists', 'artist', fresh.rediscoverArtists); - rows('most_played_tracks', 'track', fresh.mostPlayedTracks); - rows('last_played_artists', 'artist', fresh.lastPlayedArtists); - }); - }); - }, - toResult: (rows) { - final recentlyAddedAlbums = []; - final rediscoverAlbums = []; - final rediscoverArtists = []; - final mostPlayedTracks = []; - final lastPlayedArtists = []; - for (final r in rows) { - switch (r.section) { - case 'recently_added_albums': - recentlyAddedAlbums.add(r.entityId); - case 'rediscover_albums': - rediscoverAlbums.add(r.entityId); - case 'rediscover_artists': - rediscoverArtists.add(r.entityId); - case 'most_played_tracks': - mostPlayedTracks.add(r.entityId); - case 'last_played_artists': - lastPlayedArtists.add(r.entityId); - } - } - return HomeIndex( - recentlyAddedAlbums: recentlyAddedAlbums, - rediscoverAlbums: rediscoverAlbums, - rediscoverArtists: rediscoverArtists, - mostPlayedTracks: mostPlayedTracks, - lastPlayedArtists: lastPlayedArtists, - ); - }, - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - // SWR: cached layout shows instantly; fresh /api/home/index lands - // in the background and tiles re-resolve as the table mutates. - alwaysRefresh: true, - tag: 'homeIndex', - ); -}); - -/// Drift-first per #357 plan C. Watches cached_artists for the row; -/// when empty + online, fetches via REST + populates drift, which -/// re-emits via watch(). -final artistProvider = - StreamProvider.family((ref, id) { - final db = ref.watch(appDbProvider); - // LEFT JOIN cached_albums for cover-URL reconstruction (see - // CachedArtistAdapter.toRef). First row carries the alphabetically- - // first album. - final query = (db.select(db.cachedArtists)..where((t) => t.id.equals(id))) - .join([ - drift.leftOuterJoin(db.cachedAlbums, - db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)), - ]) - ..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]); - return cacheFirst( - driftStream: query.watch(), - fetchAndPopulate: () async { - final api = await ref.read(libraryApiProvider.future); - final fresh = await api.getArtist(id); - await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift()); - }, - toResult: (rows) { - if (rows.isEmpty) return const ArtistRef(id: '', name: ''); - final artist = rows.first.readTable(db.cachedArtists); - final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums); - return artist.toRef(coverAlbumId: firstAlbum?.id ?? ''); - }, - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - // No alwaysRefresh: artist rows don't change frequently, and the - // metadata prefetcher creates many subscriptions in parallel — - // each silently re-fetching once would saturate the request - // pipeline behind the user's actual playback request. - tag: 'artist($id)', - ); -}); - -final artistAlbumsProvider = - StreamProvider.family, String>((ref, artistId) { - final db = ref.watch(appDbProvider); - // Join cached_albums + cached_artists to fill artist_name on each row. - final query = db.select(db.cachedAlbums).join([ - drift.leftOuterJoin(db.cachedArtists, - db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)), - ])..where(db.cachedAlbums.artistId.equals(artistId)); - - return cacheFirst>( - driftStream: query.watch(), - fetchAndPopulate: () async { - final api = await ref.read(libraryApiProvider.future); - final fresh = await api.getArtistAlbums(artistId); - await db.batch((b) { - b.insertAllOnConflictUpdate( - db.cachedAlbums, fresh.map((a) => a.toDrift()).toList()); - }); - }, - toResult: (rows) => rows.map((r) { - final album = r.readTable(db.cachedAlbums); - final artist = r.readTableOrNull(db.cachedArtists); - return album.toRef(artistName: artist?.name ?? ''); - }).toList(), - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - // SWR: re-fetch the full album list on every visit. Without this, - // a previously-incomplete drift cache (e.g. user had only opened - // one album by this artist, so cachedAlbums had just that row) - // would render forever as a partial list. The prefetcher only - // warms artistProvider (single row), so this provider isn't - // mass-instantiated and the storm risk that motivated dropping - // alwaysRefresh elsewhere doesn't apply here. - alwaysRefresh: true, - tag: 'artistAlbums($artistId)', - ); -}); - -final artistTracksProvider = - StreamProvider.family, String>((ref, artistId) { - final db = ref.watch(appDbProvider); - final query = db.select(db.cachedTracks).join([ - drift.leftOuterJoin(db.cachedArtists, - db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)), - drift.leftOuterJoin(db.cachedAlbums, - db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)), - ])..where(db.cachedTracks.artistId.equals(artistId)); - - return cacheFirst>( - driftStream: query.watch(), - fetchAndPopulate: () async { - final api = await ref.read(libraryApiProvider.future); - final fresh = await api.getArtistTracks(artistId); - await db.batch((b) { - b.insertAllOnConflictUpdate( - db.cachedTracks, fresh.map((t) => t.toDrift()).toList()); - }); - }, - toResult: (rows) => rows.map((r) { - final track = r.readTable(db.cachedTracks); - final artist = r.readTableOrNull(db.cachedArtists); - final album = r.readTableOrNull(db.cachedAlbums); - return track.toRef( - artistName: artist?.name ?? '', - albumTitle: album?.title ?? '', - ); - }).toList(), - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - tag: 'artistTracks($artistId)', - ); -}); - -/// Composite shape (album + tracks) — uses async* + Stream.combineLatest -/// for reactive updates over both rows. -final albumProvider = StreamProvider.family< - ({AlbumRef album, List tracks}), String>((ref, albumId) async* { - final db = ref.watch(appDbProvider); - - final albumQuery = (db.select(db.cachedAlbums) - ..where((t) => t.id.equals(albumId))) - .join([ - drift.leftOuterJoin(db.cachedArtists, - db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)), - ]); - - final tracksQuery = (db.select(db.cachedTracks) - ..where((t) => t.albumId.equals(albumId)) - ..orderBy([ - (t) => drift.OrderingTerm.asc(t.discNumber), - (t) => drift.OrderingTerm.asc(t.trackNumber), - ])) - .join([ - drift.leftOuterJoin(db.cachedArtists, - db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)), - ]); - - // Once-per-subscription guard so we don't re-fetch in a loop if the - // server genuinely returns zero tracks (or if the fetch fails). - var fetchAttempted = false; - // (revalidated flag removed; see SWR note below the yield.) - - Future isOnline() async { - try { - return await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true); - } catch (_) { - return true; - } - } - - // Cold-cache fetch: pulls the album + its tracks + any unique - // artists referenced and writes all three tables in one batch. - // Returns true on success (drift watch will re-emit), false on - // failure so the caller can yield an empty result. - Future fetchAndPopulate() async { - try { - final api = await ref.read(libraryApiProvider.future); - final fresh = await api - .getAlbum(albumId) - .timeout(const Duration(seconds: 10)); - // Collect every artist mentioned by the album + its tracks so - // the JOINs that drive both the album header and the track rows - // have something to bind to. Without this, drift returns null - // for the artist row and artistName surfaces empty — visible - // as a missing artist line in the mini player and row list. - final artists = {}; - if (fresh.album.artistId.isNotEmpty && - fresh.album.artistName.isNotEmpty) { - artists[fresh.album.artistId] = ArtistRef( - id: fresh.album.artistId, - name: fresh.album.artistName, - ); - } - for (final t in fresh.tracks) { - if (t.artistId.isNotEmpty && - t.artistName.isNotEmpty && - !artists.containsKey(t.artistId)) { - artists[t.artistId] = ArtistRef( - id: t.artistId, - name: t.artistName, - ); - } - } - await db.batch((b) { - if (artists.isNotEmpty) { - b.insertAllOnConflictUpdate( - db.cachedArtists, artists.values.map((a) => a.toDrift()).toList()); - } - b.insertAllOnConflictUpdate(db.cachedAlbums, [fresh.album.toDrift()]); - b.insertAllOnConflictUpdate(db.cachedTracks, - fresh.tracks.map((t) => t.toDrift()).toList()); - }); - return true; - } catch (e, st) { - debugPrint('albumProvider($albumId): cold-cache fetch failed: $e\n$st'); - return false; - } - } - - await for (final albumRows in albumQuery.watch()) { - // Case 1: no album row at all → cold-fetch. - if (albumRows.isEmpty) { - if (fetchAttempted) { - // Already tried and got nothing back. - yield ( - album: const AlbumRef(id: '', title: '', artistId: ''), - tracks: const [], - ); - continue; - } - fetchAttempted = true; - if (!await isOnline()) { - yield ( - album: const AlbumRef(id: '', title: '', artistId: ''), - tracks: const [], - ); - continue; - } - final ok = await fetchAndPopulate(); - if (!ok) { - yield ( - album: const AlbumRef(id: '', title: '', artistId: ''), - tracks: const [], - ); - } - // On success, drift watch re-emits with rows; loop continues. - continue; - } - - final albumRow = albumRows.first; - final album = albumRow.readTable(db.cachedAlbums).toRef( - artistName: albumRow.readTableOrNull(db.cachedArtists)?.name ?? '', - ); - - final trackRows = await tracksQuery.get(); - - // Case 2: album row exists but no tracks. This happens when an - // upstream provider (artistAlbumsProvider, sync, etc.) populated - // album rows without their track lists. Trigger the same - // fetchAndPopulate so the album becomes complete; drift watch - // re-emits and we land in the populated branch on the next pass. - if (trackRows.isEmpty && !fetchAttempted) { - fetchAttempted = true; - if (await isOnline()) { - final ok = await fetchAndPopulate(); - if (ok) continue; // wait for watch re-emit - } - // Fall through and yield with the album + empty tracks if the - // fetch failed or we're offline. - } - - final tracks = trackRows.map((r) { - final track = r.readTable(db.cachedTracks); - final artist = r.readTableOrNull(db.cachedArtists); - return track.toRef( - artistName: artist?.name ?? '', - albumTitle: album.title, - ); - }).toList(); - - // Note: NO SWR here on purpose. Prior code kicked a background - // refresh on every first cache hit, which combined with the - // metadata prefetcher meant every prewarmed album id triggered - // an extra fetch even when drift was already populated. Tracks - // and album metadata don't change on the same timescale as - // playlists; a stale read is fine until the user invalidates - // (pull-to-refresh) or the album is genuinely re-fetched. - - yield (album: album, tracks: tracks); - } -}); diff --git a/flutter_client/lib/library/library_screen.dart b/flutter_client/lib/library/library_screen.dart deleted file mode 100644 index 4695bccb..00000000 --- a/flutter_client/lib/library/library_screen.dart +++ /dev/null @@ -1,936 +0,0 @@ -import 'dart:convert'; - -import 'package:drift/drift.dart' as drift; -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../api/endpoints/library_lists.dart'; -import '../api/endpoints/likes.dart'; -import '../api/endpoints/me.dart'; -import '../auth/auth_provider.dart' show authControllerProvider; -import '../cache/adapters.dart'; -import '../cache/audio_cache_manager.dart' show appDbProvider; -import '../cache/cache_first.dart'; -import '../cache/connectivity_provider.dart'; -import '../cache/db.dart'; -import '../cache/metadata_prefetcher.dart'; -import '../cache/shuffle_source.dart'; -import '../cache/tile_providers.dart'; -import '../library/library_providers.dart' show dioProvider; -import '../models/album.dart'; -import '../models/artist.dart'; -import '../models/history_event.dart'; -import '../models/quarantine_mine.dart'; -import '../models/track.dart'; -import '../player/player_provider.dart'; -import '../quarantine/quarantine_provider.dart'; -import '../shared/live_events_provider.dart'; -import '../shared/widgets/main_app_bar_actions.dart'; -import '../shared/widgets/skeletons.dart'; -import '../shared/widgets/server_image.dart'; -import '../theme/theme_extension.dart'; -import 'widgets/album_card.dart'; -import 'widgets/artist_card.dart'; -import 'widgets/track_row.dart'; - -// Providers scoped to this screen. Each tab gets its own first page. -// Infinite scroll is a future enhancement; for v1 phone testing the -// first 50 rows per tab is enough. - -final _libraryListsApiProvider = FutureProvider((ref) async { - return LibraryListsApi(await ref.watch(dioProvider.future)); -}); - -final _likesApiProvider = FutureProvider((ref) async { - return LikesApi(await ref.watch(dioProvider.future)); -}); - -final _meApiProvider = FutureProvider((ref) async { - return MeApi(await ref.watch(dioProvider.future)); -}); - -/// Drift-first all-artists list. Reads cached_artists ordered by -/// sortName; GridView.builder takes care of lazy widget construction -/// so loading the full set up front is fine for typical library sizes. -/// SWR refresh on every subscription hits /api/artists with a generous -/// limit so newly-scanned-but-not-yet-synced rows land soon after. -/// -/// The old AsyncNotifier+loadMore infinite-scroll path went away: the -/// previous "fetch one page at a time as you scroll" felt like the -/// rest of the app was buffering when this tab was the slow surface, -/// and SyncController already populates the full set of artist rows -/// via /api/library/sync. -final _libraryArtistsProvider = StreamProvider>((ref) { - final db = ref.watch(appDbProvider); - // LEFT JOIN cached_albums so each artist row carries a representative - // album id for cover-URL reconstruction. Sorted by artist sortName - // then album sortTitle: the first row per artist gets the - // alphabetically-first album. Dedup happens in toResult. - final query = db.select(db.cachedArtists).join([ - drift.leftOuterJoin(db.cachedAlbums, - db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)), - ]) - ..orderBy([ - drift.OrderingTerm.asc(db.cachedArtists.sortName), - drift.OrderingTerm.asc(db.cachedAlbums.sortTitle), - ]); - return cacheFirst>( - driftStream: query.watch(), - fetchAndPopulate: () async { - // Cold-cache fallback: sync should have populated drift already, - // but a fresh install + first Library visit before sync completes - // will be empty. Fetch a generous chunk via /api/artists and - // persist; subsequent SWR refreshes keep things current. - final api = await ref.read(_libraryListsApiProvider.future); - final fresh = await api.listArtists(limit: 1000, offset: 0); - await db.batch((b) { - b.insertAllOnConflictUpdate( - db.cachedArtists, - fresh.items.map((a) => a.toDrift()).toList(), - ); - }); - }, - toResult: (rows) { - // The join multiplies rows by album count; dedup by artist id - // keeping the first occurrence so we get one ArtistRef per - // artist with the alphabetically-first album's id for the - // cover-URL projection. Artists with no albums in drift yet - // come through as a single row with null cachedAlbums and an - // empty coverUrl — UI falls back to the placeholder icon. - final seen = {}; - final out = []; - for (final r in rows) { - final a = r.readTable(db.cachedArtists); - if (!seen.add(a.id)) continue; - final album = r.readTableOrNull(db.cachedAlbums); - out.add(a.toRef(coverAlbumId: album?.id ?? '')); - } - return out; - }, - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - alwaysRefresh: true, - tag: 'libraryArtists', - ); -}); - -/// Drift-first all-albums list. Joins cached_albums with cached_artists -/// for the artistName field. Same cold-cache fallback + SWR refresh -/// pattern as libraryArtistsProvider. -final _libraryAlbumsProvider = StreamProvider>((ref) { - final db = ref.watch(appDbProvider); - final query = db.select(db.cachedAlbums).join([ - drift.leftOuterJoin(db.cachedArtists, - db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)), - ]) - ..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]); - return cacheFirst>( - driftStream: query.watch(), - fetchAndPopulate: () async { - final api = await ref.read(_libraryListsApiProvider.future); - final fresh = await api.listAlbums(limit: 1000, offset: 0); - await db.batch((b) { - b.insertAllOnConflictUpdate( - db.cachedAlbums, - fresh.items.map((a) => a.toDrift()).toList(), - ); - }); - }, - toResult: (rows) => rows.map((r) { - final album = r.readTable(db.cachedAlbums); - final artist = r.readTableOrNull(db.cachedArtists); - return album.toRef(artistName: artist?.name ?? ''); - }).toList(), - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - alwaysRefresh: true, - tag: 'libraryAlbums', - ); -}); - -// Drift-first History tab. Mirrors homeProvider's pattern: store the -// last /api/me/history page as JSON in a single-row drift table, yield -// it immediately on subscribe (so the tab paints from disk on cold -// open), then SWR-refresh in the background. Also gives basic offline -// scrollback — the last fetched page survives connectivity loss. -// -// JSON blob (vs columnar) because the page is small, always read whole, -// and the HistoryPage.fromJson constructor already accepts the wire -// shape — no schema-evolution pain when server-side fields change. -final _historyProvider = StreamProvider((ref) { - final db = ref.watch(appDbProvider); - return cacheFirst( - driftStream: db.select(db.cachedHistorySnapshot).watch(), - fetchAndPopulate: () async { - final api = await ref.read(_meApiProvider.future); - final fresh = await api.history(); - await db.into(db.cachedHistorySnapshot).insertOnConflictUpdate( - CachedHistorySnapshotCompanion.insert( - json: _encodeHistoryPage(fresh), - updatedAt: drift.Value(DateTime.now()), - ), - ); - }, - toResult: (rows) => rows.isEmpty - ? const HistoryPage(events: [], hasMore: false) - : HistoryPage.fromJson( - jsonDecode(rows.first.json) as Map), - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - // SWR: yield cache instantly, then refresh in the background so the - // tab reflects the freshest plays. Matches homeProvider behavior. - alwaysRefresh: true, - tag: 'history', - ); -}); - -/// Encodes HistoryPage back to the wire-format JSON shape -/// /api/me/history emits, so HistoryPage.fromJson can round-trip -/// through the drift cache. -String _encodeHistoryPage(HistoryPage h) => jsonEncode({ - 'events': h.events - .map((e) => { - 'id': e.id, - 'played_at': e.playedAt, - 'track': { - 'id': e.track.id, - 'title': e.track.title, - 'album_id': e.track.albumId, - 'album_title': e.track.albumTitle, - 'artist_id': e.track.artistId, - 'artist_name': e.track.artistName, - 'track_number': e.track.trackNumber, - 'disc_number': e.track.discNumber, - 'duration_sec': e.track.durationSec, - 'stream_url': e.track.streamUrl, - }, - }) - .toList(), - 'has_more': h.hasMore, - }); - -// Per-item Liked tabs (Slice E of the per-item rendering pass). -// Each provider yields just the ordered list of entity IDs; the UI -// then renders per-tile widgets that hydrate each entity individually -// via albumTileProvider / artistTileProvider / trackTileProvider. -// -// Reads come from cached_likes (sync- and optimistic-write-populated), -// projected with ORDER BY likedAt DESC. fetchAndPopulate hits the -// cheap /api/likes/ids endpoint — the bulk /api/likes/* endpoints -// that returned fully denormalized entities are no longer needed for -// this path since tile providers handle entity hydration themselves. -// -// likedAt ordering note: cached_likes.likedAt is whatever drift -// assigned via currentDateAndTime when the row was first inserted -// (either by sync or LikesController). insertOrIgnore on subsequent -// fetches preserves the existing likedAt so ordering stays stable. -// Approximate but acceptable — matches prior Slice 3 behavior. - -List _idsForEntity(List rows) => - rows.map((r) => r.entityId).toList(growable: false); - -final _likedTrackIdsProvider = StreamProvider>((ref) { - final db = ref.watch(appDbProvider); - final query = (db.select(db.cachedLikes) - ..where((t) => t.entityType.equals('track')) - ..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)])) - .watch(); - return cacheFirst>( - driftStream: query, - fetchAndPopulate: () => _populateLikeIds(ref), - toResult: _idsForEntity, - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - alwaysRefresh: true, - tag: 'likedTrackIds', - ); -}); - -final _likedAlbumIdsProvider = StreamProvider>((ref) { - final db = ref.watch(appDbProvider); - final query = (db.select(db.cachedLikes) - ..where((t) => t.entityType.equals('album')) - ..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)])) - .watch(); - return cacheFirst>( - driftStream: query, - fetchAndPopulate: () => _populateLikeIds(ref), - toResult: _idsForEntity, - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - alwaysRefresh: true, - tag: 'likedAlbumIds', - ); -}); - -final _likedArtistIdsProvider = StreamProvider>((ref) { - final db = ref.watch(appDbProvider); - final query = (db.select(db.cachedLikes) - ..where((t) => t.entityType.equals('artist')) - ..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)])) - .watch(); - return cacheFirst>( - driftStream: query, - fetchAndPopulate: () => _populateLikeIds(ref), - toResult: _idsForEntity, - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - alwaysRefresh: true, - tag: 'likedArtistIds', - ); -}); - -/// Shared cold-cache populator: hits /api/likes/ids once and writes -/// rows for all three entity types via insertOrIgnore. The three -/// providers above all trigger this on their first empty-drift -/// emission; the dedup in cacheFirst's revalidate state plus drift's -/// insertOrIgnore semantics make the multiple-trigger case cheap. -Future _populateLikeIds(Ref ref) async { - final api = await ref.read(_likesApiProvider.future); - final user = ref.read(authControllerProvider).value; - if (user == null) return; - final fresh = await api.ids(); - final db = ref.read(appDbProvider); - await db.batch((b) { - for (final id in fresh.tracks) { - b.insert( - db.cachedLikes, - CachedLikesCompanion.insert( - userId: user.id, - entityType: 'track', - entityId: id, - ), - mode: drift.InsertMode.insertOrIgnore, - ); - } - for (final id in fresh.albums) { - b.insert( - db.cachedLikes, - CachedLikesCompanion.insert( - userId: user.id, - entityType: 'album', - entityId: id, - ), - mode: drift.InsertMode.insertOrIgnore, - ); - } - for (final id in fresh.artists) { - b.insert( - db.cachedLikes, - CachedLikesCompanion.insert( - userId: user.id, - entityType: 'artist', - entityId: id, - ), - mode: drift.InsertMode.insertOrIgnore, - ); - } - }); -} - -// Hidden tab uses the canonical `myQuarantineProvider` (AsyncNotifier with -// optimistic flag/unflag) so flagging from any kebab and unflagging from -// the Hidden tab keep one source of truth. - -class LibraryScreen extends ConsumerStatefulWidget { - const LibraryScreen({super.key}); - - @override - ConsumerState createState() => _LibraryScreenState(); -} - -class _LibraryScreenState extends ConsumerState - with TickerProviderStateMixin { - late final TabController _ctrl = TabController(length: 5, vsync: this); - - @override - void dispose() { - _ctrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - // Pre-warm every tab's provider on Library mount so swiping - // between tabs feels instant rather than each tab paying its - // own cold-cache cost on first visit. ref.listen subscribes - // without rebuilding LibraryScreen on emit; subscriptions stay - // alive for the lifetime of this widget. cacheFirst handles - // dedupe of concurrent fetchAndPopulate triggers. - ref.listen(_libraryArtistsProvider, (_, __) {}); - ref.listen(_libraryAlbumsProvider, (_, __) {}); - ref.listen(_historyProvider, (_, __) {}); - ref.listen(_likedTrackIdsProvider, (_, __) {}); - ref.listen(_likedAlbumIdsProvider, (_, __) {}); - ref.listen(_likedArtistIdsProvider, (_, __) {}); - ref.listen(myQuarantineProvider, (_, __) {}); - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - title: Text('Library', style: TextStyle(color: fs.parchment)), - actions: [ - IconButton( - key: const Key('shuffle_all_button'), - tooltip: 'Shuffle all', - icon: Icon(LucideIcons.shuffle, color: fs.parchment), - onPressed: () async { - final messenger = ScaffoldMessenger.of(context); - final refs = await ref.read(shuffleSourceProvider).tracks(); - if (refs.isEmpty) { - messenger.showSnackBar(const SnackBar( - content: Text('Nothing to shuffle yet'), - )); - return; - } - await ref - .read(playerActionsProvider) - .playTracks(refs, shuffle: true); - }, - ), - const MainAppBarActions(currentRoute: '/library'), - ], - bottom: TabBar( - controller: _ctrl, - isScrollable: true, - tabAlignment: TabAlignment.start, - labelColor: fs.parchment, - unselectedLabelColor: fs.ash, - indicatorColor: fs.accent, - tabs: const [ - Tab(text: 'Artists'), - Tab(text: 'Albums'), - Tab(text: 'History'), - Tab(text: 'Liked'), - Tab(text: 'Hidden'), - ], - ), - ), - body: TabBarView( - controller: _ctrl, - children: const [ - _ArtistsTab(), - _AlbumsTab(), - _HistoryTab(), - _LikedTab(), - _HiddenTab(), - ], - ), - ); - } -} - -class _ArtistsTab extends ConsumerWidget { - const _ArtistsTab(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - return ref.watch(_libraryArtistsProvider).when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), - data: (artists) { - // Warm details for the first screenful so taps are instant. - ref - .read(metadataPrefetcherProvider) - .warmArtists(artists.map((a) => a.id)); - return artists.isEmpty - ? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center)) - : RefreshIndicator( - onRefresh: () async => ref.refresh(_libraryArtistsProvider.future), - // LayoutBuilder + cell-aware ArtistCard width mirrors - // the AlbumsTab pattern so the circular avatar stays - // a true circle on narrow grid cells. - // - // Drift-first: GridView holds the full sorted list; - // .builder lazily realizes only visible cells, so - // even on large libraries the up-front cost is just - // a sort over cached_artists, not N network round - // trips. - child: LayoutBuilder(builder: (ctx, constraints) { - const cols = 3; - const sidePad = 8.0; - const gap = 8.0; - final cellW = (constraints.maxWidth - - sidePad * 2 - - gap * (cols - 1)) / - cols; - // avatar (cellW - 16) + gap (8) + 1 line name (~18) - // + slack matched to AlbumsTab's overflow guard. - final cellH = (cellW - 16) + 8 + 18 + 8; - return GridView.builder( - padding: const EdgeInsets.all(sidePad), - gridDelegate: - SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: cols, - mainAxisExtent: cellH, - mainAxisSpacing: gap, - crossAxisSpacing: gap, - ), - itemCount: artists.length, - itemBuilder: (ctx, i) { - final artist = artists[i]; - return ArtistCard( - artist: artist, - width: cellW, - onTap: () => ctx.push('/artists/${artist.id}', - extra: artist), - ); - }, - ); - }), - ); - }, - ); - } -} - -class _AlbumsTab extends ConsumerWidget { - const _AlbumsTab(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - return ref.watch(_libraryAlbumsProvider).when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), - data: (albums) { - return albums.isEmpty - ? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center)) - : RefreshIndicator( - onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future), - // Same responsive 3-up grid as the artist detail - // album list — LayoutBuilder + AlbumCard sized to the - // cell, mainAxisExtent matched to actual card height. - // Drift-first; full sorted list, lazy realization via - // GridView.builder. - child: LayoutBuilder(builder: (ctx, constraints) { - const cols = 3; - const sidePad = 8.0; - const gap = 8.0; - final cellW = (constraints.maxWidth - - sidePad * 2 - - gap * (cols - 1)) / - cols; - // cover (cellW - 16) + gap (8) + 2-line title (~36) - // + artist line (~16) + slack. Slack is generous on - // purpose — line-height + font scaling variations - // would otherwise overflow the cell by a pixel - // (logged as a noisy RenderFlex warning). - final cellH = (cellW - 16) + 8 + 36 + 16 + 8; - return GridView.builder( - padding: const EdgeInsets.all(sidePad), - gridDelegate: - SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: cols, - mainAxisExtent: cellH, - mainAxisSpacing: gap, - crossAxisSpacing: gap, - ), - itemCount: albums.length, - itemBuilder: (ctx, i) { - final album = albums[i]; - return AlbumCard( - album: album, - width: cellW, - titleMaxLines: 2, - onTap: () => ctx.push('/albums/${album.id}', - extra: album), - ); - }, - ); - }), - ); - }, - ); - } -} - -class _HistoryTab extends ConsumerWidget { - const _HistoryTab(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - return ref.watch(_historyProvider).when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), - data: (page) => page.events.isEmpty - ? Center(child: Text('No listening history yet.', style: TextStyle(color: fs.ash))) - : RefreshIndicator( - onRefresh: () async => ref.refresh(_historyProvider.future), - child: ListView.separated( - itemCount: page.events.length, - separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron), - itemBuilder: (ctx, i) { - final event = page.events[i]; - return TrackRow( - track: event.track, - onTap: () => ref - .read(playerActionsProvider) - .playTracks([event.track]), - trailing: Text( - _relativeTime(event.playedAt), - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ); - }, - ), - ), - ); - } -} - -class _LikedTab extends ConsumerWidget { - const _LikedTab(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - // SSE wire-up: any cross-device like / unlike triggers a refresh - // of the discovery providers. LikesController handles local - // mutations optimistically through the same cached_likes table, - // so toggling a like locally re-emits the streams instantly - // without needing an invalidate here. - ref.listen>(liveEventsProvider, (_, next) { - final e = next.asData?.value; - if (e == null) return; - switch (e.kind) { - case 'track.liked': - case 'track.unliked': - case 'album.liked': - case 'album.unliked': - case 'artist.liked': - case 'artist.unliked': - ref.invalidate(_likedTrackIdsProvider); - ref.invalidate(_likedAlbumIdsProvider); - ref.invalidate(_likedArtistIdsProvider); - } - }); - final tracksA = ref.watch(_likedTrackIdsProvider); - final albumsA = ref.watch(_likedAlbumIdsProvider); - final artistsA = ref.watch(_likedArtistIdsProvider); - - if (tracksA.isLoading || albumsA.isLoading || artistsA.isLoading) { - return const Center(child: CircularProgressIndicator()); - } - final trackIds = tracksA.value; - final albumIds = albumsA.value; - final artistIds = artistsA.value; - if (trackIds == null || albumIds == null || artistIds == null) { - return Center(child: Text("Couldn't load liked items.", style: TextStyle(color: fs.error))); - } - if (trackIds.isEmpty && albumIds.isEmpty && artistIds.isEmpty) { - return Center(child: Text('No liked artists, albums, or tracks yet.', style: TextStyle(color: fs.ash), textAlign: TextAlign.center)); - } - return RefreshIndicator( - onRefresh: () async { - await Future.wait([ - ref.refresh(_likedTrackIdsProvider.future), - ref.refresh(_likedAlbumIdsProvider.future), - ref.refresh(_likedArtistIdsProvider.future), - ]); - }, - child: ListView(children: [ - if (artistIds.isNotEmpty) ...[ - _SectionHeader(label: 'Artists', count: artistIds.length), - SizedBox( - height: 168, - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 8), - itemCount: artistIds.length, - itemBuilder: (ctx, i) => - _LikedArtistTile(id: artistIds[i]), - ), - ), - ], - if (albumIds.isNotEmpty) ...[ - _SectionHeader(label: 'Albums', count: albumIds.length), - SizedBox( - height: 200, - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 8), - itemCount: albumIds.length, - itemBuilder: (ctx, i) => - _LikedAlbumTile(id: albumIds[i]), - ), - ), - ], - if (trackIds.isNotEmpty) ...[ - _SectionHeader(label: 'Tracks', count: trackIds.length), - ...trackIds.map( - (id) => _LikedTrackRow(id: id, sectionIds: trackIds), - ), - ], - const SizedBox(height: 96), - ]), - ); - } -} - -/// Skeleton→content cross-fade duration. Matches home_screen so the -/// reveal feel is consistent across surfaces. See _tileRevealDuration -/// in home_screen.dart for the rationale. -const Duration _likedTileReveal = Duration(milliseconds: 220); - -/// Liked-Artists carousel tile. Skeleton until artistTileProvider -/// yields a populated row. -class _LikedArtistTile extends ConsumerWidget { - const _LikedArtistTile({required this.id}); - final String id; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final artist = ref.watch(artistTileProvider(id)).asData?.value; - return AnimatedSwitcher( - duration: _likedTileReveal, - switchInCurve: Curves.easeOut, - child: artist == null - ? const SkeletonArtistTile(key: ValueKey('skeleton')) - : ArtistCard( - key: ValueKey('artist-${artist.id}'), - artist: artist, - onTap: () => - context.push('/artists/${artist.id}', extra: artist), - ), - ); - } -} - -/// Liked-Albums carousel tile. Skeleton until albumTileProvider -/// yields a populated row. -class _LikedAlbumTile extends ConsumerWidget { - const _LikedAlbumTile({required this.id}); - final String id; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final album = ref.watch(albumTileProvider(id)).asData?.value; - return AnimatedSwitcher( - duration: _likedTileReveal, - switchInCurve: Curves.easeOut, - child: album == null - ? const SkeletonAlbumTile(key: ValueKey('skeleton')) - : AlbumCard( - key: ValueKey('album-${album.id}'), - album: album, - onTap: () => context.push('/albums/${album.id}', extra: album), - ), - ); - } -} - -/// Liked-Tracks list row. Skeleton until trackTileProvider yields a -/// populated row. Tap plays the section starting at this track, -/// using whichever tracks are currently hydrated; still-loading -/// tracks are skipped from the play queue and join on next rebuild. -class _LikedTrackRow extends ConsumerWidget { - const _LikedTrackRow({required this.id, required this.sectionIds}); - final String id; - final List sectionIds; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final track = ref.watch(trackTileProvider(id)).asData?.value; - return AnimatedSwitcher( - duration: _likedTileReveal, - switchInCurve: Curves.easeOut, - child: track == null - ? const SkeletonTrackRow(key: ValueKey('skeleton')) - : TrackRow( - key: ValueKey('track-${track.id}'), - track: track, - onTap: () { - final hydrated = []; - for (final i in sectionIds) { - final v = ref.read(trackTileProvider(i)).asData?.value; - if (v != null) hydrated.add(v); - } - final start = hydrated.indexWhere((t) => t.id == id); - ref.read(playerActionsProvider).playTracks( - hydrated, - initialIndex: start < 0 ? 0 : start, - ); - }, - ), - ); - } -} - -class _HiddenTab extends ConsumerWidget { - const _HiddenTab(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - return ref.watch(myQuarantineProvider).when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), - data: (rows) => rows.isEmpty - ? Center( - child: Text( - 'No hidden tracks.\nUse a track\'s menu to flag bad rips or wrong tags.', - textAlign: TextAlign.center, - style: TextStyle(color: fs.ash), - ), - ) - : RefreshIndicator( - onRefresh: () async => ref.refresh(myQuarantineProvider.future), - child: ListView.separated( - itemCount: rows.length, - separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron), - itemBuilder: (ctx, i) => _QuarantineTile(row: rows[i]), - ), - ), - ); - } -} - -class _QuarantineTile extends ConsumerWidget { - const _QuarantineTile({required this.row}); - final QuarantineMineRow row; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final reasonLabel = switch (row.reason) { - 'bad_rip' => 'Bad rip', - 'wrong_file' => 'Wrong file', - 'wrong_tags' => 'Wrong tags', - 'duplicate' => 'Duplicate', - _ => 'Other', - }; - final coverUrl = row.albumId.isNotEmpty ? '/api/albums/${row.albumId}/cover' : ''; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: SizedBox( - width: 56, - height: 56, - child: coverUrl.isEmpty - ? Container(color: fs.slate) - : ServerImage( - url: coverUrl, - fit: BoxFit.cover, - fallback: Container(color: fs.slate), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - row.trackTitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14), - ), - Text( - '${row.artistName} · ${row.albumTitle}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12), - ), - Padding( - padding: const EdgeInsets.only(top: 4), - child: Row(children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: fs.iron, - borderRadius: BorderRadius.circular(4), - ), - child: Text(reasonLabel, style: TextStyle(color: fs.ash, fontSize: 11)), - ), - if (row.createdAt.isNotEmpty) ...[ - const SizedBox(width: 8), - Text(_relativeTime(row.createdAt), - style: TextStyle(color: fs.ash, fontSize: 11)), - ], - ]), - ), - ]), - ), - IconButton( - tooltip: 'Unhide', - icon: Icon(LucideIcons.archive_restore, color: fs.ash, size: 20), - onPressed: () async { - try { - await ref.read(myQuarantineProvider.notifier).unflag(row.trackId); - } catch (_) { - // Optimistic rollback handled by the notifier; surface - // the failure only if the user kept the tab open. - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Could not unhide; try again.')), - ); - } - } - }, - ), - ]), - ); - } -} - -class _SectionHeader extends StatelessWidget { - const _SectionHeader({required this.label, required this.count}); - final String label; - final int count; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Row(children: [ - Text(label, - style: TextStyle( - color: fs.parchment, fontSize: 18, fontWeight: FontWeight.w500)), - const SizedBox(width: 8), - Text('$count', style: TextStyle(color: fs.ash, fontSize: 13)), - ]), - ); - } -} - -/// Lightweight relative-time formatter: <1h "23m ago" / <24h "3h ago" -/// / <7d "Tue 14:32" / older "May 1" / older diff-year "May 1, 2025". -/// Mirrors web/src/lib/components/HistoryRow.svelte's relativeTime. -String _relativeTime(String iso) { - final t = DateTime.tryParse(iso); - if (t == null) return ''; - final now = DateTime.now(); - final diff = now.difference(t); - if (diff.inMinutes < 60) { - final m = diff.inMinutes < 1 ? 1 : diff.inMinutes; - return '${m}m ago'; - } - if (diff.inHours < 24) return '${diff.inHours}h ago'; - if (diff.inDays < 7) { - const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; - final hh = t.hour.toString().padLeft(2, '0'); - final mm = t.minute.toString().padLeft(2, '0'); - return '${days[t.weekday - 1]} $hh:$mm'; - } - const months = [ - 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', - ]; - if (t.year == now.year) return '${months[t.month - 1]} ${t.day}'; - return '${months[t.month - 1]} ${t.day}, ${t.year}'; -} diff --git a/flutter_client/lib/library/widgets/album_card.dart b/flutter_client/lib/library/widgets/album_card.dart deleted file mode 100644 index 533b84a8..00000000 --- a/flutter_client/lib/library/widgets/album_card.dart +++ /dev/null @@ -1,112 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_svg/flutter_svg.dart'; - -import '../../models/album.dart'; -import '../../player/player_provider.dart'; -import '../../shared/widgets/server_image.dart'; -import '../../theme/theme_extension.dart'; -import '../library_providers.dart'; -import 'play_circle_button.dart'; - -class AlbumCard extends ConsumerWidget { - const AlbumCard({ - required this.album, - required this.onTap, - this.width = 140, - this.titleMaxLines = 1, - this.showArtist = true, - super.key, - }); - final AlbumRef album; - final VoidCallback onTap; - - /// Outer width of the card. Cover is square at width - 16 (8dp - /// horizontal padding either side). Default suits horizontal lists; - /// grids should pass the cell width so the card scales down. - final double width; - - /// Lets callers (e.g. the artist detail grid) allow the title to - /// wrap to a second line so it isn't truncated to a single - /// ellipsized character at narrower widths. - final int titleMaxLines; - - /// Suppress the artist name. Useful on surfaces where the artist is - /// already implied by the page header (e.g. artist detail). - final bool showArtist; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final coverSize = width - 16; - return SizedBox( - width: width, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // Stack: cover image + overlaid play button at bottom-right. - Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Container( - width: coverSize, - height: coverSize, - color: fs.slate, - child: album.coverUrl.isEmpty - ? SvgPicture.asset('assets/svg/album-fallback.svg', - fit: BoxFit.cover) - : ServerImage(url: album.coverUrl, fit: BoxFit.cover), - ), - ), - Positioned( - bottom: 6, - right: 6, - child: PlayCircleButton( - onPressed: () => _playAlbum(ref), - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - album.title, - maxLines: titleMaxLines, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14), - ), - if (showArtist && album.artistName.isNotEmpty) - Text( - album.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ], - ), - ), - ), - ), - ); - } - - /// Fetches the album's tracks via /api/albums/{id} and starts playback - /// from the first track. Errors are swallowed by the button's outer - /// try/finally; callers don't surface them — failed fetches just keep - /// the spinner visible until the button is retapped. - Future _playAlbum(WidgetRef ref) async { - final api = await ref.read(libraryApiProvider.future); - final result = await api.getAlbum(album.id); - if (result.tracks.isEmpty) return; - await ref - .read(playerActionsProvider) - .playTracks(result.tracks, initialIndex: 0); - } -} diff --git a/flutter_client/lib/library/widgets/artist_card.dart b/flutter_client/lib/library/widgets/artist_card.dart deleted file mode 100644 index fed8c74f..00000000 --- a/flutter_client/lib/library/widgets/artist_card.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_svg/flutter_svg.dart'; - -import '../../models/artist.dart'; -import '../../player/player_provider.dart'; -import '../../shared/widgets/server_image.dart'; -import '../../theme/theme_extension.dart'; -import '../library_providers.dart'; -import 'play_circle_button.dart'; - -class ArtistCard extends ConsumerWidget { - const ArtistCard({ - required this.artist, - required this.onTap, - this.width = 140, - super.key, - }); - final ArtistRef artist; - final VoidCallback onTap; - - /// Outer width of the card. Avatar is a circle of width-16 (8dp - /// horizontal padding either side). Default suits horizontal lists; - /// grids should pass the cell width so the avatar shrinks - /// proportionally and stays a true circle. - final double width; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final coverSize = width - 16; - return SizedBox( - width: width, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Stack: circular avatar + overlaid play button at bottom-right. - // Avatar size derives from the [width] parameter so the - // Library Artists 3-column grid can pass its cell width - // (~109dp on typical phones) and the avatar stays a - // perfect circle. Previous hardcoded 124×124 got squeezed - // horizontally in the grid (cell narrower than 124+16 - // padding) but kept its 124dp height — ClipOval produced - // a visible vertical ellipse. Mirrors AlbumCard's width- - // parameter convention. - Stack( - children: [ - ClipOval( - child: Container( - width: coverSize, - height: coverSize, - color: fs.slate, - child: artist.coverUrl.isEmpty - ? SvgPicture.asset('assets/svg/album-fallback.svg', - fit: BoxFit.cover) - : ServerImage(url: artist.coverUrl, fit: BoxFit.cover), - ), - ), - Positioned( - bottom: 4, - right: 4, - child: PlayCircleButton( - onPressed: () => _playArtistShuffle(ref), - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - artist.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14), - ), - ]), - ), - ), - ), - ); - } - - /// Fetches the artist's tracks via /api/artists/{id}/tracks, shuffles - /// them (Fisher-Yates, default Random), and plays from index 0. - /// Matches the web ArtistCard's `playQueue(shuffle(tracks), 0)`. - Future _playArtistShuffle(WidgetRef ref) async { - final api = await ref.read(libraryApiProvider.future); - final tracks = await api.getArtistTracks(artist.id); - if (tracks.isEmpty) return; - final shuffled = List.of(tracks); - shuffled.shuffle(Random()); - await ref - .read(playerActionsProvider) - .playTracks(shuffled, initialIndex: 0); - } -} diff --git a/flutter_client/lib/library/widgets/cached_indicator.dart b/flutter_client/lib/library/widgets/cached_indicator.dart deleted file mode 100644 index 685aabc5..00000000 --- a/flutter_client/lib/library/widgets/cached_indicator.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../cache/audio_cache_manager.dart'; -import '../../theme/theme_extension.dart'; - -/// Small download glyph shown next to a track row when the track is -/// cached locally. FutureBuilder one-shot — track rows are short-lived -/// and cache state rarely changes mid-render. -class CachedIndicator extends ConsumerWidget { - const CachedIndicator({required this.trackId, super.key}); - final String trackId; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final mgr = ref.read(audioCacheManagerProvider); - final fs = Theme.of(context).extension()!; - return FutureBuilder( - future: mgr.isCached(trackId), - builder: (ctx, snap) { - if (snap.data != true) return const SizedBox.shrink(); - return Padding( - padding: const EdgeInsets.only(left: 4), - child: Icon(LucideIcons.circle_check_big, size: 14, color: fs.accent), - ); - }, - ); - } -} diff --git a/flutter_client/lib/library/widgets/compact_track_card.dart b/flutter_client/lib/library/widgets/compact_track_card.dart deleted file mode 100644 index 4e53d7b3..00000000 --- a/flutter_client/lib/library/widgets/compact_track_card.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../models/track.dart'; -import '../../player/player_provider.dart'; -import '../../shared/widgets/server_image.dart'; -import '../../shared/widgets/track_actions/track_actions_button.dart'; -import '../../theme/theme_extension.dart'; - -/// Small horizontal track cell used by the home Most-played section. -/// Mirrors the web CompactTrackCard sizing (~176dp wide, ~56dp tall). -/// Tap plays from this track within [sectionTracks] starting at [index]. -class CompactTrackCard extends ConsumerWidget { - const CompactTrackCard({ - super.key, - required this.track, - required this.sectionTracks, - required this.index, - }); - - final TrackRef track; - - /// All tracks in the home section so play-from-here can queue them - /// in order. - final List sectionTracks; - final int index; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - // Cover URL derives from the track's album. Mirrors web - // CompactTrackCard which calls coverUrl(track.album_id). - final coverUrl = track.albumId.isNotEmpty - ? '/api/albums/${track.albumId}/cover' - : ''; - return SizedBox( - width: 176, - child: InkWell( - onTap: () => ref - .read(playerActionsProvider) - .playTracks(sectionTracks, initialIndex: index), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Row(children: [ - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: SizedBox( - width: 48, - height: 48, - child: ServerImage( - url: coverUrl, - fit: BoxFit.cover, - fallback: Container(color: fs.slate), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - track.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 13), - ), - Text( - track.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 11), - ), - ], - ), - ), - TrackActionsButton(track: track), - ]), - ), - ), - ); - } -} diff --git a/flutter_client/lib/library/widgets/horizontal_scroll_row.dart b/flutter_client/lib/library/widgets/horizontal_scroll_row.dart deleted file mode 100644 index b20f87d5..00000000 --- a/flutter_client/lib/library/widgets/horizontal_scroll_row.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../theme/theme_extension.dart'; - -/// Mirrors the web HorizontalScrollRow: a labeled section that scrolls -/// horizontally. Multi-row sections share one [controller] so they -/// scroll together. -class HorizontalScrollRow extends StatelessWidget { - const HorizontalScrollRow({ - required this.title, - required this.children, - this.height = 200, - this.controller, - super.key, - }); - - final String title; - final List children; - final double height; - final ScrollController? controller; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (title.isNotEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Text( - title, - style: TextStyle( - fontFamily: fs.display.fontFamily, - fontSize: 18, - color: fs.parchment, - ), - ), - ), - SizedBox( - height: height, - child: ListView( - controller: controller, - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - children: children, - ), - ), - ]); - } -} diff --git a/flutter_client/lib/library/widgets/play_circle_button.dart b/flutter_client/lib/library/widgets/play_circle_button.dart deleted file mode 100644 index acfb0562..00000000 --- a/flutter_client/lib/library/widgets/play_circle_button.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; - -import '../../theme/theme_extension.dart'; - -/// Always-visible 44dp circular play button overlaid on home-screen -/// card art (AlbumCard / ArtistCard / PlaylistCard). Mirrors the -/// hover-revealed `.play-overlay` on the web cards, but always shown -/// because hover is not a real interaction on touch. -/// -/// Manages its own loading state via [_starting] so the caller's tap -/// handler can be `Future Function()` without worrying about -/// re-entrancy. Disabled state suppresses the tap (used for empty -/// playlists). -class PlayCircleButton extends StatefulWidget { - const PlayCircleButton({ - required this.onPressed, - this.enabled = true, - this.size = 44, - super.key, - }); - - /// Tap handler. Returns a Future so the button can show a spinner - /// during the play setup (fetch detail tracks, etc.). - final Future Function() onPressed; - - /// When false, the button is rendered semi-transparent and taps are - /// ignored. Empty playlists / artists with no tracks should disable. - final bool enabled; - - /// Outer diameter in logical pixels. 44 is the iOS / Android - /// touch-target minimum; matches the design doc. - final double size; - - @override - State createState() => _PlayCircleButtonState(); -} - -class _PlayCircleButtonState extends State { - bool _starting = false; - - Future _handleTap() async { - if (_starting || !widget.enabled) return; - setState(() => _starting = true); - try { - await widget.onPressed(); - } finally { - if (mounted) setState(() => _starting = false); - } - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final iconSize = widget.size * 0.5; - return Material( - color: Colors.transparent, - shape: const CircleBorder(), - child: InkResponse( - onTap: widget.enabled ? _handleTap : null, - radius: widget.size / 2, - containedInkWell: true, - customBorder: const CircleBorder(), - child: Container( - width: widget.size, - height: widget.size, - decoration: BoxDecoration( - color: fs.accent.withValues(alpha: widget.enabled ? 1.0 : 0.5), - shape: BoxShape.circle, - boxShadow: const [ - BoxShadow( - color: Color(0x66000000), - blurRadius: 6, - offset: Offset(0, 2), - ), - ], - ), - alignment: Alignment.center, - child: _starting - ? SizedBox( - width: iconSize, - height: iconSize, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(fs.parchment), - ), - ) - : Icon( - LucideIcons.play, - color: fs.parchment, - size: iconSize, - ), - ), - ), - ); - } -} diff --git a/flutter_client/lib/library/widgets/track_row.dart b/flutter_client/lib/library/widgets/track_row.dart deleted file mode 100644 index 48c97e4e..00000000 --- a/flutter_client/lib/library/widgets/track_row.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../models/track.dart'; -import '../../player/player_provider.dart'; -import '../../shared/widgets/track_actions/track_actions_button.dart'; -import '../../theme/theme_extension.dart'; -import 'cached_indicator.dart'; - -class TrackRow extends ConsumerWidget { - const TrackRow({ - required this.track, - required this.onTap, - this.trailing, - this.actions = true, - super.key, - }); - final TrackRef track; - final VoidCallback onTap; - final Widget? trailing; - - /// Render the 3-dot TrackActionsButton at the end of the row. Default - /// true; pass false in surfaces that don't want the menu (e.g. when - /// showing a static read-only list). - final bool actions; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final mins = (track.durationSec ~/ 60).toString().padLeft(2, '0'); - final secs = (track.durationSec % 60).toString().padLeft(2, '0'); - // Watch the currently-playing media item so the row's accent - // highlight tracks playback state. Matches _QueueRow's visual - // treatment in queue_screen.dart so the "you are here" cue is - // consistent across album / playlist / queue surfaces. - final currentId = ref.watch(mediaItemProvider).value?.id; - final isCurrent = currentId != null && currentId == track.id; - return Container( - decoration: BoxDecoration( - color: isCurrent ? fs.iron : null, - border: isCurrent - ? Border(left: BorderSide(color: fs.accent, width: 2)) - : null, - ), - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: Row(children: [ - if (track.trackNumber != null) - SizedBox( - width: 22, - child: Text( - track.trackNumber.toString(), - style: TextStyle(color: fs.ash, fontSize: 13), - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - track.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: isCurrent ? fs.accent : fs.parchment, - fontSize: 14, - fontWeight: - isCurrent ? FontWeight.w500 : FontWeight.w400, - ), - ), - // Skip the artist line entirely when empty so the row - // height collapses to a single line of title — keeps - // dense album views from looking padded. - if (track.artistName.isNotEmpty) - Text( - track.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ], - ), - ), - CachedIndicator(trackId: track.id), - Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)), - if (trailing != null) - Padding( - padding: const EdgeInsets.only(left: 8), - child: trailing!, - ), - if (actions) TrackActionsButton(track: track), - ]), - ), - ), - ); - } -} diff --git a/flutter_client/lib/likes/like_button.dart b/flutter_client/lib/likes/like_button.dart deleted file mode 100644 index 729d15da..00000000 --- a/flutter_client/lib/likes/like_button.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/likes.dart'; -import '../shared/widgets/lucide_heart.dart'; -import '../theme/theme_extension.dart'; -import 'likes_provider.dart'; - -class LikeButton extends ConsumerWidget { - const LikeButton({ - required this.kind, - required this.id, - this.size = 22, - super.key, - }); - final LikeKind kind; - final String id; - final double size; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final state = ref.watch(likedIdsProvider); - final liked = state.maybeWhen( - data: (s) => s.has(kind, id), - orElse: () => false, - ); - return IconButton( - icon: LucideHeart( - filled: liked, - color: liked ? fs.accent : fs.ash, - size: size, - ), - onPressed: () => ref.read(likesControllerProvider).toggle(kind, id), - ); - } -} diff --git a/flutter_client/lib/likes/likes_provider.dart b/flutter_client/lib/likes/likes_provider.dart deleted file mode 100644 index ed0bcb3a..00000000 --- a/flutter_client/lib/likes/likes_provider.dart +++ /dev/null @@ -1,174 +0,0 @@ -import 'package:drift/drift.dart' as drift; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/likes.dart'; -import '../auth/auth_provider.dart'; -import '../cache/audio_cache_manager.dart' show appDbProvider; -import '../cache/cache_first.dart'; -import '../cache/connectivity_provider.dart'; -import '../cache/db.dart'; -import '../cache/mutation_queue.dart'; -import '../library/library_providers.dart'; - -final likesApiProvider = FutureProvider((ref) async { - return LikesApi(await ref.watch(dioProvider.future)); -}); - -class LikedIds { - const LikedIds({ - required this.artists, - required this.albums, - required this.tracks, - }); - final Set artists; - final Set albums; - final Set tracks; - - bool has(LikeKind kind, String id) => switch (kind) { - LikeKind.artist => artists.contains(id), - LikeKind.album => albums.contains(id), - LikeKind.track => tracks.contains(id), - }; - - static const empty = LikedIds(artists: {}, albums: {}, tracks: {}); -} - -/// Drift-first per #357 plan C. Reads from cached_likes (populated by -/// SyncController). Reactive — sync writes propagate via watch(). -/// Empty + online triggers REST cold-cache fallback that re-populates. -final likedIdsProvider = StreamProvider((ref) { - final db = ref.watch(appDbProvider); - return cacheFirst( - driftStream: db.select(db.cachedLikes).watch(), - fetchAndPopulate: () async { - final api = await ref.read(likesApiProvider.future); - final fresh = await api.ids(); - // Need a userId for the composite primary key. The auth controller - // exposes the current user. - final user = ref.read(authControllerProvider).value; - if (user == null) return; // not logged in; skip - await db.batch((b) { - for (final id in fresh.tracks) { - b.insert( - db.cachedLikes, - CachedLikesCompanion.insert( - userId: user.id, - entityType: 'track', - entityId: id, - ), - mode: drift.InsertMode.insertOrIgnore, - ); - } - for (final id in fresh.albums) { - b.insert( - db.cachedLikes, - CachedLikesCompanion.insert( - userId: user.id, - entityType: 'album', - entityId: id, - ), - mode: drift.InsertMode.insertOrIgnore, - ); - } - for (final id in fresh.artists) { - b.insert( - db.cachedLikes, - CachedLikesCompanion.insert( - userId: user.id, - entityType: 'artist', - entityId: id, - ), - mode: drift.InsertMode.insertOrIgnore, - ); - } - }); - }, - toResult: (rows) => LikedIds( - tracks: rows - .where((r) => r.entityType == 'track') - .map((r) => r.entityId) - .toSet(), - albums: rows - .where((r) => r.entityType == 'album') - .map((r) => r.entityId) - .toSet(), - artists: rows - .where((r) => r.entityType == 'artist') - .map((r) => r.entityId) - .toSet(), - ), - isOnline: () async => (await ref.read(connectivityProvider.future)), - ); -}); - -/// Mutation controller. Writes optimistically to drift first (so the -/// likedIdsProvider stream re-emits immediately for snappy UI), then to -/// REST. Rolls back drift on REST failure. -class LikesController { - LikesController(this._ref); - final Ref _ref; - - Future toggle(LikeKind kind, String id) async { - final user = _ref.read(authControllerProvider).value; - if (user == null) return; - final db = _ref.read(appDbProvider); - final entityType = _entityType(kind); - - final existing = await (db.select(db.cachedLikes) - ..where((t) => - t.userId.equals(user.id) & - t.entityType.equals(entityType) & - t.entityId.equals(id))) - .getSingleOrNull(); - final wasLiked = existing != null; - - // Optimistic mutation — drift watch() re-emits immediately. - if (wasLiked) { - await (db.delete(db.cachedLikes) - ..where((t) => - t.userId.equals(user.id) & - t.entityType.equals(entityType) & - t.entityId.equals(id))) - .go(); - } else { - await db.into(db.cachedLikes).insert( - CachedLikesCompanion.insert( - userId: user.id, - entityType: entityType, - entityId: id, - ), - mode: drift.InsertMode.insertOrIgnore, - ); - } - - try { - final api = await _ref.read(likesApiProvider.future); - if (wasLiked) { - await api.unlike(kind, id); - } else { - await api.like(kind, id); - } - } catch (_) { - // REST failed (network or HTTP). Don't roll back drift — the - // user's intent is to like/unlike, and we want that visible to - // them even when offline. Queue the call for replay; the - // MutationReplayer will retry on next connectivity transition. - // If retries exhaust (5 attempts), the row drops and the next - // SyncController.sync brings drift back in line with the - // server's authoritative state. - await _ref.read(mutationQueueProvider).enqueue( - wasLiked ? MutationKinds.likeRemove : MutationKinds.likeAdd, - {'kind': entityType, 'id': id}, - ); - } - } - - String _entityType(LikeKind kind) => switch (kind) { - LikeKind.artist => 'artist', - LikeKind.album => 'album', - LikeKind.track => 'track', - }; -} - -final likesControllerProvider = - Provider((ref) => LikesController(ref)); diff --git a/flutter_client/lib/main.dart b/flutter_client/lib/main.dart deleted file mode 100644 index 88d5cec9..00000000 --- a/flutter_client/lib/main.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:audio_service/audio_service.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import 'app.dart'; -import 'player/audio_handler.dart'; -import 'player/player_provider.dart'; - -Future main() async { - WidgetsFlutterBinding.ensureInitialized(); - final handler = await AudioService.init( - builder: () => MinstrelAudioHandler(), - config: const AudioServiceConfig( - androidNotificationChannelId: 'com.fabledsword.minstrel.audio', - androidNotificationChannelName: 'Minstrel playback', - androidNotificationOngoing: true, - // 8a: hand external surfaces (notification / lock screen / Wear) - // a downscaled cover instead of full-res album art — smaller - // payload, faster paint, lower memory. 300px is ample for those - // targets. preloadArtwork warms it so the first paint isn't blank. - artDownscaleWidth: 300, - artDownscaleHeight: 300, - preloadArtwork: true, - ), - ); - runApp(ProviderScope( - overrides: [audioHandlerProvider.overrideWithValue(handler)], - child: const MinstrelApp(), - )); -} diff --git a/flutter_client/lib/models/admin_quarantine_item.dart b/flutter_client/lib/models/admin_quarantine_item.dart deleted file mode 100644 index a27599a7..00000000 --- a/flutter_client/lib/models/admin_quarantine_item.dart +++ /dev/null @@ -1,92 +0,0 @@ -/// One user's report under an aggregated quarantine row. -class AdminQuarantineReport { - const AdminQuarantineReport({ - required this.userId, - required this.username, - required this.reason, - this.notes, - required this.createdAt, - }); - - final String userId; - final String username; - final String reason; - final String? notes; - final String createdAt; - - factory AdminQuarantineReport.fromJson(Map j) => - AdminQuarantineReport( - userId: j['user_id'] as String? ?? '', - username: j['username'] as String? ?? '', - reason: j['reason'] as String? ?? '', - notes: j['notes'] as String?, - createdAt: j['created_at'] as String? ?? '', - ); -} - -/// Mirrors `adminQueueRowView` from internal/api/admin_quarantine.go. -/// One row aggregates all reports against a single track, with -/// per-reason counts and the underlying per-user reports nested. -class AdminQuarantineItem { - const AdminQuarantineItem({ - required this.trackId, - required this.trackTitle, - required this.artistName, - required this.albumTitle, - required this.albumId, - this.lidarrAlbumMbid, - required this.reportCount, - required this.latestAt, - required this.reasonCounts, - required this.reports, - }); - - final String trackId; - final String trackTitle; - final String artistName; - final String albumTitle; - final String albumId; - final String? lidarrAlbumMbid; - final int reportCount; - final String latestAt; - - /// Map of reason → number of reports citing that reason. - final Map reasonCounts; - - final List reports; - - /// One-line summary of the most-cited reason. Returns just the top - /// reason when only one reason exists, or `top (+N more)` when there - /// are additional distinct reasons. - String get topReasonSummary { - if (reasonCounts.isEmpty) return ''; - final entries = reasonCounts.entries.toList() - ..sort((a, b) => b.value.compareTo(a.value)); - final top = entries.first.key; - return entries.length > 1 - ? '$top (+${entries.length - 1} more)' - : top; - } - - factory AdminQuarantineItem.fromJson(Map j) { - final reasonCountsRaw = - (j['reason_counts'] as Map?)?.cast() ?? const {}; - final reportsRaw = (j['reports'] as List?) ?? const []; - return AdminQuarantineItem( - trackId: j['track_id'] as String? ?? '', - trackTitle: j['track_title'] as String? ?? '', - artistName: j['artist_name'] as String? ?? '', - albumTitle: j['album_title'] as String? ?? '', - albumId: j['album_id'] as String? ?? '', - lidarrAlbumMbid: j['lidarr_album_mbid'] as String?, - reportCount: (j['report_count'] as int?) ?? 0, - latestAt: j['latest_at'] as String? ?? '', - reasonCounts: - reasonCountsRaw.map((k, v) => MapEntry(k, (v as int?) ?? 0)), - reports: reportsRaw - .map((e) => - AdminQuarantineReport.fromJson((e as Map).cast())) - .toList(growable: false), - ); - } -} diff --git a/flutter_client/lib/models/admin_request.dart b/flutter_client/lib/models/admin_request.dart deleted file mode 100644 index 19306d2e..00000000 --- a/flutter_client/lib/models/admin_request.dart +++ /dev/null @@ -1,77 +0,0 @@ -/// Mirrors `requestView` from internal/api/requests.go. Server returns -/// these as a flat list (no envelope) from GET /api/admin/requests. -/// -/// Note the requester is exposed only as a UUID (`user_id`) — the -/// response does not include the username. The Flutter Requests screen -/// joins client-side against the AdminUser list for display. -class AdminRequest { - const AdminRequest({ - required this.id, - required this.userId, - required this.status, - required this.kind, - required this.artistName, - this.albumTitle, - this.trackTitle, - required this.requestedAt, - this.decidedAt, - this.notes, - required this.importedAlbumCount, - required this.importedTrackCount, - this.matchedTrackId, - this.matchedAlbumId, - this.matchedArtistId, - }); - - final String id; - final String userId; - - /// One of: pending, approved, rejected, completed, failed. - final String status; - - /// One of: artist, album, track. - final String kind; - - final String artistName; - final String? albumTitle; - final String? trackTitle; - final String requestedAt; - final String? decidedAt; - final String? notes; - final int importedAlbumCount; - final int importedTrackCount; - - /// Set when the ingest matched into the local library. The user-side - /// "Listen" CTA on a completed request links to whichever id is set - /// (most-specific first: track → album → artist). - final String? matchedTrackId; - final String? matchedAlbumId; - final String? matchedArtistId; - - /// Display label depending on the kind of request — what the user - /// asked for. For an album request it's the album title; for an - /// artist request it's the artist name; etc. - String get displayName => switch (kind) { - 'album' => albumTitle ?? artistName, - 'track' => trackTitle ?? artistName, - _ => artistName, - }; - - factory AdminRequest.fromJson(Map j) => AdminRequest( - id: j['id'] as String? ?? '', - userId: j['user_id'] as String? ?? '', - status: j['status'] as String? ?? 'pending', - kind: j['kind'] as String? ?? 'artist', - artistName: j['artist_name'] as String? ?? '', - albumTitle: j['album_title'] as String?, - trackTitle: j['track_title'] as String?, - requestedAt: j['requested_at'] as String? ?? '', - decidedAt: j['decided_at'] as String?, - notes: j['notes'] as String?, - importedAlbumCount: (j['imported_album_count'] as int?) ?? 0, - importedTrackCount: (j['imported_track_count'] as int?) ?? 0, - matchedTrackId: j['matched_track_id'] as String?, - matchedAlbumId: j['matched_album_id'] as String?, - matchedArtistId: j['matched_artist_id'] as String?, - ); -} diff --git a/flutter_client/lib/models/admin_user.dart b/flutter_client/lib/models/admin_user.dart deleted file mode 100644 index 46c4a80b..00000000 --- a/flutter_client/lib/models/admin_user.dart +++ /dev/null @@ -1,33 +0,0 @@ -/// Mirrors `adminUserView` from internal/api/admin_users.go. Distinct -/// from MyProfile because admin endpoints expose `auto_approve_requests` -/// and the canonical `created_at` that the user-scoped /me response -/// intentionally omits. -/// -/// The list endpoint wraps these in `{"users": [...]}`; the parsing of -/// the envelope is done in `AdminUsersApi`, not here. -class AdminUser { - const AdminUser({ - required this.id, - required this.username, - this.displayName, - required this.isAdmin, - required this.autoApproveRequests, - required this.createdAt, - }); - - final String id; - final String username; - final String? displayName; - final bool isAdmin; - final bool autoApproveRequests; - final String createdAt; - - factory AdminUser.fromJson(Map j) => AdminUser( - id: j['id'] as String? ?? '', - username: j['username'] as String? ?? '', - displayName: j['display_name'] as String?, - isAdmin: j['is_admin'] as bool? ?? false, - autoApproveRequests: j['auto_approve_requests'] as bool? ?? false, - createdAt: j['created_at'] as String? ?? '', - ); -} diff --git a/flutter_client/lib/models/album.dart b/flutter_client/lib/models/album.dart deleted file mode 100644 index a8e12800..00000000 --- a/flutter_client/lib/models/album.dart +++ /dev/null @@ -1,40 +0,0 @@ -// Mirrors internal/api/types.go AlbumRef and web/src/lib/api/types.ts. -// -// `cover_url` (NOT cover_art_url) and `duration_sec` (NOT duration_ms) match -// the server contract. `year` is omitempty on the server so we keep it -// nullable. CoverURL is non-null but may be "" — UI branches on isEmpty. -class AlbumRef { - const AlbumRef({ - required this.id, - required this.title, - required this.artistId, - this.sortTitle = '', - this.artistName = '', - this.year, - this.trackCount = 0, - this.durationSec = 0, - this.coverUrl = '', - }); - - final String id; - final String title; - final String sortTitle; - final String artistId; - final String artistName; - final int? year; - final int trackCount; - final int durationSec; - final String coverUrl; - - factory AlbumRef.fromJson(Map j) => AlbumRef( - id: j['id'] as String, - title: j['title'] as String, - sortTitle: j['sort_title'] as String? ?? '', - artistId: j['artist_id'] as String, - artistName: j['artist_name'] as String? ?? '', - year: (j['year'] as num?)?.toInt(), - trackCount: (j['track_count'] as num?)?.toInt() ?? 0, - durationSec: (j['duration_sec'] as num?)?.toInt() ?? 0, - coverUrl: j['cover_url'] as String? ?? '', - ); -} diff --git a/flutter_client/lib/models/artist.dart b/flutter_client/lib/models/artist.dart deleted file mode 100644 index 48eae240..00000000 --- a/flutter_client/lib/models/artist.dart +++ /dev/null @@ -1,30 +0,0 @@ -// Mirrors internal/api/types.go ArtistRef and web/src/lib/api/types.ts. -// -// `cover_url` is the server's field name (NOT cover_art_url). Server emits -// "" when the artist has no representative album cover, so we keep it -// non-null here and let UI code branch on isEmpty. `sort_name` and -// `album_count` are exposed by the server contract; we accept their -// absence defensively (older fixtures, partial mocks). -class ArtistRef { - const ArtistRef({ - required this.id, - required this.name, - this.sortName = '', - this.albumCount = 0, - this.coverUrl = '', - }); - - final String id; - final String name; - final String sortName; - final int albumCount; - final String coverUrl; - - factory ArtistRef.fromJson(Map j) => ArtistRef( - id: j['id'] as String, - name: j['name'] as String, - sortName: j['sort_name'] as String? ?? '', - albumCount: (j['album_count'] as num?)?.toInt() ?? 0, - coverUrl: j['cover_url'] as String? ?? '', - ); -} diff --git a/flutter_client/lib/models/artist_suggestion.dart b/flutter_client/lib/models/artist_suggestion.dart deleted file mode 100644 index a636a4b7..00000000 --- a/flutter_client/lib/models/artist_suggestion.dart +++ /dev/null @@ -1,51 +0,0 @@ -/// Mirrors web/src/lib/api/types.ts ArtistSuggestion / SeedContribution — -/// one out-of-library artist from GET /api/discover/suggestions. image_url -/// is resolved on-demand from Lidarr server-side (may be empty). -class SeedContribution { - const SeedContribution({required this.name, required this.isLiked}); - - final String name; - final bool isLiked; - - factory SeedContribution.fromJson(Map j) => SeedContribution( - name: j['name'] as String? ?? '', - isLiked: j['is_liked'] as bool? ?? false, - ); -} - -class ArtistSuggestion { - const ArtistSuggestion({ - required this.mbid, - required this.name, - required this.imageUrl, - required this.attribution, - }); - - final String mbid; - final String name; - final String imageUrl; - final List attribution; - - factory ArtistSuggestion.fromJson(Map j) => ArtistSuggestion( - mbid: j['mbid'] as String? ?? '', - name: j['name'] as String? ?? '', - imageUrl: j['image_url'] as String? ?? '', - attribution: ((j['attribution'] as List?) ?? const []) - .map((e) => - SeedContribution.fromJson((e as Map).cast())) - .toList(growable: false), - ); - - /// Mirrors web SuggestionFeed.attributionText (Oxford comma, max 3). - String get attributionText { - if (attribution.isEmpty) return ''; - final phrases = attribution - .map((s) => '${s.isLiked ? 'liked' : 'played'} ${s.name}') - .toList(growable: false); - if (phrases.length == 1) return 'Because you ${phrases[0]}.'; - if (phrases.length == 2) { - return 'Because you ${phrases[0]} and ${phrases[1]}.'; - } - return 'Because you ${phrases[0]}, ${phrases[1]}, and ${phrases[2]}.'; - } -} diff --git a/flutter_client/lib/models/history_event.dart b/flutter_client/lib/models/history_event.dart deleted file mode 100644 index 5482eb00..00000000 --- a/flutter_client/lib/models/history_event.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'track.dart'; - -/// Mirrors web/src/lib/api/history.ts HistoryEvent. Server emits -/// `{events, has_more}` rather than the standard `Page` envelope — -/// history is timestamp-keyed so total isn't meaningful, only "more -/// pages exist below." -class HistoryEvent { - const HistoryEvent({ - required this.id, - required this.playedAt, - required this.track, - }); - - final String id; - /// RFC3339 timestamp; UI formats relative ("3h ago" / "Tue 14:32" / - /// "May 1, 2025") via formatRelative helper. - final String playedAt; - final TrackRef track; - - factory HistoryEvent.fromJson(Map j) => HistoryEvent( - id: j['id'] as String? ?? '', - playedAt: j['played_at'] as String? ?? '', - track: TrackRef.fromJson( - (j['track'] as Map?)?.cast() ?? const {}, - ), - ); -} - -class HistoryPage { - const HistoryPage({required this.events, required this.hasMore}); - - final List events; - final bool hasMore; - - factory HistoryPage.fromJson(Map j) { - final raw = (j['events'] as List?) ?? const []; - return HistoryPage( - events: raw - .map((e) => HistoryEvent.fromJson((e as Map).cast())) - .toList(growable: false), - hasMore: j['has_more'] as bool? ?? false, - ); - } -} diff --git a/flutter_client/lib/models/home_data.dart b/flutter_client/lib/models/home_data.dart deleted file mode 100644 index 1e9c5afc..00000000 --- a/flutter_client/lib/models/home_data.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'album.dart'; -import 'artist.dart'; -import 'track.dart'; - -// Mirrors internal/api/types.go HomePayload and web/src/lib/api/types.ts. -// Section keys MUST stay in sync with the server's JSON tags: -// recently_added_albums / rediscover_albums / rediscover_artists / -// most_played_tracks / last_played_artists. -// -// Server contract guarantees all five slices are non-nil at JSON encode -// time (empty sections render as []), but we still tolerate missing keys -// so partial fixtures and offline-degraded responses can be parsed. -class HomeData { - const HomeData({ - required this.recentlyAddedAlbums, - required this.rediscoverAlbums, - required this.rediscoverArtists, - required this.mostPlayedTracks, - required this.lastPlayedArtists, - }); - - final List recentlyAddedAlbums; - final List rediscoverAlbums; - final List rediscoverArtists; - final List mostPlayedTracks; - final List lastPlayedArtists; - - factory HomeData.fromJson(Map j) => HomeData( - recentlyAddedAlbums: _list(j, 'recently_added_albums', AlbumRef.fromJson), - rediscoverAlbums: _list(j, 'rediscover_albums', AlbumRef.fromJson), - rediscoverArtists: _list(j, 'rediscover_artists', ArtistRef.fromJson), - mostPlayedTracks: _list(j, 'most_played_tracks', TrackRef.fromJson), - lastPlayedArtists: _list(j, 'last_played_artists', ArtistRef.fromJson), - ); - - static List _list( - Map j, - String key, - T Function(Map) parse, - ) { - final raw = j[key] as List? ?? const []; - return raw - .map((e) => parse((e as Map).cast())) - .toList(growable: false); - } -} diff --git a/flutter_client/lib/models/home_index.dart b/flutter_client/lib/models/home_index.dart deleted file mode 100644 index d6f4c8c8..00000000 --- a/flutter_client/lib/models/home_index.dart +++ /dev/null @@ -1,41 +0,0 @@ -/// Mirrors internal/api/types.go HomeIndexPayload. Five flat slices of -/// entity ID strings — the per-item rendering variant of HomeData. -/// Section name implies entity type; no per-entry type tag is needed. -/// -/// Slices are non-null after fromJson so callers can branch on `length` -/// instead of dealing with null sections. -class HomeIndex { - const HomeIndex({ - required this.recentlyAddedAlbums, - required this.rediscoverAlbums, - required this.rediscoverArtists, - required this.mostPlayedTracks, - required this.lastPlayedArtists, - }); - - final List recentlyAddedAlbums; - final List rediscoverAlbums; - final List rediscoverArtists; - final List mostPlayedTracks; - final List lastPlayedArtists; - - static const empty = HomeIndex( - recentlyAddedAlbums: [], - rediscoverAlbums: [], - rediscoverArtists: [], - mostPlayedTracks: [], - lastPlayedArtists: [], - ); - - factory HomeIndex.fromJson(Map j) { - List ids(String key) => - ((j[key] as List?) ?? const []).map((e) => e.toString()).toList(); - return HomeIndex( - recentlyAddedAlbums: ids('recently_added_albums'), - rediscoverAlbums: ids('rediscover_albums'), - rediscoverArtists: ids('rediscover_artists'), - mostPlayedTracks: ids('most_played_tracks'), - lastPlayedArtists: ids('last_played_artists'), - ); - } -} diff --git a/flutter_client/lib/models/invite.dart b/flutter_client/lib/models/invite.dart deleted file mode 100644 index 446a4394..00000000 --- a/flutter_client/lib/models/invite.dart +++ /dev/null @@ -1,40 +0,0 @@ -/// Mirrors `inviteResp` from internal/api/admin_invites.go. The list -/// endpoint wraps these in `{"invites": [...]}`; the create endpoint -/// returns a single bare invite. Envelope parsing is done in -/// `AdminInvitesApi`, not here. -/// -/// `invitedBy` is the UUID of the inviting admin (not their username); -/// the server doesn't currently denormalize it. Likewise `redeemedBy`. -/// TTL is hardcoded server-side at 24h, so admins can't customise the -/// expiry — only the optional `note`. -class Invite { - const Invite({ - required this.token, - required this.invitedBy, - this.note, - required this.createdAt, - required this.expiresAt, - this.redeemedAt, - this.redeemedBy, - }); - - final String token; - final String invitedBy; - final String? note; - final String createdAt; - final String expiresAt; - final String? redeemedAt; - final String? redeemedBy; - - bool get isRedeemed => redeemedAt != null; - - factory Invite.fromJson(Map j) => Invite( - token: j['token'] as String? ?? '', - invitedBy: j['invited_by'] as String? ?? '', - note: j['note'] as String?, - createdAt: j['created_at'] as String? ?? '', - expiresAt: j['expires_at'] as String? ?? '', - redeemedAt: j['redeemed_at'] as String?, - redeemedBy: j['redeemed_by'] as String?, - ); -} diff --git a/flutter_client/lib/models/lidarr.dart b/flutter_client/lib/models/lidarr.dart deleted file mode 100644 index 8a8e7f6f..00000000 --- a/flutter_client/lib/models/lidarr.dart +++ /dev/null @@ -1,47 +0,0 @@ -/// Mirrors web/src/lib/api/types.ts LidarrSearchResult — one row in -/// `/api/lidarr/search` results. `in_library` and `requested` let the -/// UI gray out rows the user can't act on (already imported / already -/// awaiting review). -class LidarrSearchResult { - const LidarrSearchResult({ - required this.mbid, - required this.name, - required this.secondaryText, - required this.imageUrl, - required this.artistMbid, - required this.albumMbid, - required this.inLibrary, - required this.requested, - }); - - final String mbid; - final String name; - final String secondaryText; - final String imageUrl; - final String artistMbid; - final String albumMbid; - final bool inLibrary; - final bool requested; - - factory LidarrSearchResult.fromJson(Map j) => - LidarrSearchResult( - mbid: j['mbid'] as String? ?? '', - name: j['name'] as String? ?? '', - secondaryText: j['secondary_text'] as String? ?? '', - imageUrl: j['image_url'] as String? ?? '', - artistMbid: j['artist_mbid'] as String? ?? '', - albumMbid: j['album_mbid'] as String? ?? '', - inLibrary: j['in_library'] as bool? ?? false, - requested: j['requested'] as bool? ?? false, - ); -} - -enum LidarrRequestKind { artist, album, track } - -extension LidarrRequestKindStr on LidarrRequestKind { - String get wire => switch (this) { - LidarrRequestKind.artist => 'artist', - LidarrRequestKind.album => 'album', - LidarrRequestKind.track => 'track', - }; -} diff --git a/flutter_client/lib/models/my_profile.dart b/flutter_client/lib/models/my_profile.dart deleted file mode 100644 index 0c6ceb5c..00000000 --- a/flutter_client/lib/models/my_profile.dart +++ /dev/null @@ -1,49 +0,0 @@ -/// Mirrors web/src/lib/api/me.ts MyProfile. The `display_name` and -/// `email` are nullable — server returns null when the user hasn't -/// set them yet (registration only requires a username). -class MyProfile { - const MyProfile({ - required this.id, - required this.username, - this.displayName, - this.email, - required this.isAdmin, - }); - - final String id; - final String username; - final String? displayName; - final String? email; - final bool isAdmin; - - factory MyProfile.fromJson(Map j) => MyProfile( - id: j['id'] as String? ?? '', - username: j['username'] as String? ?? '', - displayName: j['display_name'] as String?, - email: j['email'] as String?, - isAdmin: j['is_admin'] as bool? ?? false, - ); -} - -/// Mirrors web/src/lib/api/listenbrainz.ts LBStatus. -class ListenBrainzStatus { - const ListenBrainzStatus({ - required this.enabled, - required this.tokenSet, - this.lastScrobbledAt, - }); - - final bool enabled; - /// True when the user has stored a token (token itself is never read - /// back from the server). UI uses this to show "token saved" vs - /// "no token" without exposing the value. - final bool tokenSet; - final String? lastScrobbledAt; - - factory ListenBrainzStatus.fromJson(Map j) => - ListenBrainzStatus( - enabled: j['enabled'] as bool? ?? false, - tokenSet: j['token_set'] as bool? ?? false, - lastScrobbledAt: j['last_scrobbled_at'] as String?, - ); -} diff --git a/flutter_client/lib/models/page.dart b/flutter_client/lib/models/page.dart deleted file mode 100644 index 29df33db..00000000 --- a/flutter_client/lib/models/page.dart +++ /dev/null @@ -1,44 +0,0 @@ -// Mirrors the server's Page[T] envelope used by paged endpoints -// (/api/search, /api/library/artists, /api/library/albums, -// /api/likes/*). Class is named `Paged` rather than `Page` -// to avoid ambiguous imports with Flutter Material's Navigator-2.0 -// `Page` class. -// -// Wire shape from internal/api/types.go Page[T]: -// { items: T[], total: int, limit: int, offset: int } -// -// Dart generics can't auto-infer T from JSON, so callers pass an -// itemFromJson function alongside the raw map. Parse logic stays in -// each entity's own fromJson; this class only handles the envelope. -class Paged { - const Paged({ - required this.items, - required this.total, - required this.limit, - required this.offset, - }); - - final List items; - final int total; - final int limit; - final int offset; - - factory Paged.fromJson( - Map j, - T Function(Map) itemFromJson, - ) { - final raw = (j['items'] as List?) ?? const []; - return Paged( - items: raw - .map((e) => itemFromJson((e as Map).cast())) - .toList(growable: false), - total: (j['total'] as num?)?.toInt() ?? 0, - limit: (j['limit'] as num?)?.toInt() ?? 0, - offset: (j['offset'] as num?)?.toInt() ?? 0, - ); - } - - /// Convenience for endpoints that always return the first page or - /// when the caller just wants the items. - bool get hasMore => offset + items.length < total; -} diff --git a/flutter_client/lib/models/playlist.dart b/flutter_client/lib/models/playlist.dart deleted file mode 100644 index 1ae01860..00000000 --- a/flutter_client/lib/models/playlist.dart +++ /dev/null @@ -1,127 +0,0 @@ -// Mirrors internal/api/playlists.go Playlist + PlaylistDetail wire shapes. -// -// Server distinguishes user playlists (created by the caller) from -// system playlists (server-generated mixes like for_you / discover). -// system_variant is null for user playlists; "for_you" / "discover" -// otherwise. Read-only operations work the same; PATCH/POST/DELETE -// are gated on user-owned playlists. - -class Playlist { - const Playlist({ - required this.id, - required this.userId, - required this.name, - required this.description, - required this.isPublic, - required this.systemVariant, - required this.trackCount, - required this.coverUrl, - required this.ownerUsername, - required this.createdAt, - required this.updatedAt, - }); - - final String id; - final String userId; - final String name; - final String description; - final bool isPublic; - /// "for_you" / "discover" / null - final String? systemVariant; - final int trackCount; - /// Server-emitted URL to the cover; empty when no tracks yet. - final String coverUrl; - /// Display name of the owner — for showing other users' public playlists. - final String ownerUsername; - final String createdAt; - final String updatedAt; - - bool get isSystem => systemVariant != null; - - /// Whether this playlist supports the generic by-kind refresh/ - /// shuffle endpoints (#411 R2) — i.e. a singleton system kind. - /// The server exposes a `refreshable` flag for JSON-sourced - /// playlists, but the list tiles are drift-cache-sourced (no - /// migration just for this), so derive it: every system kind is a - /// singleton except songs_like_artist (multi-per-user). This rule - /// holds for For-You/Discover and all planned discovery mixes; if - /// a future non-singleton kind appears, extend the exclusion. - bool get refreshable => isSystem && systemVariant != 'songs_like_artist'; - - factory Playlist.fromJson(Map j) => Playlist( - id: j['id'] as String, - userId: j['user_id'] as String? ?? '', - name: j['name'] as String? ?? '', - description: j['description'] as String? ?? '', - isPublic: j['is_public'] as bool? ?? false, - systemVariant: j['system_variant'] as String?, - trackCount: (j['track_count'] as num?)?.toInt() ?? 0, - coverUrl: j['cover_url'] as String? ?? '', - ownerUsername: j['owner_username'] as String? ?? '', - createdAt: j['created_at'] as String? ?? '', - updatedAt: j['updated_at'] as String? ?? '', - ); -} - -/// Playlist row as returned in PlaylistDetail.tracks. Each row carries -/// the track's display fields directly so the client doesn't need a -/// separate /api/tracks/{id} fetch per row. -/// -/// track_id is nullable: when the upstream track has been removed from -/// the library, the row remains in the playlist with its title/artist -/// preserved but track_id=null and stream_url=null. UI should render -/// these greyed-out and unplayable. -class PlaylistTrack { - const PlaylistTrack({ - required this.position, - required this.trackId, - required this.title, - required this.albumId, - required this.albumTitle, - required this.artistId, - required this.artistName, - required this.durationSec, - required this.streamUrl, - }); - - final int position; - final String? trackId; - final String title; - final String? albumId; - final String albumTitle; - final String? artistId; - final String artistName; - final int durationSec; - final String? streamUrl; - - bool get isAvailable => trackId != null; - - factory PlaylistTrack.fromJson(Map j) => PlaylistTrack( - position: (j['position'] as num?)?.toInt() ?? 0, - trackId: j['track_id'] as String?, - title: j['title'] as String? ?? '', - albumId: j['album_id'] as String?, - albumTitle: j['album_title'] as String? ?? '', - artistId: j['artist_id'] as String?, - artistName: j['artist_name'] as String? ?? '', - durationSec: (j['duration_sec'] as num?)?.toInt() ?? 0, - streamUrl: j['stream_url'] as String?, - ); -} - -class PlaylistDetail { - const PlaylistDetail({required this.playlist, required this.tracks}); - - final Playlist playlist; - final List tracks; - - factory PlaylistDetail.fromJson(Map j) { - final tracksRaw = (j['tracks'] as List?) ?? const []; - return PlaylistDetail( - playlist: Playlist.fromJson(j), - tracks: tracksRaw - .map((e) => PlaylistTrack.fromJson((e as Map).cast())) - .toList(growable: false), - ); - } -} diff --git a/flutter_client/lib/models/quarantine_mine.dart b/flutter_client/lib/models/quarantine_mine.dart deleted file mode 100644 index d9358d6b..00000000 --- a/flutter_client/lib/models/quarantine_mine.dart +++ /dev/null @@ -1,46 +0,0 @@ -/// Mirrors web/src/lib/api/types.ts LidarrQuarantineMineRow. One row -/// per quarantined track owned by the caller. Server returns a flat -/// list (no Page envelope) at /api/quarantine/mine. -class QuarantineMineRow { - const QuarantineMineRow({ - required this.trackId, - required this.reason, - this.notes, - required this.createdAt, - required this.trackTitle, - required this.trackDurationMs, - required this.albumId, - required this.albumTitle, - this.albumCoverArtPath, - required this.artistId, - required this.artistName, - }); - - final String trackId; - /// One of: bad_rip / wrong_file / wrong_tags / duplicate / other. - final String reason; - final String? notes; - final String createdAt; - final String trackTitle; - final int trackDurationMs; - final String albumId; - final String albumTitle; - final String? albumCoverArtPath; - final String artistId; - final String artistName; - - factory QuarantineMineRow.fromJson(Map j) => - QuarantineMineRow( - trackId: j['track_id'] as String? ?? '', - reason: j['reason'] as String? ?? 'other', - notes: j['notes'] as String?, - createdAt: j['created_at'] as String? ?? '', - trackTitle: j['track_title'] as String? ?? '', - trackDurationMs: (j['track_duration_ms'] as num?)?.toInt() ?? 0, - albumId: j['album_id'] as String? ?? '', - albumTitle: j['album_title'] as String? ?? '', - albumCoverArtPath: j['album_cover_art_path'] as String?, - artistId: j['artist_id'] as String? ?? '', - artistName: j['artist_name'] as String? ?? '', - ); -} diff --git a/flutter_client/lib/models/search_response.dart b/flutter_client/lib/models/search_response.dart deleted file mode 100644 index 2d1e505b..00000000 --- a/flutter_client/lib/models/search_response.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'album.dart'; -import 'artist.dart'; -import 'page.dart'; -import 'track.dart'; - -/// Mirrors internal/api/search.go SearchResponse — three pages keyed by -/// facet, each independently paged. The mobile UI usually only walks -/// the first page of each facet (limit 20-50); infinite scroll within a -/// facet is a future enhancement, not v1. -class SearchResponse { - const SearchResponse({ - required this.artists, - required this.albums, - required this.tracks, - }); - - final Paged artists; - final Paged albums; - final Paged tracks; - - factory SearchResponse.fromJson(Map j) => SearchResponse( - artists: Paged.fromJson( - (j['artists'] as Map?)?.cast() ?? const {}, - ArtistRef.fromJson, - ), - albums: Paged.fromJson( - (j['albums'] as Map?)?.cast() ?? const {}, - AlbumRef.fromJson, - ), - tracks: Paged.fromJson( - (j['tracks'] as Map?)?.cast() ?? const {}, - TrackRef.fromJson, - ), - ); - - bool get isEmpty => - artists.items.isEmpty && albums.items.isEmpty && tracks.items.isEmpty; -} diff --git a/flutter_client/lib/models/system_playlists_status.dart b/flutter_client/lib/models/system_playlists_status.dart deleted file mode 100644 index b8588196..00000000 --- a/flutter_client/lib/models/system_playlists_status.dart +++ /dev/null @@ -1,24 +0,0 @@ -/// Mirrors `systemPlaylistsStatusResp` from internal/api/me_system_playlists.go. -/// Reflects the caller's most recent system_playlist_runs row, or zero -/// values when no row exists yet (the user has never had a build attempted). -class SystemPlaylistsStatus { - const SystemPlaylistsStatus({ - required this.inFlight, - this.lastRunAt, - this.lastError, - }); - - final bool inFlight; - final String? lastRunAt; - final String? lastError; - - factory SystemPlaylistsStatus.empty() => - const SystemPlaylistsStatus(inFlight: false); - - factory SystemPlaylistsStatus.fromJson(Map j) => - SystemPlaylistsStatus( - inFlight: j['in_flight'] as bool? ?? false, - lastRunAt: j['last_run_at'] as String?, - lastError: j['last_error'] as String?, - ); -} diff --git a/flutter_client/lib/models/track.dart b/flutter_client/lib/models/track.dart deleted file mode 100644 index 1e5adc58..00000000 --- a/flutter_client/lib/models/track.dart +++ /dev/null @@ -1,58 +0,0 @@ -// Mirrors internal/api/types.go TrackRef and web/src/lib/api/types.ts. -// -// Server uses `duration_sec` (NOT duration_ms). `track_number` and -// `disc_number` are omitempty server-side so they're nullable here. -// `stream_url` points at /api/tracks/{id}/stream. -class TrackRef { - const TrackRef({ - required this.id, - required this.title, - required this.albumId, - required this.artistId, - this.albumTitle = '', - this.artistName = '', - this.trackNumber, - this.discNumber, - this.durationSec = 0, - this.streamUrl = '', - }); - - final String id; - final String title; - final String albumId; - final String albumTitle; - final String artistId; - final String artistName; - final int? trackNumber; - final int? discNumber; - final int durationSec; - final String streamUrl; - - factory TrackRef.fromJson(Map j) => TrackRef( - id: j['id'] as String, - title: j['title'] as String, - albumId: j['album_id'] as String, - albumTitle: j['album_title'] as String? ?? '', - artistId: j['artist_id'] as String, - artistName: j['artist_name'] as String? ?? '', - trackNumber: (j['track_number'] as num?)?.toInt(), - discNumber: (j['disc_number'] as num?)?.toInt(), - durationSec: (j['duration_sec'] as num?)?.toInt() ?? 0, - streamUrl: j['stream_url'] as String? ?? '', - ); - - /// Round-trips through [TrackRef.fromJson] (same server snake_case - /// keys). Used to persist the playback queue for resume-on-launch. - Map toJson() => { - 'id': id, - 'title': title, - 'album_id': albumId, - 'album_title': albumTitle, - 'artist_id': artistId, - 'artist_name': artistName, - if (trackNumber != null) 'track_number': trackNumber, - if (discNumber != null) 'disc_number': discNumber, - 'duration_sec': durationSec, - 'stream_url': streamUrl, - }; -} diff --git a/flutter_client/lib/models/user.dart b/flutter_client/lib/models/user.dart deleted file mode 100644 index 9c674f3b..00000000 --- a/flutter_client/lib/models/user.dart +++ /dev/null @@ -1,24 +0,0 @@ -// Mirrors internal/api/types.go UserView. The /api/* user shape — narrower -// than dbq.User (no password hash, no api_token, no subsonic_password). -// -// `id` is a pgtype.UUID server-side which marshals to a plain string when -// Valid, so we accept it as String here. `is_admin` is always present in -// the server response but we tolerate its absence (default false) to keep -// older fixtures and partial mocks loadable. -class User { - const User({ - required this.id, - required this.username, - required this.isAdmin, - }); - - final String id; - final String username; - final bool isAdmin; - - factory User.fromJson(Map j) => User( - id: j['id'] as String, - username: j['username'] as String, - isAdmin: j['is_admin'] as bool? ?? false, - ); -} diff --git a/flutter_client/lib/player/album_color_extractor.dart b/flutter_client/lib/player/album_color_extractor.dart deleted file mode 100644 index 32db2ab1..00000000 --- a/flutter_client/lib/player/album_color_extractor.dart +++ /dev/null @@ -1,92 +0,0 @@ -// Dominant-color extraction from album cover art (#396 item 1). -// The full-screen Now Playing screen uses this to paint a top-to-bottom -// gradient backdrop that grounds each track in its album's palette. -// -// Implementation: reuses AlbumCoverCache to get a local file path for -// the cover, then runs PaletteGenerator over it. Results are cached -// in-memory keyed by album_id so back-to-back plays of the same album -// don't repeat the work. Cache is process-lifetime; album-art changes -// are rare enough that LRU eviction isn't worth the complexity. -// -// Returns null on any failure (no cover, empty album_id, palette -// extraction returned nothing). Callers fall back to the FabledSword -// obsidian color for the gradient when null. - -import 'dart:io'; - -import 'package:flutter/painting.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:palette_generator/palette_generator.dart'; - -import 'player_provider.dart'; - -/// Caches dominant colors keyed by album_id. Process-lifetime; refilled -/// on app restart. -class AlbumColorCache { - AlbumColorCache(this._ref); - - final Ref _ref; - final Map _cache = {}; - - /// Returns the dominant color for the album's cover, or null if no - /// cover is available or extraction fails. Same call for the same - /// album_id is cached after the first successful resolution. - Future getOrExtract(String albumId) async { - if (albumId.isEmpty) return null; - if (_cache.containsKey(albumId)) return _cache[albumId]; - final color = await _extract(albumId); - _cache[albumId] = color; - return color; - } - - /// Synchronous peek. Returns the previously-extracted color if this - /// album has been resolved this process; otherwise null. Used by - /// the now-playing fast-path swap so a warm cache transitions the - /// gradient in lockstep with the audio, instead of awaiting the - /// async [getOrExtract] future. A null return means "either no - /// extraction yet or extraction returned null" — caller falls back - /// to the async path. - Color? peekColor(String albumId) => _cache[albumId]; - - Future _extract(String albumId) async { - try { - final coverCache = _ref.read(albumCoverCacheProvider); - final path = await coverCache.getOrFetch(albumId); - if (path == null) return null; - final file = File(path); - if (!await file.exists()) return null; - final palette = await PaletteGenerator.fromImageProvider( - FileImage(file), - // Small target size: palette extraction is CPU-bound and the - // gradient only needs a single dominant color, so we don't - // need full-resolution sampling. - size: const Size(80, 80), - maximumColorCount: 8, - ); - // Prefer the explicit dominant color; fall back to the strongest - // muted swatch (which tends to read better as a background than - // a hot vibrant pick), then any populated swatch. - final swatch = palette.dominantColor ?? - palette.darkMutedColor ?? - palette.darkVibrantColor ?? - palette.mutedColor; - return swatch?.color; - } catch (_) { - return null; - } - } -} - -final albumColorCacheProvider = Provider( - (ref) => AlbumColorCache(ref), -); - -/// Family provider: dominant color for the given album_id, or null if -/// no cover / extraction failed. The Now Playing screen watches this -/// keyed by the current MediaItem's album_id. -final albumColorProvider = FutureProvider.family( - (ref, albumId) async { - final cache = ref.watch(albumColorCacheProvider); - return cache.getOrExtract(albumId); - }, -); diff --git a/flutter_client/lib/player/album_cover_cache.dart b/flutter_client/lib/player/album_cover_cache.dart deleted file mode 100644 index ac2534c4..00000000 --- a/flutter_client/lib/player/album_cover_cache.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; -import 'package:path_provider/path_provider.dart'; - -/// Caches album cover bytes to disk so MediaItem.artUri can point at a -/// file:// URI. Android's MediaSession framework fetches artUri itself -/// and doesn't carry our Bearer header, so we pre-fetch via the -/// authenticated dio and hand the system a local file path instead. -/// -/// Cache layout: `{applicationCacheDirectory}/album_covers/{albumId}.jpg`. -/// No explicit eviction — covers are tiny and OS clears app cache when -/// space is tight. -class AlbumCoverCache { - AlbumCoverCache({ - required Future Function() dioFactory, - Future Function()? cacheDirFactory, - }) : _dioFactory = dioFactory, - _cacheDirFactory = cacheDirFactory ?? getApplicationCacheDirectory; - - final Future Function() _dioFactory; - final Future Function() _cacheDirFactory; - - /// In-flight requests keyed by albumId so concurrent callers for the - /// same album dedupe to one fetch. - final Map> _inflight = {}; - - /// Cached covers directory path resolved once on the first - /// async cacheDir call, then reused for sync existsSync() checks - /// in [peekCached]. Without this every "is the cover already on - /// disk?" check would have to await path_provider, blocking the - /// MediaItem broadcast on every track change. - String? _coversDirPath; - - /// Returns the local file path for [albumId]'s cover if it's - /// already cached on disk, or null otherwise. Synchronous — uses - /// File.existsSync() against a path computed from the directory - /// resolved by an earlier async [getOrFetch] call. Returns null - /// until at least one getOrFetch has populated _coversDirPath. - /// - /// The audio handler uses this to seed MediaItem.artUri on the - /// initial broadcast so external media controllers (Wear, Android - /// Auto, Bluetooth) see the cover on the first frame for warm-cache - /// tracks. Cold-cache tracks still fall back to the async - /// _loadArtForCurrentItem path. - String? peekCached(String albumId) { - if (albumId.isEmpty) return null; - final dir = _coversDirPath; - if (dir == null) return null; - final path = '$dir/$albumId.jpg'; - return File(path).existsSync() ? path : null; - } - - /// Returns local file path to the album cover, or null on any - /// failure (network, 4xx/5xx, disk full, empty albumId). - Future getOrFetch(String albumId) { - if (albumId.isEmpty) return Future.value(null); - final existing = _inflight[albumId]; - if (existing != null) return existing; - final fut = _doFetch(albumId); - _inflight[albumId] = fut; - fut.whenComplete(() => _inflight.remove(albumId)); - return fut; - } - - Future _doFetch(String albumId) async { - try { - final dir = await _cacheDirFactory(); - final coversDir = Directory('${dir.path}/album_covers'); - await coversDir.create(recursive: true); - // Cache the resolved directory so [peekCached] can do its - // existsSync() check without re-awaiting path_provider. - _coversDirPath = coversDir.path; - final filePath = '${coversDir.path}/$albumId.jpg'; - final file = File(filePath); - if (await file.exists()) return filePath; - - final dio = await _dioFactory(); - final r = await dio.get>( - '/api/albums/$albumId/cover', - options: Options(responseType: ResponseType.bytes), - ); - final bytes = r.data; - if (bytes == null || bytes.isEmpty) return null; - await file.writeAsBytes(bytes, flush: true); - return filePath; - } catch (e) { - debugPrint('AlbumCoverCache: fetch failed for $albumId: $e'); - return null; - } - } -} diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart deleted file mode 100644 index 38614724..00000000 --- a/flutter_client/lib/player/audio_handler.dart +++ /dev/null @@ -1,1035 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:audio_service/audio_service.dart'; -import 'package:audio_session/audio_session.dart'; -import 'package:flutter/foundation.dart'; -import 'package:just_audio/just_audio.dart'; -import 'package:path_provider/path_provider.dart'; - -import '../cache/audio_cache_manager.dart'; -import '../models/track.dart'; -import 'album_cover_cache.dart'; - -class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { - MinstrelAudioHandler() { - _player.playbackEventStream.listen( - _broadcastState, - // ExoPlayer surfaces stream errors (404, premature EOS, decoder - // failure, network drop) here. Logging alone leaves the UI - // claiming "now playing X" while audio is silent — the user- - // observable mismatch between visual and audio state. Skip - // forward so the queue advances past the failed track and - // mediaItem updates to whatever's actually playing. If the - // failure is at the queue tail, just_audio will go idle on its - // own and _broadcastState reflects that. - onError: (Object e, StackTrace st) { - debugPrint('audio_handler: playbackEventStream error: $e\n$st'); - unawaited(_handlePlaybackError()); - }, - ); - _player.currentIndexStream.listen(_onCurrentIndexChanged); - // Re-broadcast on shuffle/repeat changes so the PlaybackState's - // shuffleMode + repeatMode fields stay current for UI subscribers. - _player.shuffleModeEnabledStream.listen((_) => _broadcastState(null)); - _player.loopModeStream.listen((_) => _broadcastState(null)); - // Watch buffered-position so we can register stream-cached files - // in the audio cache index once they're fully downloaded. Without - // this, files written by LockCachingAudioSource never appear in - // the index and the eviction loop can't reclaim them. - _player.bufferedPositionStream - .listen((_) => unawaited(_maybeRegisterStreamCache())); - unawaited(_configureAudioSession()); - } - - final AudioPlayer _player = AudioPlayer(); - String _baseUrl = ''; - String? _token; - AlbumCoverCache? _coverCache; - AudioCacheManager? _audioCacheManager; - LikeBridge? _likeBridge; - - /// Trackers to dedupe registration — once we've inserted an index row - /// for a trackId, don't repeat the work on every buffered-position - /// emit. Cleared on dispose only; surviving across queue rebuilds is - /// fine because the index is itself the source of truth. - final Set _streamCacheRegistered = {}; - - /// Cached on first use so we don't hit the platform channel every - /// time the buffered-position stream emits (~200ms cadence). - String? _cacheDirPath; - - /// How long the session may sit not-actively-playing (paused, or a - /// finished queue) before we tear it down so the Wear tile / lock - /// screen / notification don't linger on a stale track. The - /// notification is configured ongoing (main.dart), so nothing else - /// ever drives the MediaSession to a terminal state. - static const _idleStopTimeout = Duration(minutes: 5); - - /// Single-shot cleanup timer, armed while not actively playing and - /// cancelled the moment playback resumes or a new queue is set. - Timer? _idleStopTimer; - - /// Periodic PlaybackState re-broadcast while actively playing so - /// external surfaces (lock screen, Wear, Android Auto) interpolate the - /// scrubber smoothly. The in-app bar uses positionStream and doesn't - /// need this. Active only while playing; cancelled otherwise. - Timer? _positionBroadcastTimer; - - /// Buffering-stall watchdog. On poor coverage a stream hangs in - /// ProcessingState.buffering with NO error event, so the onError - /// recovery never fires. Armed while playing+buffering; on fire, if - /// buffered position hasn't advanced it's a dead stream → recover. - /// Re-arms a fresh window while buffering-but-progressing (slow-but- - /// downloading is not a stall). - static const _stallTimeout = Duration(seconds: 15); - Timer? _stallTimer; - Duration _stallMarkBuffered = Duration.zero; - - /// Per-track recovery budget. Reset when a track reaches - /// ready+playing. One retry of the same source (rebuilt fresh), then - /// skip to the next cached track (or pause) instead of thrashing - /// through unreachable streams. - static const _maxRecoverRetries = 1; - String? _recoveringTrackId; - int _recoverAttempts = 0; - - /// Player volume captured when an OS duck interruption begins, - /// restored when it ends. Null when not currently ducked. - double? _volumeBeforeDuck; - - /// True when an interruption (call / other media) paused playback that - /// was actively going, so a transient (pause-type) interruption end - /// auto-resumes. Cleared on resume or a non-resumable end. - bool _interruptedWhilePlaying = false; - - /// True while _fillRemainingSources is doing backward-fill inserts - /// at index 0..initialIndex-1. Each insert shifts the player's - /// currentIndex (it tracks the actively-playing source through - /// list mutations), and the resulting _onCurrentIndexChanged - /// callbacks would push the wrong MediaItem onto the stream - /// (queue.value[shifted_idx] != actively-playing track). When - /// the fill completes, currentIndex == initialIndex, mediaItem - /// is already correct, and we re-enable normal listener behavior. - bool _suppressIndexUpdates = false; - - /// Increments on every setQueueFromTracks call. The background - /// fill task captures the value at start and bails if the live - /// counter has moved on — without this, a stale fill will keep - /// addAudioSource'ing tracks from the previous playlist into the - /// new queue, leaving the player "locked" to a corrupted state. - int _queueGeneration = 0; - - /// Logical-queue index that just_audio player-index 0 currently maps - /// to. setQueueFromTracks fast-starts with a SINGLE source at player - /// index 0 while queue.value already holds the full list, so the - /// playing track's logical index is clampedInitial, not 0. The - /// transient currentIndexStream→0 emission that arrives just after - /// setAudioSources resolves (and after _suppressIndexUpdates is back - /// to false) would otherwise make _onCurrentIndexChanged broadcast - /// queue.value[0] — the FIRST track — over the correct item, pinning - /// the mini bar / playlist marker to the wrong track until a later - /// index event. Decremented in lockstep with the backward fill's - /// front inserts so player-index → logical-index stays correct and - /// lands at 0 once the player list fully matches queue.value. - int _logicalIndexBase = 0; - - /// Tracks the most recent setQueueFromTracks() input so - /// skipToQueueItem can reconstruct the source list. just_audio - /// requires every source to be built before it can be a skip - /// target, but setQueueFromTracks only builds the initial source - /// and fills the rest in the background — so a skip to an index - /// past the fill front needs to rebuild from the stored tracks. - List _lastTracks = const []; - - /// #415: which system playlist this queue was seeded from - /// ('for_you' | 'discover'), or null for library / user-playlist / - /// radio. The play-events reporter reads this so play_started - /// carries `source` and the server advances that rotation. A fresh - /// setQueueFromTracks from a non-system surface clears it; internal - /// rebuilds (skipToQueueItem) preserve it. - String? _queueSource; - String? get queueSource => _queueSource; - - /// The full TrackRef list backing the current queue (even when only - /// part is built as just_audio sources). Empty when nothing is - /// queued. Exposed for resume-state persistence (#54). - List get queuedTracks => _lastTracks; - - /// Volume stream for UI subscribers. Mirrors the just_audio player's - /// volume directly; set via setVolume(double). - Stream get volumeStream => _player.volumeStream; - double get volume => _player.volume; - - /// Position stream for UI subscribers. just_audio emits roughly every - /// 200ms while playing, which is what makes the seek bar advance - /// smoothly. PlaybackState.updatePosition only changes on event - /// transitions (play/pause/buffer), so it's too coarse for live - /// scrubbing UI. - Stream get positionStream => _player.positionStream; - Duration get position => _player.position; - - /// Broadcasts the title of a track that just failed to play (404, - /// decoder failure, premature EOS, network drop) and was auto-skipped - /// or paused by _handlePlaybackError. The app listens and surfaces a - /// debounced/coalesced SnackBar so a silent skip isn't mysterious. - /// App-lifetime singleton handler — intentionally never closed. - final _playbackErrors = StreamController.broadcast(); - Stream get playbackErrorStream => _playbackErrors.stream; - - void configure({ - required String baseUrl, - required String? token, - AlbumCoverCache? coverCache, - AudioCacheManager? audioCacheManager, - LikeBridge? likeBridge, - }) { - _baseUrl = baseUrl; - _token = token; - if (coverCache != null) _coverCache = coverCache; - if (likeBridge != null) _likeBridge = likeBridge; - if (audioCacheManager != null) _audioCacheManager = audioCacheManager; - } - - /// Invoked by play() when nothing is loaded (mediaItem == null) so a - /// media-button press after the #52 teardown resumes the last - /// persisted session (#448). Registered by ResumeController.start(); - /// null until then (then it's a no-op fall-through to _player.play()). - Future Function()? _resumeHook; - void setResumeHook(Future Function() hook) => _resumeHook = hook; - - Future setQueueFromTracks( - List tracks, { - int initialIndex = 0, - String? source, - }) async { - if (tracks.isEmpty) return; - // New playback supersedes any pending idle cleanup. play() below - // re-broadcasts and _reconcileIdleTimer would cancel anyway; doing - // it up front avoids a race during the pause→swap window. - _idleStopTimer?.cancel(); - _idleStopTimer = null; - _queueSource = source; - final clampedInitial = initialIndex.clamp(0, tracks.length - 1); - - // Bump the generation FIRST. Any in-flight _fillRemainingSources - // from a previous play will see the mismatch on its next gen - // check and stop calling player mutations — important so a stale - // fill doesn't append old-playlist tracks into the new queue. - final myGen = ++_queueGeneration; - _lastTracks = tracks; - - // Pause the old source immediately so the previous track stops - // audibly the moment the user taps, instead of bleeding through - // until the new source finishes building. setAudioSources below - // will swap the source list cleanly; pause is the simplest way - // to silence the player during the (possibly multi-100ms) build. - if (_player.playing) { - await _player.pause(); - } - - // Build MediaItems up front (pure — no side effects); we'll - // broadcast queue/mediaItem only AFTER setAudioSources resolves - // so the audio engine and the UI flip together. If the build - // throws, the UI stays on the previous track (correct: audio - // also stays on the previous track since setAudioSources never - // ran). - final items = tracks.map(_toMediaItem).toList(); - - // Build only the initial source for fast start. Remaining - // sources stream in via _fillRemainingSources() — addAudioSource - // for next/auto-advance tracks, insertAudioSource for skipPrev. - final AudioSource initial; - try { - initial = await _buildAudioSource(tracks[clampedInitial]); - } catch (e, st) { - // Source build failed (bad URL, missing baseUrl, etc.). Don't - // broadcast the new state — leaving queue/mediaItem on the - // previous track keeps UI in sync with what the player is - // actually doing (which is "still on the previous track, - // paused"). - debugPrint('audio_handler: _buildAudioSource failed: $e\n$st'); - return; - } - if (myGen != _queueGeneration) { - debugPrint('audio_handler: superseded before setAudioSources (gen=$myGen)'); - return; - } - - // Suppress _onCurrentIndexChanged side effects while the source - // list is being swapped — without this, a transient currentIndex - // emission during setAudioSources could broadcast the OLD queue's - // entry at the NEW index. Re-enabled after the broadcasts land. - _suppressIndexUpdates = true; - try { - await _player.setAudioSources([initial], initialIndex: 0); - // Player-index 0 holds the single fast-start source, which is - // logical-queue index clampedInitial. Record the offset before - // broadcasting so the post-resolve currentIndexStream→0 emission - // maps back to the correct item instead of queue.value[0]. - _logicalIndexBase = clampedInitial; - // Broadcast in this order: queue first (so any consumer that - // reacts to mediaItem and reads queue.value sees the consistent - // pair), then mediaItem. - queue.add(items); - mediaItem.add(items[clampedInitial]); - } finally { - _suppressIndexUpdates = false; - } - - unawaited(_loadArtForCurrentItem()); - unawaited(_fillRemainingSources(tracks, clampedInitial, myGen)); - } - - /// Switches playback to the [index]th queue item. The full - /// just_audio source list isn't necessarily built yet - /// (_fillRemainingSources runs in the background), so we - /// reconstruct from the stored TrackRef list rather than calling - /// _player.seek(index: ...) on a possibly-missing source. Calling - /// setQueueFromTracks again is the safe path: it bumps the - /// generation, cancels any in-flight fill, rebuilds source[0] as - /// the target, and re-fills around. play() restarts playback so - /// queue taps feel like "jump to this song" rather than "set the - /// pointer and wait for me to press play." - @override - Future skipToQueueItem(int index) async { - if (index < 0 || index >= _lastTracks.length) return; - // Preserve the system-playlist source across an internal rebuild - // — a queue-item skip is still playing from the same playlist. - await setQueueFromTracks(_lastTracks, initialIndex: index, source: _queueSource); - await play(); - } - - /// Background fill of the rest of the just_audio source list after - /// the initial source is playing. Forward direction first (most - /// common skipNext target). Backward inserts shift the player's - /// currentIndex; we suppress _onCurrentIndexChanged side effects - /// for those so the mediaItem stream doesn't bounce to the wrong - /// queue entry. - /// - /// `gen` is the queue generation captured when this fill started. - /// Every loop iteration checks against _queueGeneration; if a - /// newer setQueueFromTracks has run (user tapped play on something - /// else), bail immediately so we don't pollute the new queue with - /// addAudioSource calls from this stale fill. - Future _fillRemainingSources( - List tracks, int initialIndex, int gen) async { - for (var i = initialIndex + 1; i < tracks.length; i++) { - if (gen != _queueGeneration) return; - try { - final src = await _buildAudioSource(tracks[i]); - if (gen != _queueGeneration) return; - await _player.addAudioSource(src); - } catch (e) { - debugPrint('audio_handler: forward fill failed for ${tracks[i].id}: $e'); - } - } - if (initialIndex > 0) { - _suppressIndexUpdates = true; - try { - for (var i = 0; i < initialIndex; i++) { - if (gen != _queueGeneration) return; - final src = await _buildAudioSource(tracks[i]); - if (gen != _queueGeneration) return; - await _player.insertAudioSource(i, src); - // Each front insert shifts the playing source's player index - // up by one; drop the base in lockstep so player-index → - // logical-index stays correct (and reaches 0 once the player - // list fully matches queue.value). - _logicalIndexBase -= 1; - } - } catch (e) { - debugPrint('audio_handler: backward fill failed: $e'); - } finally { - // Only release the flag if we're still the active gen — a - // newer setQueueFromTracks already reset it for itself. - if (gen == _queueGeneration) _suppressIndexUpdates = false; - } - } - } - - String _resolveStreamUrl(TrackRef t) { - if (t.streamUrl.isEmpty) { - return '$_baseUrl/api/tracks/${t.id}/stream'; - } - final parsed = Uri.tryParse(t.streamUrl); - if (parsed != null && parsed.hasScheme) { - return t.streamUrl; - } - return '$_baseUrl${t.streamUrl}'; - } - - /// Builds an AudioSource for a track. Cache-aware: - /// 1. If the track is fully cached on disk, returns a file:// source. - /// 2. Else returns a LockCachingAudioSource that streams + caches as - /// it plays (subsequent plays will hit the cache). - /// - /// Without an audio cache manager configured (e.g. in older code paths - /// that pre-date #357), falls back to a plain network AudioSource.uri. - Future _buildAudioSource(TrackRef t) async { - final headers = _token == null ? null : {'Authorization': 'Bearer $_token'}; - final mgr = _audioCacheManager; - - // 1. Cache hit: play from disk, no headers needed. - if (mgr != null) { - final path = await mgr.pathFor(t.id); - if (path != null) { - return AudioSource.uri(Uri.file(path)); - } - } - - final url = _resolveStreamUrl(t); - final parsed = Uri.parse(url); - if (!parsed.hasScheme || parsed.host.isEmpty) { - throw StateError( - 'audio_handler: refused to play scheme-less URL "$url" ' - '(baseUrl="$_baseUrl", track.streamUrl="${t.streamUrl}", ' - 'track.id="${t.id}"). configure() must be called with a ' - 'non-empty baseUrl before setQueueFromTracks().', - ); - } - - // 2. Cache miss WITH manager: stream + cache-as-you-play. Future - // plays of this track will hit the cache. If the cache write - // completes, we don't currently register an index row — that would - // require a download-complete hook from just_audio that's not - // exposed cleanly. Acceptable for v1: prefetcher / explicit - // pin / Download buttons cover the index path; LockCaching handles - // the network optimization. - if (mgr != null) { - _cacheDirPath ??= (await getApplicationCacheDirectory()).path; - final cacheFile = File('${_cacheDirPath!}/audio_cache/${t.id}.mp3'); - // ignore: experimental_member_use - return LockCachingAudioSource(parsed, - headers: headers, cacheFile: cacheFile); - } - - // 3. No manager configured: plain network source (legacy path). - return AudioSource.uri(parsed, headers: headers); - } - - /// Inserts [track] right after the currently-playing item so it plays - /// next. If nothing is playing, appends to the end. - Future playNext(TrackRef track) async { - final source = await _buildAudioSource(track); - final item = _toMediaItem(track); - final currentIdx = _player.currentIndex; - final insertAt = currentIdx == null ? queue.value.length : currentIdx + 1; - await _player.insertAudioSource(insertAt, source); - final current = queue.value; - queue.add([ - ...current.sublist(0, insertAt), - item, - ...current.sublist(insertAt), - ]); - } - - /// Appends [track] to the end of the queue. - Future enqueue(TrackRef track) async { - final source = await _buildAudioSource(track); - final item = _toMediaItem(track); - await _player.addAudioSource(source); - queue.add([...queue.value, item]); - } - - MediaItem _toMediaItem(TrackRef t) { - // Stash album_id + artist_id in extras so widgets reconstructing - // a TrackRef from the MediaItem (player kebab → "Go to artist", - // "Go to album") have the IDs they need to navigate. Earlier code - // only carried album_id which left "Go to artist" pushing - // /artists/ (empty id, route 404). - final extras = {}; - if (t.albumId.isNotEmpty) extras['album_id'] = t.albumId; - if (t.artistId.isNotEmpty) extras['artist_id'] = t.artistId; - // Sync-peek the album cover cache so warm-cache tracks broadcast - // with artUri populated on the first frame. External media - // controllers (Android Wear, Bluetooth dashes, Auto, lock screen) - // can only render the cover bytes that audio_service hands them - // at MediaItem broadcast time; the later async _loadArtForCurrent - // Item path repopulates for cold-cache tracks. Without this seed, - // every track change starts with a generic icon on the watch and - // only gets the real cover after one or two seconds. - final coverPath = (t.albumId.isNotEmpty && _coverCache != null) - ? _coverCache!.peekCached(t.albumId) - : null; - // MediaItem.rating intentionally NOT set: audio_service propagates - // it to MediaSession.setRating(), but the Android session also - // needs setRatingType(RATING_HEART) configured to expose that to - // controllers — audio_service doesn't surface that config knob, - // and broadcasting an unanchored rating made Wear OS reject the - // session entirely. The LikeBridge wiring stays in place so - // setRating can still fire from any surface that DOES route it, - // we just don't advertise it. - return MediaItem( - id: t.id, - title: t.title, - artist: t.artistName, - album: t.albumTitle, - duration: Duration(seconds: t.durationSec), - artUri: coverPath != null ? Uri.file(coverPath) : null, - extras: extras.isEmpty ? null : extras, - ); - } - - /// Once a track is fully buffered (LockCaching has written the whole - /// file to disk), insert an audio_cache_index row so the file shows - /// up to AudioCacheManager.evict() and clearAll(). No-op if the - /// cache manager isn't configured, no current track, the file isn't - /// fully buffered yet, the on-disk file is missing, or we already - /// registered this trackId during this subscription. - Future _maybeRegisterStreamCache() async { - final mgr = _audioCacheManager; - if (mgr == null) return; - final current = mediaItem.value; - if (current == null) return; - final trackId = current.id; - if (_streamCacheRegistered.contains(trackId)) return; - - final dur = _player.duration; - if (dur == null) return; - final buf = _player.bufferedPosition; - // 200ms slack for header bytes / encoding rounding. - if (buf < dur - const Duration(milliseconds: 200)) return; - - _cacheDirPath ??= (await getApplicationCacheDirectory()).path; - final path = '${_cacheDirPath!}/audio_cache/$trackId.mp3'; - final file = File(path); - if (!await file.exists()) return; - final size = await file.length(); - if (size <= 0) return; - - _streamCacheRegistered.add(trackId); - await mgr.registerStreamCache(trackId, path, size); - } - - /// Called when playbackEventStream emits an error. Skips past the - /// failing track so the UI and audio re-converge — without this, - /// _player goes silent on a 404 / decoder failure / EOS but - /// mediaItem stays on the failed track and the user sees a "now - /// playing" header for something that isn't. - /// - /// If we're at the last track, seekToNext is a no-op; the state - /// drops to idle and _broadcastState reflects that. - /// onError entrypoint (404 / decoder / premature EOS / network drop). - /// Routes into the unified recovery path (retry the track once, then - /// skip to the next cached track or pause) instead of immediately - /// skipping the literal next — possibly also-unreachable — source. - Future _handlePlaybackError() async { - await _recoverPlayback(); - } - - void _onCurrentIndexChanged(int? idx) { - if (idx == null) return; - if (_suppressIndexUpdates) return; - // Push the new track's MediaItem onto the mediaItem stream so - // the player UI rebuilds with the new title/artist/album/cover. - // Without this, the bar and full player stayed pinned to whichever - // track was passed via setQueueFromTracks(initialIndex:) regardless - // of skip/auto-advance. - final items = queue.value; - // Map the just_audio player index back to the logical queue index. - // During the fast-start/fill window the player list is a moving - // window offset from queue.value by _logicalIndexBase; mapping the - // raw player index straight in here is what previously broadcast - // queue.value[0] (the first track) over the correct item on every - // fast-start with initialIndex > 0. - final logical = idx + _logicalIndexBase; - if (logical >= 0 && logical < items.length) { - mediaItem.add(items[logical]); - } - unawaited(_loadArtForCurrentItem()); - } - - /// Async-fetches the cover for whichever item is currently active and - /// pushes a MediaItem update with artUri set. No-op if no cache is - /// configured, no current item, the item has no album_id in extras, - /// or the fetch returns null. - Future _loadArtForCurrentItem() async { - final cache = _coverCache; - if (cache == null) return; - final current = mediaItem.value; - if (current == null) return; - final albumId = current.extras?['album_id'] as String?; - if (albumId == null || albumId.isEmpty) return; - if (current.artUri != null) return; // already set - final path = await cache.getOrFetch(albumId); - if (path == null) return; - // Discard if the user advanced to another track while we waited. - if (mediaItem.value?.id != current.id) return; - mediaItem.add(current.copyWith(artUri: Uri.file(path))); - } - - /// Display-state-only teardown. Stops playback, clears the queue and - /// current track, and broadcasts idle so external surfaces (Wear tile, - /// notification, in-app mini bar) drop their visible state — but does - /// NOT call super.stop(), so the FGS and MediaSessionCompat remain - /// alive and addressable. - /// - /// Why this matters (Fable #472): the paired Wear OS companion app - /// caches a MediaController bound to our MediaSession's token. If - /// the system destroys our service (super.stop() → stopSelf() makes - /// it eligible under memory pressure), the next play() spins up a - /// new MediaSession with a fresh token; the companion's cached - /// controller still points at the dead one and transport taps from - /// notification + watch silently no-op. Keeping the service alive - /// across idle periods preserves the binding. - /// - /// Used by the #52 idle timeout. Full termination (super.stop) is - /// reserved for the explicit-close path (onTaskRemoved while idle). - Future _softTeardown() async { - _idleStopTimer?.cancel(); - _idleStopTimer = null; - _positionBroadcastTimer?.cancel(); - _positionBroadcastTimer = null; - _stallTimer?.cancel(); - _stallTimer = null; - try { - await _player.stop(); - } catch (_) {} - playbackState.add(playbackState.value.copyWith( - playing: false, - processingState: AudioProcessingState.idle, - )); - mediaItem.add(null); - queue.add([]); - } - - /// Full termination — soft teardown plus super.stop(), which calls - /// stopSelf() on the AudioService and lets the OS reclaim it. Reserved - /// for the explicit-close path (onTaskRemoved when not playing). On the - /// idle path we use _softTeardown instead to preserve the Wear OS - /// MediaController binding (Fable #472). - @override - Future stop() async { - await _softTeardown(); - await super.stop(); - } - - /// App swiped away from recents. Keep playing if audio is active - /// (standard media behaviour — music shouldn't die because the app - /// left recents); otherwise stop so the watch tile / notification - /// don't linger on a stale paused track. - @override - Future onTaskRemoved() async { - if (_player.playing && - _player.processingState != ProcessingState.completed) { - await super.onTaskRemoved(); - return; - } - await stop(); - } - - @override - Future play() async { - // Nothing loaded (fresh, or torn down by the #52 idle/dismiss - // teardown): a media-button press resumes the last persisted - // session instead of no-op'ing (#448). The hook restores the queue - // and starts playback itself, so we return without calling - // _player.play() on an empty player. - if (mediaItem.value == null && _resumeHook != null) { - await _resumeHook!(); - return; - } - await _player.play(); - } - - @override - Future pause() => _player.pause(); - - @override - Future seek(Duration position) => _player.seek(position); - - @override - Future skipToNext() => _player.seekToNext(); - - @override - Future skipToPrevious() => _player.seekToPrevious(); - - /// Heart rating from external surfaces (Wear's favorite button, - /// lock-screen like) → LikesController.toggle(track). We only - /// route through the bridge when the rating actually flips relative - /// to the current state, so repeated taps from a flaky controller - /// don't ping-pong the like. - @override - Future setRating(Rating rating, [Map? extras]) async { - final media = mediaItem.value; - final bridge = _likeBridge; - if (media == null || bridge == null) return; - final currentlyLiked = bridge.isTrackLiked(media.id); - final wantLiked = rating.hasHeart(); - if (currentlyLiked == wantLiked) return; - try { - await bridge.toggleTrackLike(media.id); - } catch (_) { - // LikesController already rolls back on REST failure; nothing - // to do here beyond letting the broadcast skip. - return; - } - // Re-emit so the watch's heart icon updates immediately. - mediaItem.add(media.copyWith(rating: Rating.newHeartRating(wantLiked))); - } - - /// Re-emits the current mediaItem with a fresh rating pulled from - /// the LikeBridge. Called by PlayerActions on likedIdsProvider - /// changes so the watch / lock-screen heart icon updates when the - /// user toggles a like from TrackRow, the kebab menu, or another - /// device's playback (SSE-routed). No-op if no track is playing - /// or the like state didn't change. - void refreshCurrentRating() { - final media = mediaItem.value; - final bridge = _likeBridge; - if (media == null || bridge == null) return; - final liked = bridge.isTrackLiked(media.id); - if (media.rating?.hasHeart() == liked) return; - mediaItem.add(media.copyWith(rating: Rating.newHeartRating(liked))); - } - - /// Re-broadcasts PlaybackState so the notification favorite control's - /// icon/label reflects a like toggled from elsewhere (TrackRow, kebab, - /// another device via SSE). Sibling to refreshCurrentRating, which - /// updates the Wear/lock heart via MediaItem.rating. - void refreshFavoriteControl() => _broadcastState(null); - - @override - Future setShuffleMode(AudioServiceShuffleMode shuffleMode) async { - await _player - .setShuffleModeEnabled(shuffleMode != AudioServiceShuffleMode.none); - // _broadcastState picks up the change via shuffleModeEnabledStream. - } - - @override - Future setRepeatMode(AudioServiceRepeatMode repeatMode) async { - final loop = switch (repeatMode) { - AudioServiceRepeatMode.none => LoopMode.off, - AudioServiceRepeatMode.one => LoopMode.one, - AudioServiceRepeatMode.all || AudioServiceRepeatMode.group => LoopMode.all, - }; - await _player.setLoopMode(loop); - } - - /// Sets player volume in [0.0, 1.0]. Note: most mobile browsers tie - /// page audio to system volume — this is the in-app slider for parity - /// with desktop/web; on mobile the system volume is the real control. - Future setVolume(double v) async { - await _player.setVolume(v.clamp(0.0, 1.0)); - } - - /// Handles the notification favorite control (and any future custom - /// actions). Toggles the current track's like via the LikeBridge, - /// then re-broadcasts so the heart icon/label flips. Signature - /// matches `AudioHandler.customAction` (`Future`). - @override - Future customAction(String name, - [Map? extras]) async { - if (name == 'minstrel.favorite') { - final media = mediaItem.value; - final bridge = _likeBridge; - if (media == null || bridge == null) return null; - try { - await bridge.toggleTrackLike(media.id); - } catch (_) {} - _broadcastState(null); - return null; - } - return super.customAction(name, extras); - } - - // _broadcastState accepts a nullable event because the shuffle/repeat - // listeners don't have one — we just want to re-emit PlaybackState - // with up-to-date shuffleMode/repeatMode fields. - void _broadcastState(PlaybackEvent? event) { - final playing = _player.playing; - // No custom favorite MediaControl here. audio_service builds a - // PlaybackStateCompat.CustomAction for it and throws - // "You must specify an icon resource id to build a CustomAction" - // on real builds (the androidIcon doesn't resolve to a usable id), - // and that exception aborts the ENTIRE media notification — no - // tray or Wear controls at all. Removed; like/favorite remains - // available in-app and via the standard lock-screen surface. - playbackState.add(PlaybackState( - controls: [ - MediaControl.skipToPrevious, - if (playing) MediaControl.pause else MediaControl.play, - MediaControl.skipToNext, - ], - // androidCompactActionIndices tells the system which controls - // appear in the collapsed/lock-screen view. Without this, some - // Android versions render the player without working buttons. - androidCompactActionIndices: const [0, 1, 2], - // systemActions enumerates which actions the system can invoke - // on us — without play/pause/skip in here, taps on lock-screen - // controls don't route back to the handler on Android 13+. - // - // v2026.05.13.3 added stop, skipToQueueItem, setShuffleMode, - // setRepeatMode, and setRating to this set; reverted because - // Pixel Watch 2 stopped showing controls entirely after that - // change. The MediaSession contract on Wear OS requires more - // setup than just advertising the action (e.g. setRatingType - // for setRating) and audio_service doesn't expose those knobs. - // Keep the override methods themselves (skipToQueueItem is - // still routed via QueueScreen's direct handler call; - // setRating is harmless if never invoked) so we don't lose - // the underlying functionality — just don't tell the system - // we support them. - systemActions: const { - MediaAction.play, - MediaAction.pause, - MediaAction.skipToNext, - MediaAction.skipToPrevious, - MediaAction.seek, - }, - processingState: switch (_player.processingState) { - ProcessingState.idle => AudioProcessingState.idle, - ProcessingState.loading => AudioProcessingState.loading, - ProcessingState.buffering => AudioProcessingState.buffering, - ProcessingState.ready => AudioProcessingState.ready, - ProcessingState.completed => AudioProcessingState.completed, - }, - playing: playing, - updatePosition: _player.position, - bufferedPosition: _player.bufferedPosition, - speed: _player.speed, - queueIndex: event?.currentIndex ?? _player.currentIndex, - shuffleMode: _player.shuffleModeEnabled - ? AudioServiceShuffleMode.all - : AudioServiceShuffleMode.none, - repeatMode: switch (_player.loopMode) { - LoopMode.off => AudioServiceRepeatMode.none, - LoopMode.one => AudioServiceRepeatMode.one, - LoopMode.all => AudioServiceRepeatMode.all, - }, - )); - _reconcileIdleTimer(); - _reconcilePositionBroadcast(); - _reconcileStallWatchdog(); - } - - /// Arms (or cancels) the idle-cleanup timer based on the current - /// player state. Driven from _broadcastState, which fires on every - /// play/pause/buffer/complete transition, so paused AND finished-queue - /// both arm it. Idle (already stopped) never re-arms — otherwise stop() - /// would loop every _idleStopTimeout. - void _reconcileIdleTimer() { - final ps = _player.processingState; - if (ps == ProcessingState.idle) { - _idleStopTimer?.cancel(); - _idleStopTimer = null; - return; - } - final activelyPlaying = - _player.playing && ps != ProcessingState.completed; - if (activelyPlaying) { - _idleStopTimer?.cancel(); - _idleStopTimer = null; - return; - } - _idleStopTimer ??= Timer(_idleStopTimeout, _onIdleTimeout); - } - - void _onIdleTimeout() { - _idleStopTimer = null; - final ps = _player.processingState; - if (ps == ProcessingState.idle) return; // already torn down - if (_player.playing && ps != ProcessingState.completed) { - return; // resumed between arm and fire - } - // _softTeardown — not full stop() — to preserve the MediaSession - // binding for the paired Wear OS companion (Fable #472). - unawaited(_softTeardown()); - } - - /// While actively playing, keeps a 1s periodic PlaybackState re- - /// broadcast running so updateTime/updatePosition stay fresh and - /// external surfaces interpolate the scrubber smoothly. Idempotent and - /// driven from _broadcastState — which the periodic tick itself calls, - /// so the "already running" guard prevents pile-up. Cancels the moment - /// playback is no longer active. - void _reconcilePositionBroadcast() { - final ps = _player.processingState; - final active = _player.playing && - ps != ProcessingState.idle && - ps != ProcessingState.completed; - if (!active) { - _positionBroadcastTimer?.cancel(); - _positionBroadcastTimer = null; - return; - } - _positionBroadcastTimer ??= Timer.periodic( - const Duration(seconds: 1), - (_) => _broadcastState(null), - ); - } - - /// Buffering-stall watchdog. Armed while playing+buffering/loading. - /// On fire: still stuck with no >=1s buffered progress → dead stream, - /// recover; progressing → re-arm a fresh window (slow-but-downloading - /// is fine). When the track is happily ready+playing, clears the - /// per-track recovery budget. Driven from _broadcastState; mirrors - /// the other reconcile helpers. - void _reconcileStallWatchdog() { - final ps = _player.processingState; - if (ps == ProcessingState.ready && _player.playing) { - _recoveringTrackId = null; - _recoverAttempts = 0; - } - final buffering = _player.playing && - (ps == ProcessingState.buffering || ps == ProcessingState.loading); - if (!buffering) { - _stallTimer?.cancel(); - _stallTimer = null; - return; - } - if (_stallTimer != null) return; // window already running - _stallMarkBuffered = _player.bufferedPosition; - _stallTimer = Timer(_stallTimeout, () { - _stallTimer = null; - final ps2 = _player.processingState; - final stillBuffering = _player.playing && - (ps2 == ProcessingState.buffering || - ps2 == ProcessingState.loading); - if (!stillBuffering) return; - final progressed = (_player.bufferedPosition - _stallMarkBuffered) >= - const Duration(seconds: 1); - if (progressed) { - _reconcileStallWatchdog(); // slow but downloading — re-arm - } else { - unawaited(_recoverPlayback()); - } - }); - } - - /// Unified recovery for a failed OR stalled stream. Retries the - /// current track once (rebuilt from scratch via skipToQueueItem so a - /// transient blip doesn't lose it); on exhaustion, surfaces the - /// failure (#58 SnackBar) and skips to the next cached track — or - /// pauses — instead of blindly walking into more unreachable streams. - Future _recoverPlayback() async { - _stallTimer?.cancel(); - _stallTimer = null; - final media = mediaItem.value; - if (media == null) return; - final trackId = media.id; - final curIdx = _lastTracks.indexWhere((t) => t.id == trackId); - if (curIdx < 0) { - try { - await _player.pause(); - } catch (_) {} - return; - } - if (_recoveringTrackId != trackId) { - _recoveringTrackId = trackId; - _recoverAttempts = 0; - } - if (_recoverAttempts < _maxRecoverRetries) { - _recoverAttempts++; - // Rebuild + restart the SAME track (fresh source / HTTP). - await skipToQueueItem(curIdx); - return; - } - if (media.title.isNotEmpty) _playbackErrors.add(media.title); - _recoveringTrackId = null; - _recoverAttempts = 0; - await _skipToNextCachedOrPause(curIdx); - } - - /// Skips to the first track after [fromIdx] that is fully cached on - /// disk (plays with zero network). If none, pauses rather than - /// thrashing through unreachable streams on a dead connection. - Future _skipToNextCachedOrPause(int fromIdx) async { - final mgr = _audioCacheManager; - if (mgr != null) { - for (var i = fromIdx + 1; i < _lastTracks.length; i++) { - if (await mgr.pathFor(_lastTracks[i].id) != null) { - await skipToQueueItem(i); - return; - } - } - } - try { - await _player.pause(); - } catch (_) {} - } - - /// Configures the OS audio session for music playback and wires - /// interruption + becoming-noisy handling. just_audio auto-activates / - /// deactivates the session around playback once it's configured, but - /// does NOT handle interruptions or the headphones-unplugged event - /// itself — that's done here. Best effort: a failure must not break - /// playback. - Future _configureAudioSession() async { - try { - final session = await AudioSession.instance; - await session.configure(const AudioSessionConfiguration.music()); - session.interruptionEventStream.listen(_onInterruption); - session.becomingNoisyEventStream.listen((_) { - // Headphones unplugged / Bluetooth disconnected — never blast the - // phone speaker; pause like every other media app. - unawaited(_player.pause()); - }); - } catch (e, st) { - debugPrint('audio_handler: audio session configure failed: $e\n$st'); - } - } - - void _onInterruption(AudioInterruptionEvent event) { - if (event.begin) { - switch (event.type) { - case AudioInterruptionType.duck: - // Transient: another app wants the foreground briefly. Lower - // our volume rather than stopping (OS may also auto-duck). - _volumeBeforeDuck = _player.volume; - unawaited(_player.setVolume(0.3)); - case AudioInterruptionType.pause: - case AudioInterruptionType.unknown: - // Call / other media took focus. Remember whether we were - // actively playing so a transient end can resume us. - _interruptedWhilePlaying = _player.playing && - _player.processingState != ProcessingState.completed; - unawaited(_player.pause()); - } - } else { - switch (event.type) { - case AudioInterruptionType.duck: - unawaited(_player.setVolume(_volumeBeforeDuck ?? 1.0)); - _volumeBeforeDuck = null; - case AudioInterruptionType.pause: - // Transient interruption ended — resume only if WE paused it - // and the session is still alive (a long call may have let the - // #52 idle timer tear it down; re-init is resume-last-session - // territory, out of scope here). - if (_interruptedWhilePlaying && - _player.processingState != ProcessingState.idle) { - unawaited(_player.play()); - } - _interruptedWhilePlaying = false; - case AudioInterruptionType.unknown: - // Unknown end — do not auto-resume (could surprise the user). - _interruptedWhilePlaying = false; - } - } - } -} - -/// Adapter that lets the audio handler call into the app's -/// LikesController without depending on Riverpod directly. Constructed -/// in PlayerActions where ref is available; consumed inside the audio -/// handler's setRating override and MediaItem builder to keep external -/// media controllers (Wear, lock screen, Auto) in sync with the -/// likedIds drift cache. -class LikeBridge { - const LikeBridge({ - required this.toggleTrackLike, - required this.isTrackLiked, - }); - - /// Flip the like state for [trackId]. Returns a Future that resolves - /// once the underlying LikesController has both updated drift - /// optimistically and rolled the change through the REST API - /// (errors result in drift rollback inside LikesController). - final Future Function(String trackId) toggleTrackLike; - - /// Read the current like state for [trackId] from the drift-backed - /// likedIdsProvider. Synchronous because the audio handler needs - /// to populate MediaItem.rating on the broadcast hot path. - final bool Function(String trackId) isTrackLiked; -} diff --git a/flutter_client/lib/player/now_playing_screen.dart b/flutter_client/lib/player/now_playing_screen.dart deleted file mode 100644 index 9301e477..00000000 --- a/flutter_client/lib/player/now_playing_screen.dart +++ /dev/null @@ -1,655 +0,0 @@ -import 'dart:io'; - -import 'package:audio_service/audio_service.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../api/endpoints/likes.dart' show LikeKind; -import '../likes/like_button.dart'; -import '../models/track.dart'; -import '../shared/widgets/server_image.dart'; -import '../shared/widgets/track_actions/track_actions_button.dart'; -import '../theme/theme_extension.dart'; -import 'album_color_extractor.dart'; -import 'player_provider.dart'; - -/// Hero tag shared between the mini player's cover and the full-screen -/// _AlbumArt's cover so tapping the mini bar animates the artwork from -/// the bar's footprint to the full-screen size. Stable per-route (not -/// keyed by media.id) so the transition works regardless of what's -/// playing. -const String kPlayerCoverHeroTag = 'player-cover'; - -/// Duration for the AnimatedSwitcher / AnimatedContainer track-change -/// crossfade. ~300ms reads as a smooth transition without dragging. -const Duration _trackChangeDuration = Duration(milliseconds: 300); - -/// Full-screen player. Mounted on /now-playing. Pushed via a slide-up -/// transition (see routing.dart). Dismisses three ways: -/// -/// - System back / leading button → Navigator.pop -/// - Vertical drag down past threshold → Navigator.pop (matches the -/// slide-down animation users expect from a "pull down to close" -/// sheet) -/// - Tap of the mini player ascends here; reverse motion descends back. -class NowPlayingScreen extends ConsumerStatefulWidget { - const NowPlayingScreen({super.key}); - - @override - ConsumerState createState() => _NowPlayingScreenState(); -} - -class _NowPlayingScreenState extends ConsumerState { - // Cumulative drag distance used to decide if a vertical drag should - // dismiss. Reset on each drag start. - double _dragOffset = 0; - - /// The MediaItem currently displayed on the screen. Held separately - /// from the live mediaItemProvider so we can preload the new track's - /// cover bytes + dominant color BEFORE flipping the visible state. - /// Without this gate the full-player UI swapped to the new track's - /// title/cover slot immediately while the image was still decoding, - /// producing a visible "pop in" once the bytes arrived. - MediaItem? _displayedMedia; - - /// Dominant color of [_displayedMedia]'s cover. Tweened by the - /// backdrop AnimatedContainer when it changes. - Color? _displayedDominant; - - /// The id of the most-recent track we kicked a preload for. Used to - /// drop stale preload completions when the user skips rapidly past - /// a track whose cover hadn't finished decoding yet. - String? _pendingPreloadId; - - @override - void initState() { - super.initState(); - // Seed displayed state from the current mediaItem if a track is - // already playing when the full player is opened. ref.listen - // below only fires on CHANGES after subscription, so without - // this seed the screen sits on "Nothing playing." even though - // the mini bar shows a live track — the listener never gets a - // mediaItem emission because the stream's current value hasn't - // changed. - final current = ref.read(mediaItemProvider).value; - if (current == null) return; - _displayedMedia = current; - // Best-effort synchronous color seed: if albumColorProvider has - // already extracted this album's dominant color earlier in the - // session, pull it now so the backdrop renders correctly on - // first frame. Otherwise the post-frame _scheduleSwap below - // resolves it asynchronously and AnimatedContainer tweens. - final albumId = current.extras?['album_id'] as String?; - if (albumId != null && albumId.isNotEmpty) { - _displayedDominant = - ref.read(albumColorProvider(albumId)).asData?.value; - } - // Kick the preload pipeline post-frame so the cover bytes are - // decoded + the dominant color is resolved even when the user - // opens the player to a track that hasn't yet flowed through - // ref.listen. - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - _scheduleSwap(current); - }); - } - - /// Preload the new track's cover bytes + dominant color, then - /// atomically flip _displayedMedia / _displayedDominant. Until both - /// resolve, the screen continues to render the previous track — - /// the new title/cover/gradient appear together in one frame. - /// - /// Concurrency: if a second track change arrives while the first - /// preload is still in flight, the older preload's - /// _pendingPreloadId check fails and its completion is dropped. - Future _scheduleSwap(MediaItem newMedia) async { - _pendingPreloadId = newMedia.id; - - // Fast path: when Prefetcher has already warmed both the cover - // bytes (file:// artUri populated via _toMediaItem's peekCached) - // AND the palette color (AlbumColorCache.peekColor returns - // non-null), we can commit synchronously. The slow async path - // exists for genuine cold-cache moments (first play of an album - // that sync hasn't seen yet); the fast path is what makes the - // common in-queue auto-advance flip the visual in lockstep with - // the audio transition instead of lagging a tick behind. - final artUri = newMedia.artUri; - final albumId = newMedia.extras?['album_id'] as String?; - if (artUri != null && - artUri.isScheme('file') && - albumId != null && - albumId.isNotEmpty) { - final cachedColor = ref.read(albumColorCacheProvider).peekColor(albumId); - if (cachedColor != null) { - if (!mounted) return; - if (_pendingPreloadId != newMedia.id) return; - setState(() { - _displayedMedia = newMedia; - _displayedDominant = cachedColor; - }); - return; - } - } - - // Slow path: cover and/or color are not yet cached. Hold the - // current displayed state, preload, then atomic-commit. - // - // 1. Precache the cover image bytes so when _AlbumArt mounts with - // the new media, FileImage paints synchronously. Non-file - // artUris fall through to ServerImage which handles its own - // network load + 120ms fade — they won't snap. - if (artUri != null && artUri.isScheme('file') && context.mounted) { - try { - await precacheImage(FileImage(File.fromUri(artUri)), context); - } catch (_) { - // Decode failed (missing file, corrupt bytes) — proceed - // anyway; _AlbumArt's errorBuilder shows the slate fallback. - } - } - - // 2. Wait for the dominant-color extraction so the gradient is - // populated when we swap. PaletteGenerator runs against the - // same FileImage, typically resolving within ~50ms once the - // decode completes. - Color? newDominant; - if (albumId != null && albumId.isNotEmpty) { - try { - newDominant = await ref.read(albumColorProvider(albumId).future); - } catch (_) { - // Color extraction failed — keep the previous dominant so the - // backdrop doesn't drop to obsidian on the swap. - } - } - - // 3. Atomic flip. Bail if a newer swap superseded us, or the - // screen was disposed mid-preload. - if (!mounted) return; - if (_pendingPreloadId != newMedia.id) return; - setState(() { - _displayedMedia = newMedia; - if (newDominant != null) _displayedDominant = newDominant; - }); - } - - void _onDragStart(DragStartDetails _) { - _dragOffset = 0; - } - - void _onDragUpdate(DragUpdateDetails d) { - _dragOffset += d.delta.dy; - } - - void _onDragEnd(DragEndDetails d) { - // Pop when either the user has dragged > 80px down OR flicked - // downward at speed (>500 px/s). Either should feel responsive - // without dismissing on accidental drags. - final flicked = d.primaryVelocity != null && d.primaryVelocity! > 500; - if (_dragOffset > 80 || flicked) { - Navigator.of(context).maybePop(); - } - _dragOffset = 0; - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - - // Listen for mediaItem changes and schedule a preload-then-swap. - // Build renders _displayedMedia / _displayedDominant; the live - // mediaItemProvider only drives the swap pipeline. - // - // Decision process: - // - On the first non-null mediaItem after mount, seed - // _displayedMedia immediately so the screen has something to - // paint. - // - On a track id change, kick off a preload. The new track's - // cover bytes + dominant color must both resolve before we - // flip the displayed state. The user sees the previous track - // in full until the new one is fully ready, then a clean - // atomic swap (no fade, no placeholder flash). - // - On the same track id but the artUri-bearing rebroadcast - // (audio_handler sends MediaItem twice on track change), - // refresh through the same preload pipeline so the displayed - // cover reflects the freshly-written AlbumCoverCache file. - ref.listen>(mediaItemProvider, (prev, next) { - final newMedia = next.asData?.value; - if (newMedia == null) { - if (_displayedMedia != null) { - setState(() { - _displayedMedia = null; - _displayedDominant = null; - _pendingPreloadId = null; - }); - // Session was torn down (#52 idle/dismiss) while the full - // player was open. Don't strand the user on an empty - // "Nothing playing." screen — minimize back (the mini bar is - // already gone too). maybePop is a no-op if this is somehow - // the root route. - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) Navigator.of(context).maybePop(); - }); - } - return; - } - if (_displayedMedia == null) { - // First mount with a track playing — seed immediately, then - // kick the preload pipeline to update the dominant color and - // ensure the cover is decoded. - setState(() => _displayedMedia = newMedia); - _scheduleSwap(newMedia); - return; - } - final isNewTrack = newMedia.id != _displayedMedia!.id; - final coverGotPopulated = - newMedia.id == _displayedMedia!.id && - newMedia.artUri != null && - _displayedMedia!.artUri == null; - if (isNewTrack || coverGotPopulated) { - _scheduleSwap(newMedia); - } - }); - - final playback = ref.watch(playbackStateProvider).value; - final displayedMedia = _displayedMedia; - - if (displayedMedia == null) { - // Either nothing playing, or the very first mount before a - // mediaItem has been emitted. The ref.listen above will seed - // _displayedMedia as soon as a non-null MediaItem arrives. - return const Scaffold(body: Center(child: Text('Nothing playing.'))); - } - - // Use positionProvider (just_audio's positionStream, ~200ms) for - // the seek bar so it scrubs live; PlaybackState.updatePosition - // only fires on state transitions and would leave the bar frozen - // between them. - final pos = ref.watch(positionProvider).value ?? Duration.zero; - final dur = displayedMedia.duration ?? Duration.zero; - final isPlaying = playback?.playing == true; - final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all; - final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none; - final actions = ref.read(playerActionsProvider); - final albumId = (displayedMedia.extras?['album_id'] as String?) ?? ''; - - // Backdrop color: render the dominant we've already committed to - // _displayedDominant. AnimatedContainer tweens between successive - // values, so the only color changes the user sees are the - // atomic-with-cover swaps from _scheduleSwap. 0.55 alpha keeps - // the gradient present without overwhelming the title/artist - // text below. - final dominant = _displayedDominant ?? fs.obsidian; - final gradientTop = dominant.withValues(alpha: 0.55); - - return Scaffold( - backgroundColor: fs.obsidian, - // The whole screen accepts vertical drag for dismissal. Buttons - // and the seek slider are still tappable since gesture arena - // gives priority to their child gesture detectors. - body: GestureDetector( - onVerticalDragStart: _onDragStart, - onVerticalDragUpdate: _onDragUpdate, - onVerticalDragEnd: _onDragEnd, - behavior: HitTestBehavior.translucent, - child: AnimatedContainer( - duration: _trackChangeDuration, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [gradientTop, fs.obsidian], - stops: const [0.0, 0.7], - ), - ), - child: SafeArea( - child: Column( - children: [ - _TopBar(fs: fs), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - children: [ - const Spacer(), - // No AnimatedSwitcher: _displayedMedia only - // advances after the preload pipeline has the - // new cover + color ready, so a track change - // is an atomic swap. Fading would just smear a - // transition the user explicitly asked us not - // to add. - _AlbumArt( - media: displayedMedia, albumId: albumId, fs: fs), - const SizedBox(height: 28), - _TitleRow(media: displayedMedia, fs: fs), - const SizedBox(height: 4), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - displayedMedia.artist ?? '', - style: TextStyle(color: fs.ash, fontSize: 14), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if ((displayedMedia.album ?? '').isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - displayedMedia.album!, - style: TextStyle(color: fs.ash, fontSize: 12), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - const SizedBox(height: 24), - _SecondaryControls( - fs: fs, - actions: actions, - shuffleOn: shuffleOn, - repeatMode: repeatMode, - media: displayedMedia, - ), - const SizedBox(height: 8), - _SeekRow(position: pos, duration: dur, fs: fs, ref: ref), - const SizedBox(height: 24), - _PrimaryControls( - fs: fs, - ref: ref, - isPlaying: isPlaying, - ), - const SizedBox(height: 24), - ], - ), - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -class _TopBar extends StatelessWidget { - const _TopBar({required this.fs}); - final FabledSwordTheme fs; - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 48, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Row(children: [ - IconButton( - icon: Icon(LucideIcons.chevron_down, color: fs.parchment), - tooltip: 'Close', - onPressed: () => Navigator.of(context).maybePop(), - ), - const Spacer(), - IconButton( - icon: Icon(LucideIcons.list_music, color: fs.parchment), - tooltip: 'Queue', - onPressed: () => GoRouter.of(context).push('/queue'), - ), - ]), - ), - ); - } -} - -class _AlbumArt extends StatelessWidget { - const _AlbumArt({required this.media, required this.albumId, required this.fs}); - final MediaItem media; - final String albumId; - final FabledSwordTheme fs; - - @override - Widget build(BuildContext context) { - // Pick the best available source. AlbumCoverCache writes a file:// - // URI to media.artUri once the cover lands on disk; before then, - // fall back to fetching via ServerImage from /api/albums//cover - // (which carries the auth header and falls back gracefully on - // failure). - Widget cover; - final artUri = media.artUri; - if (artUri != null && artUri.isScheme('file')) { - cover = Image( - image: FileImage(File.fromUri(artUri)), - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container(color: fs.slate), - ); - } else if (albumId.isNotEmpty) { - cover = ServerImage( - url: '/api/albums/$albumId/cover', - fit: BoxFit.cover, - fallback: Container(color: fs.slate), - ); - } else { - cover = Container(color: fs.slate); - } - return AspectRatio( - aspectRatio: 1, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 320, maxHeight: 320), - child: Hero( - tag: kPlayerCoverHeroTag, - // flightShuttleBuilder ensures the in-flight Hero renders the - // destination's cover image (not the mini bar's small one) - // during the entire animation, which reads as a smooth grow - // rather than a swap mid-flight. - flightShuttleBuilder: - (_, __, ___, ____, toHeroContext) => toHeroContext.widget, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: cover, - ), - ), - ), - ); - } -} - -class _TitleRow extends StatelessWidget { - const _TitleRow({required this.media, required this.fs}); - final MediaItem media; - final FabledSwordTheme fs; - - @override - Widget build(BuildContext context) { - // Title-only — like + kebab moved into _SecondaryControls above - // the seek bar so they share the same row as shuffle/repeat/queue. - return Text( - media.title, - style: TextStyle(color: fs.parchment, fontSize: 22), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - ); - } -} - -class _SeekRow extends StatelessWidget { - const _SeekRow({ - required this.position, - required this.duration, - required this.fs, - required this.ref, - }); - final Duration position; - final Duration duration; - final FabledSwordTheme fs; - final WidgetRef ref; - - String _fmt(Duration d) { - final m = d.inMinutes.remainder(60).toString(); - final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); - return '$m:$s'; - } - - @override - Widget build(BuildContext context) { - final maxMs = duration.inMilliseconds.toDouble().clamp(1.0, double.infinity); - return Column(children: [ - SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 3, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7), - overlayShape: const RoundSliderOverlayShape(overlayRadius: 14), - ), - child: Slider( - activeColor: fs.accent, - inactiveColor: fs.slate, - min: 0, - max: maxMs, - value: position.inMilliseconds - .toDouble() - .clamp(0.0, duration.inMilliseconds.toDouble()), - onChanged: (v) => ref - .read(audioHandlerProvider) - .seek(Duration(milliseconds: v.toInt())), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(_fmt(position), style: TextStyle(color: fs.ash, fontSize: 11)), - Text(_fmt(duration), style: TextStyle(color: fs.ash, fontSize: 11)), - ], - ), - ), - ]); - } -} - -class _PrimaryControls extends StatelessWidget { - const _PrimaryControls({ - required this.fs, - required this.ref, - required this.isPlaying, - }); - final FabledSwordTheme fs; - final WidgetRef ref; - final bool isPlaying; - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - IconButton( - iconSize: 36, - icon: Icon(LucideIcons.skip_back, color: fs.parchment), - onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(), - ), - IconButton( - iconSize: 72, - icon: Icon( - isPlaying ? LucideIcons.circle_pause : LucideIcons.circle_play, - color: fs.accent, - ), - onPressed: () { - final h = ref.read(audioHandlerProvider); - if (isPlaying) { - h.pause(); - } else { - h.play(); - } - }, - ), - IconButton( - iconSize: 36, - icon: Icon(LucideIcons.skip_forward, color: fs.parchment), - onPressed: () => ref.read(audioHandlerProvider).skipToNext(), - ), - ], - ); - } -} - -/// Action row sitting just above the seek bar. Holds shuffle / repeat -/// / queue plus the like + kebab that used to live in the title row, -/// so the title can sit truly centered above and this row carries -/// every per-track action in one place. -class _SecondaryControls extends StatelessWidget { - const _SecondaryControls({ - required this.fs, - required this.actions, - required this.shuffleOn, - required this.repeatMode, - required this.media, - }); - final FabledSwordTheme fs; - final PlayerActions actions; - final bool shuffleOn; - final AudioServiceRepeatMode repeatMode; - final MediaItem media; - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - IconButton( - tooltip: shuffleOn ? 'Shuffle on' : 'Shuffle off', - icon: Icon( - LucideIcons.shuffle, - color: shuffleOn ? fs.accent : fs.ash, - ), - onPressed: actions.toggleShuffle, - ), - IconButton( - tooltip: switch (repeatMode) { - AudioServiceRepeatMode.none => 'Repeat off', - AudioServiceRepeatMode.one => 'Repeat one', - _ => 'Repeat all', - }, - icon: Icon( - repeatMode == AudioServiceRepeatMode.one - ? LucideIcons.repeat_1 - : LucideIcons.repeat, - color: repeatMode == AudioServiceRepeatMode.none - ? fs.ash - : fs.accent, - ), - onPressed: actions.cycleRepeat, - ), - IconButton( - tooltip: 'Queue', - icon: Icon(LucideIcons.list_music, color: fs.ash), - onPressed: () => GoRouter.of(context).push('/queue'), - ), - LikeButton(kind: LikeKind.track, id: media.id, size: 22), - TrackActionsButton( - track: TrackRef( - id: media.id, - title: media.title, - albumId: (media.extras?['album_id'] as String?) ?? '', - albumTitle: media.album ?? '', - artistId: (media.extras?['artist_id'] as String?) ?? '', - artistName: media.artist ?? '', - durationSec: media.duration?.inSeconds ?? 0, - streamUrl: '', - ), - hideQueueActions: true, - // /now-playing lives outside the ShellRoute. Pushing - // /artists/:id or /albums/:id (both shell-children) on top - // of it would make go_router try to mount a duplicate - // ShellRoute (the same _debugCheckDuplicatedPageKeys crash - // we hit before with /queue). Await our own pop fully - // before pushing the destination so go_router never sees - // both pages active in the same frame. - onNavigate: (path) async { - await Navigator.of(context).maybePop(); - if (context.mounted) GoRouter.of(context).push(path); - }, - ), - ], - ); - } -} diff --git a/flutter_client/lib/player/play_events_reporter.dart b/flutter_client/lib/player/play_events_reporter.dart deleted file mode 100644 index a06d2630..00000000 --- a/flutter_client/lib/player/play_events_reporter.dart +++ /dev/null @@ -1,277 +0,0 @@ -// Flutter play-event lifecycle reporter (#415 stage 3). -// -// The Flutter client previously reported NO plays — listening on -// mobile never reached the server's play_events, so history, -// recommendation scoring, ListenBrainz scrobbles, and (since #415) -// system-playlist rotation all missed mobile activity entirely. This -// closes that gap and is the path that carries the #415 `source` tag. -// -// State machine over (current track id, playing). Mirrors the web -// events dispatcher, with one deliberate divergence: when a track -// changes inside a queue we classify ended-vs-skipped by whether the -// prior track reached (near) its duration, instead of the web -// dispatcher's blanket "track change = skip". Blanket-skip would mark -// every naturally-finished in-queue track as a skip and dilute the -// recommendation skip-ratio — the exact failure mode that motivated -// doing this properly. (The web dispatcher likely has the same -// false-skip issue; flagged separately, not fixed here.) - -import 'dart:async'; -import 'dart:math'; - -import 'package:audio_service/audio_service.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/events.dart'; -import '../auth/auth_provider.dart' show secureStorageProvider; -import '../cache/mutation_queue.dart' - show MutationKinds, mutationQueueProvider; -import '../library/library_providers.dart' show dioProvider; -import 'audio_handler.dart' show MinstrelAudioHandler; -import 'player_provider.dart' show audioHandlerProvider; - -const _clientIdKey = 'play_events_client_id'; - -/// Tolerance for "the track basically finished": within 3s of the -/// known duration counts as a natural completion, not a skip. -const _completionToleranceMs = 3000; - -class PlayEventsReporter with WidgetsBindingObserver { - PlayEventsReporter(this._ref); - final Ref _ref; - - final _subs = >[]; - bool _disposed = false; - - String? _clientId; - EventsApi? _api; - - // Server play_event_id when the live play_started succeeded; null - // if start failed / offline — then the close is captured into the - // offline mutation queue instead of a live ended/skipped call. - String? _openPlayEventId; - - // The play currently being tracked, captured independently of - // connectivity so an offline play is still a complete record. - String? _curTrackId; - DateTime? _curStartedAt; - String? _curSource; - int _curLastPositionMs = 0; - int _curDurationMs = 0; - bool _curReachedEnd = false; - - String? _prevTrackId; - - Future start() async { - final MinstrelAudioHandler handler; - try { - // audioHandlerProvider throws until main() overrides it (real - // app always does). In tests / no-audio environments there's - // nothing to report — fail safe and stay inert rather than - // surfacing an unhandled async error. - handler = _ref.read(audioHandlerProvider); - _clientId = await _resolveClientId(); - final dio = await _ref.read(dioProvider.future); - _api = EventsApi(dio); - } catch (_) { - return; - } - if (_disposed) return; - WidgetsBinding.instance.addObserver(this); - - _subs.add(handler.positionStream.listen((p) { - final ms = p.inMilliseconds; - // Advance the tracked play's progress ONLY while its track is - // the current one. A track change resets position to 0; gating - // on _curTrackId keeps the finishing track's last-known values - // intact for the close branch. - final mi = handler.mediaItem.value; - if (mi != null && mi.id == _curTrackId) { - _curLastPositionMs = ms; - final d = mi.duration; - if (d != null && d.inMilliseconds > 0) { - _curDurationMs = d.inMilliseconds; - if (ms >= _curDurationMs - _completionToleranceMs) { - _curReachedEnd = true; - } - } - } - })); - _subs.add(handler.mediaItem.listen((_) => _evaluate(handler))); - _subs.add(handler.playbackState.listen((_) => _evaluate(handler))); - } - - void _evaluate(MinstrelAudioHandler handler) { - if (_disposed) return; - final mi = handler.mediaItem.value; - final st = handler.playbackState.value; - final tid = mi?.id; - final playing = st.playing; - final completed = st.processingState == AudioProcessingState.completed; - - // Track changed → close the prior tracked play. - if (tid != _prevTrackId && _curTrackId != null) { - _closeCurrent(viaOffline: false); - } - - // Entered playing for a new track → begin tracking it. - if (tid != null && playing && _curTrackId != tid) { - _beginTrack(handler, tid); - } - - // Whole-queue natural end (just_audio only emits `completed` at - // the end of the sequence, not between items) → close it. - if (completed && _curTrackId != null) { - _curReachedEnd = true; - _closeCurrent(viaOffline: false); - } - - _prevTrackId = tid; - } - - void _beginTrack(MinstrelAudioHandler handler, String trackId) { - _curTrackId = trackId; - _curStartedAt = DateTime.now().toUtc(); - _curSource = handler.queueSource; - _curLastPositionMs = 0; - _curReachedEnd = false; - final d = handler.mediaItem.value?.duration; - _curDurationMs = d?.inMilliseconds ?? 0; - _openPlayEventId = null; - // Fire the live play_started; adopt the server id only if we're - // still on this track when the response lands. Failure is fine — - // the close path captures the whole play into the offline queue. - _startLive(handler, trackId); - } - - Future _startLive(MinstrelAudioHandler handler, String trackId) async { - final api = _api; - final cid = _clientId; - if (api == null || cid == null) return; - try { - final id = await api.playStarted( - trackId: trackId, - clientId: cid, - source: _curSource, - ); - if (id != null && _curTrackId == trackId) { - _openPlayEventId = id; - } - } catch (_) { - // Offline / flaky — _openPlayEventId stays null; the close path - // enqueues the completed play for replay. - } - } - - /// Closes the currently-tracked play. `finished` is derived from - /// whether it reached ~its duration. If the live start registered a - /// server id we attempt the live ended/skipped close and fall back - /// to the offline queue on failure; with no server id (offline - /// start) — or viaOffline (app teardown, must be durable) — the - /// completed play is enqueued directly. The server's RecordOffline - /// Play applies the canonical skip rule, so the offline payload - /// only needs duration, not our finished/skipped guess. - void _closeCurrent({required bool viaOffline}) { - final trackId = _curTrackId; - final startedAt = _curStartedAt; - if (trackId == null || startedAt == null) { - _resetCurrent(); - return; - } - final reached = _curReachedEnd; - final lastPos = _curLastPositionMs; - final durationMs = (reached && _curDurationMs > 0) - ? _curDurationMs - : lastPos; - final source = _curSource; - final id = _openPlayEventId; - - if (!viaOffline && id != null) { - // Live close; on failure, fall back to the durable offline path - // so a transient blip at close time doesn't lose the play. - final fut = reached - ? _api?.playEnded(playEventId: id, durationPlayedMs: durationMs) - : _api?.playSkipped(playEventId: id, positionMs: lastPos); - fut?.catchError((_) { - _enqueueOffline(trackId, startedAt, source, durationMs); - }); - } else { - _enqueueOffline(trackId, startedAt, source, durationMs); - } - _resetCurrent(); - } - - void _resetCurrent() { - _curTrackId = null; - _curStartedAt = null; - _curSource = null; - _curLastPositionMs = 0; - _curDurationMs = 0; - _curReachedEnd = false; - _openPlayEventId = null; - } - - void _enqueueOffline( - String trackId, - DateTime startedAt, - String? source, - int durationPlayedMs, - ) { - final cid = _clientId; - if (cid == null) return; - // ignore: unawaited_futures - _ref.read(mutationQueueProvider).enqueue(MutationKinds.playOffline, { - 'trackId': trackId, - 'clientId': cid, - 'at': startedAt.toIso8601String(), - 'durationPlayedMs': durationPlayedMs, - if (source != null && source.isNotEmpty) 'source': source, - }); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - // App backgrounded / killed mid-play: close durably via the - // offline queue (a fire-and-forget POST during teardown is - // unreliable; the queue survives a process kill and drains on - // next launch). Mirrors the intent of web's pagehide beacon. - if (state == AppLifecycleState.paused || - state == AppLifecycleState.detached) { - if (_curTrackId != null) { - _closeCurrent(viaOffline: true); - } - } - } - - Future _resolveClientId() async { - final storage = _ref.read(secureStorageProvider); - final existing = await storage.read(key: _clientIdKey); - if (existing != null && existing.isNotEmpty) return existing; - final rnd = Random.secure(); - final bytes = List.generate(16, (_) => rnd.nextInt(256)); - final id = - bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); - await storage.write(key: _clientIdKey, value: id); - return id; - } - - void dispose() { - _disposed = true; - WidgetsBinding.instance.removeObserver(this); - for (final s in _subs) { - s.cancel(); - } - _subs.clear(); - } -} - -/// Read once at app start (app.dart postFrame) to activate reporting. -/// Disposed via ref.onDispose when the scope tears down. -final playEventsReporterProvider = Provider((ref) { - final r = PlayEventsReporter(ref); - ref.onDispose(r.dispose); - // ignore: unawaited_futures - r.start(); - return r; -}); diff --git a/flutter_client/lib/player/playback_error_reporter.dart b/flutter_client/lib/player/playback_error_reporter.dart deleted file mode 100644 index b4f8b67d..00000000 --- a/flutter_client/lib/player/playback_error_reporter.dart +++ /dev/null @@ -1,78 +0,0 @@ -// Surfaces playback errors (#58). _handlePlaybackError in the audio -// handler silently skips a dead track (404 / decoder failure / premature -// EOS / network drop) with only a debugPrint — which hides exactly the -// signal that distinguishes "this track is broken" from "the app is -// flaky" (server file moved, auth expired, cache miss, transcode fail). -// -// This listens to the handler's playbackErrorStream and shows a -// transient SnackBar via a global ScaffoldMessenger key. Bursts are -// coalesced: a debounce window collects errors and emits one message -// ("Skipped N unplayable tracks") instead of stacking N toasts. - -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import 'player_provider.dart'; - -/// Set on MaterialApp.router so SnackBars can be shown from outside any -/// widget's BuildContext (the handler's error stream is isolate-side). -final scaffoldMessengerKey = GlobalKey(); - -class PlaybackErrorReporter { - PlaybackErrorReporter(this._ref); - final Ref _ref; - - StreamSubscription? _sub; - Timer? _debounce; - final _buffer = []; - bool _disposed = false; - - void start() { - try { - // audioHandlerProvider throws until main() overrides it (the real - // app always does). In tests / no-audio environments there's - // nothing to report — stay inert. - final h = _ref.read(audioHandlerProvider); - _sub = h.playbackErrorStream.listen(_onError); - } catch (_) { - return; - } - } - - void _onError(String title) { - if (_disposed) return; - _buffer.add(title); - _debounce?.cancel(); - _debounce = Timer(const Duration(seconds: 2), _flush); - } - - void _flush() { - if (_disposed || _buffer.isEmpty) return; - final n = _buffer.length; - final first = _buffer.first; - _buffer.clear(); - final msg = n == 1 - ? 'Couldn’t play “$first” — skipping' - : 'Skipped $n unplayable tracks'; - scaffoldMessengerKey.currentState?.showSnackBar( - SnackBar(content: Text(msg)), - ); - } - - void dispose() { - _disposed = true; - _debounce?.cancel(); - _sub?.cancel(); - } -} - -/// Read once at app start (app.dart postFrame). Disposed via -/// ref.onDispose when the scope tears down. -final playbackErrorReporterProvider = Provider((ref) { - final r = PlaybackErrorReporter(ref); - ref.onDispose(r.dispose); - r.start(); - return r; -}); diff --git a/flutter_client/lib/player/player_bar.dart b/flutter_client/lib/player/player_bar.dart deleted file mode 100644 index 3c8be42d..00000000 --- a/flutter_client/lib/player/player_bar.dart +++ /dev/null @@ -1,346 +0,0 @@ -import 'dart:io'; - -import 'package:audio_service/audio_service.dart'; -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../api/endpoints/likes.dart' show LikeKind; -import '../likes/like_button.dart'; -import '../models/track.dart'; -import '../shared/widgets/track_actions/track_actions_button.dart'; -import '../theme/theme_extension.dart'; -import 'now_playing_screen.dart' show kPlayerCoverHeroTag; -import 'player_provider.dart'; - -/// Compact player bar mounted at the bottom of the app shell. Mini -/// view only — the heavyweight shuffle/repeat/queue/volume controls -/// live in NowPlayingScreen, accessible by tap or swipe-up. -/// -/// ┌─────────────────────────────────────┬──────────┐ -/// │ [art] Title ♥ ⋮ │ ⏮ ⏯ ⏭ │ -/// │ Artist │ │ -/// ├─────────────────────────────────────────────────┤ -/// │ 0:00 ━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:06 │ -/// └─────────────────────────────────────────────────┘ -/// -/// Tap anywhere on the bar (including art/title) ascends to the full -/// player. A vertical drag upward also ascends, so the gesture mirrors -/// the drag-down dismissal on the full screen. -class PlayerBar extends ConsumerStatefulWidget { - const PlayerBar({super.key}); - - @override - ConsumerState createState() => _PlayerBarState(); -} - -class _PlayerBarState extends ConsumerState { - /// Last non-null artUri we've seen from the mediaItem stream. Held - /// across the audio_handler's two-broadcast track-change pattern - /// (bare MediaItem first, then with artUri once AlbumCoverCache - /// resolves) so the mini bar keeps showing the previous track's - /// cover until the new one is known. Without this hold the bar - /// flickered to the slate placeholder for ~100ms on every track - /// change. - Uri? _lastArtUri; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final media = ref.watch(mediaItemProvider).value; - final playback = ref.watch(playbackStateProvider).value; - if (media == null) return const SizedBox.shrink(); - - // Capture the latest non-null artUri so _TrackInfo's cover stays - // continuous across track changes. - if (media.artUri != null) _lastArtUri = media.artUri; - final displayMedia = media.artUri == null && _lastArtUri != null - ? media.copyWith(artUri: _lastArtUri) - : media; - - // positionProvider is just_audio's positionStream (~200ms cadence) - // so the mini bar's seek crawls forward smoothly. PlaybackState - // only updates on event transitions and would leave it frozen. - final pos = ref.watch(positionProvider).value ?? Duration.zero; - final dur = media.duration ?? Duration.zero; - - return Material( - color: fs.iron, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => context.push('/now-playing'), - // Swipe up anywhere on the bar to expand into the full player. - // We only act on a clear upward flick (>200 px/s) so a slow - // tap-with-tiny-jitter doesn't accidentally open the screen. - onVerticalDragEnd: (d) { - final v = d.primaryVelocity ?? 0; - if (v < -200) context.push('/now-playing'); - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded(child: _TrackInfo(media: displayMedia)), - const SizedBox(width: 8), - _PlayControls(playback: playback, ref: ref), - ], - ), - ), - const SizedBox(height: 4), - _SeekRow(position: pos, duration: dur), - ], - ), - ), - ), - ); - } -} - -class _TrackInfo extends StatelessWidget { - const _TrackInfo({required this.media}); - final MediaItem media; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final artistName = (media.artist ?? '').trim(); - - // Cover is wrapped in a Hero with the shared kPlayerCoverHeroTag so - // tapping the mini bar to expand into NowPlayingScreen animates the - // artwork from this 48dp footprint to the full-screen size rather - // than fade-cutting. Tag is stable per-route (not keyed by media.id) - // so the transition works regardless of what's playing. - // - // Why no transition / placeholder handling: the audio_handler - // broadcasts MediaItem twice on track change — once with artUri - // null (the new track's bare metadata), then with artUri set once - // AlbumCoverCache resolves. The widget tree above hands us the - // most-recent non-null artUri (`displayArtUri`), so the previous - // track's cover stays visible across that null gap and the new - // cover snaps in the moment its artUri arrives. Rapid change is - // fine here per operator preference; AnimatedSwitcher previously - // smeared the swap and made the slate placeholder visible. - final displayArtUri = media.artUri; - final Widget cover; - if (displayArtUri != null) { - // CachedNetworkImageProvider for HTTPS art URIs so the mini bar - // hits the same disk cache the rest of the UI uses (ServerImage, - // discover thumbnails). FileImage stays for the file:// branch - // populated by AlbumCoverCache — bytes are already on disk and a - // second cache layer would only burn duplicate space. - cover = Image( - image: displayArtUri.isScheme('file') - ? FileImage(File.fromUri(displayArtUri)) as ImageProvider - : CachedNetworkImageProvider(displayArtUri.toString()), - width: 48, - height: 48, - // Without a fit, Image paints the source at its intrinsic - // resolution inside the 48dp box — a thumbnail-sized cover - // would render as a tiny inset. Cover stretches/crops to fill - // the box uniformly. - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => - Container(width: 48, height: 48, color: fs.slate), - ); - } else { - cover = Container(width: 48, height: 48, color: fs.slate); - } - return Row( - // Default centering vertically aligns the like/kebab buttons - // against the album art (48dp) — visually they span the title + - // artist block instead of sitting on the title baseline. - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Hero( - tag: kPlayerCoverHeroTag, - child: cover, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - media.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14), - ), - if (artistName.isNotEmpty) - Text( - artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ], - ), - ), - // Like + kebab as siblings to the title/artist column rather - // than nested inside the title row. Width stays 32dp each so - // horizontal footprint matches what we had; height stretches - // to the row's full 48dp so the icons sit at vertical center - // against the title+artist block. - SizedBox( - width: 32, - height: 48, - child: LikeButton( - kind: LikeKind.track, - id: media.id, - size: 20, - ), - ), - SizedBox( - width: 32, - height: 48, - child: TrackActionsButton( - track: _trackRefFromMediaItem(media), - hideQueueActions: true, - ), - ), - ], - ); - } -} - -class _PlayControls extends StatelessWidget { - const _PlayControls({required this.playback, required this.ref}); - final PlaybackState? playback; - final WidgetRef ref; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final isPlaying = playback?.playing == true; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 36, - height: 36, - child: IconButton( - padding: EdgeInsets.zero, - iconSize: 22, - icon: Icon(LucideIcons.skip_back, color: fs.parchment), - onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(), - ), - ), - SizedBox( - width: 44, - height: 44, - child: IconButton( - padding: EdgeInsets.zero, - iconSize: 32, - icon: Icon( - isPlaying ? LucideIcons.circle_pause : LucideIcons.circle_play, - color: fs.accent, - ), - onPressed: () { - final h = ref.read(audioHandlerProvider); - if (isPlaying) { - h.pause(); - } else { - h.play(); - } - }, - ), - ), - SizedBox( - width: 36, - height: 36, - child: IconButton( - padding: EdgeInsets.zero, - iconSize: 22, - icon: Icon(LucideIcons.skip_forward, color: fs.parchment), - onPressed: () => ref.read(audioHandlerProvider).skipToNext(), - ), - ), - ], - ); - } -} - -class _SeekRow extends ConsumerWidget { - const _SeekRow({required this.position, required this.duration}); - final Duration position; - final Duration duration; - - String _fmt(Duration d) { - final m = d.inMinutes.remainder(60).toString(); - final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); - return '$m:$s'; - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final maxMs = - duration.inMilliseconds.toDouble().clamp(1.0, double.infinity); - return Row( - children: [ - SizedBox( - width: 36, - child: Text( - _fmt(position), - textAlign: TextAlign.right, - style: TextStyle(color: fs.ash, fontSize: 10), - ), - ), - Expanded( - child: SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 2, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), - overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), - ), - child: Slider( - activeColor: fs.accent, - inactiveColor: fs.slate, - min: 0, - max: maxMs, - value: position.inMilliseconds - .toDouble() - .clamp(0.0, duration.inMilliseconds.toDouble()), - onChanged: (v) => ref - .read(audioHandlerProvider) - .seek(Duration(milliseconds: v.toInt())), - ), - ), - ), - SizedBox( - width: 36, - child: Text( - _fmt(duration), - style: TextStyle(color: fs.ash, fontSize: 10), - ), - ), - ], - ); - } -} - -/// Reconstructs a minimal TrackRef from a MediaItem so the -/// TrackActionsButton has the fields its sheet expects. Mirrors the -/// pattern used by NowPlayingScreen — extras['album_id'] is what -/// audio_handler stashed at queue-build time. -TrackRef _trackRefFromMediaItem(MediaItem media) => TrackRef( - id: media.id, - title: media.title, - albumId: (media.extras?['album_id'] as String?) ?? '', - albumTitle: media.album ?? '', - // artist_id is stashed in extras by audio_handler — without it - // the kebab's "Go to artist" pushes /artists/ (empty id) and 404s. - artistId: (media.extras?['artist_id'] as String?) ?? '', - artistName: media.artist ?? '', - durationSec: media.duration?.inSeconds ?? 0, - streamUrl: '', - ); diff --git a/flutter_client/lib/player/player_provider.dart b/flutter_client/lib/player/player_provider.dart deleted file mode 100644 index 8a8be4db..00000000 --- a/flutter_client/lib/player/player_provider.dart +++ /dev/null @@ -1,198 +0,0 @@ -import 'dart:math' show Random; - -import 'package:audio_service/audio_service.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/likes.dart' show LikeKind; -import '../api/endpoints/radio.dart'; -import '../auth/auth_provider.dart'; -import '../cache/audio_cache_manager.dart'; -import '../library/library_providers.dart' show dioProvider; -import '../likes/likes_provider.dart'; -import '../models/track.dart'; -import 'album_cover_cache.dart'; -import 'audio_handler.dart'; - -final audioHandlerProvider = Provider((ref) { - throw UnimplementedError('overridden in main()'); -}); - -final albumCoverCacheProvider = Provider((ref) { - return AlbumCoverCache( - dioFactory: () => ref.read(dioProvider.future), - ); -}); - -final playbackStateProvider = StreamProvider( - (ref) => ref.watch(audioHandlerProvider).playbackState, -); - -final mediaItemProvider = StreamProvider( - (ref) => ref.watch(audioHandlerProvider).mediaItem, -); - -final queueProvider = StreamProvider>( - (ref) => ref.watch(audioHandlerProvider).queue, -); - -/// Player volume in [0.0, 1.0]. Mirrors just_audio's player.volume; -/// modify via PlayerActions.setVolume(). Surfaced separately from -/// PlaybackState because audio_service's PlaybackState doesn't carry -/// volume — it's a player-side concern. -final volumeProvider = StreamProvider( - (ref) => ref.watch(audioHandlerProvider).volumeStream, -); - -/// Live playback position. just_audio emits at ~200ms cadence so seek -/// bars driven by this provider scrub smoothly. Don't use -/// PlaybackState.updatePosition for that — it only changes on state -/// transitions (play/pause/buffer/seek) and the bar would appear -/// frozen between events. -final positionProvider = StreamProvider( - (ref) => ref.watch(audioHandlerProvider).positionStream, -); - -class PlayerActions { - PlayerActions(this._ref) { - // Keep MediaItem.rating in sync with the drift-backed likedIds - // cache so external media surfaces (Wear's heart button, lock- - // screen favorite, Auto's like icon) reflect the current state - // even when the user toggles a like from somewhere other than - // the watch itself — e.g. tapping the heart on a TrackRow, the - // kebab menu, or another logged-in device propagating via SSE. - _ref.listen>(likedIdsProvider, (_, next) { - if (next.value == null) return; - _ref.read(audioHandlerProvider) - ..refreshCurrentRating() - ..refreshFavoriteControl(); - }); - } - final Ref _ref; - - Future playTracks( - List tracks, { - int initialIndex = 0, - bool shuffle = false, - String? source, - }) async { - // shuffle=true means "play this pool randomly, starting at a - // random track" (client-side; used for non-system surfaces that - // want shuffle). System playlists instead fetch a server-ordered - // list and pass source — no client shuffle, the order is already - // rotation-aware (#415). - var startAt = initialIndex; - if (shuffle && tracks.length > 1) { - startAt = Random().nextInt(tracks.length); - } - final url = await _ref.read(serverUrlProvider.future); - final token = await _ref.read(secureStorageProvider).read(key: 'session_token'); - final cache = _ref.read(albumCoverCacheProvider); - final audioCache = _ref.read(audioCacheManagerProvider); - final h = _ref.read(audioHandlerProvider) - ..configure( - baseUrl: url ?? '', - token: token, - coverCache: cache, - audioCacheManager: audioCache, - likeBridge: _buildLikeBridge(), - ); - await h.setQueueFromTracks(tracks, initialIndex: startAt, source: source); - if (shuffle) { - await h.setShuffleMode(AudioServiceShuffleMode.all); - } - await h.play(); - } - - /// Rebuilds a previously-persisted queue WITHOUT auto-playing, then - /// seeks to [position]. The resume-on-launch path (#54): the user - /// sees their last track in the mini bar, paused, and continues with - /// play / a media button. Mirrors playTracks' configure step so - /// streaming works after restore; intentionally no h.play(). - Future restoreQueue( - List tracks, { - int initialIndex = 0, - Duration position = Duration.zero, - String? source, - }) async { - if (tracks.isEmpty) return; - final url = await _ref.read(serverUrlProvider.future); - final token = await _ref.read(secureStorageProvider).read(key: 'session_token'); - final cache = _ref.read(albumCoverCacheProvider); - final audioCache = _ref.read(audioCacheManagerProvider); - final h = _ref.read(audioHandlerProvider) - ..configure( - baseUrl: url ?? '', - token: token, - coverCache: cache, - audioCacheManager: audioCache, - likeBridge: _buildLikeBridge(), - ); - await h.setQueueFromTracks(tracks, initialIndex: initialIndex, source: source); - if (position > Duration.zero) { - await h.seek(position); - } - } - - /// Builds the adapter the audio handler uses to read + flip the - /// current track's like state in response to external media- - /// controller events (Wear's heart button, lock-screen favorite). - /// Constructed here because the audio handler is initialized in - /// main.dart before the ProviderScope exists; passing the bridge - /// in via configure() keeps the handler Riverpod-agnostic while - /// still letting it call into LikesController + likedIdsProvider. - LikeBridge _buildLikeBridge() { - return LikeBridge( - toggleTrackLike: (id) => - _ref.read(likesControllerProvider).toggle(LikeKind.track, id), - isTrackLiked: (id) => - _ref.read(likedIdsProvider).value?.has(LikeKind.track, id) ?? false, - ); - } - - Future playNext(TrackRef track) async { - final h = _ref.read(audioHandlerProvider); - await h.playNext(track); - } - - Future enqueue(TrackRef track) async { - final h = _ref.read(audioHandlerProvider); - await h.enqueue(track); - } - - /// Toggles shuffle on/off. Pre-existing audio_service convention. - Future toggleShuffle() async { - final h = _ref.read(audioHandlerProvider); - final state = await h.playbackState.first; - final next = state.shuffleMode == AudioServiceShuffleMode.none - ? AudioServiceShuffleMode.all - : AudioServiceShuffleMode.none; - await h.setShuffleMode(next); - } - - /// Cycles repeat mode: none → all → one → none. - Future cycleRepeat() async { - final h = _ref.read(audioHandlerProvider); - final state = await h.playbackState.first; - final next = switch (state.repeatMode) { - AudioServiceRepeatMode.none => AudioServiceRepeatMode.all, - AudioServiceRepeatMode.all => AudioServiceRepeatMode.one, - _ => AudioServiceRepeatMode.none, - }; - await h.setRepeatMode(next); - } - - Future setVolume(double v) async { - await _ref.read(audioHandlerProvider).setVolume(v); - } - - /// Fetches `/api/radio?seed_track=` and starts playing the - /// returned track list (seed at index 0 + recommended picks). - Future startRadio(String trackId) async { - final dio = await _ref.read(dioProvider.future); - final tracks = await RadioApi(dio).seedTrack(trackId); - if (tracks.isEmpty) return; - await playTracks(tracks); - } -} - -final playerActionsProvider = Provider((ref) => PlayerActions(ref)); diff --git a/flutter_client/lib/player/queue_screen.dart b/flutter_client/lib/player/queue_screen.dart deleted file mode 100644 index 87eff48f..00000000 --- a/flutter_client/lib/player/queue_screen.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:audio_service/audio_service.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../theme/theme_extension.dart'; -import 'player_provider.dart'; - -class QueueScreen extends ConsumerWidget { - const QueueScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final queue = ref.watch(queueProvider).value ?? const []; - final current = ref.watch(mediaItemProvider).value; - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - leading: IconButton( - icon: Icon(LucideIcons.arrow_left, color: fs.parchment), - onPressed: () => context.pop(), - ), - title: Text('Queue', style: TextStyle(color: fs.parchment)), - ), - body: queue.isEmpty - ? Center( - child: Text('Queue is empty.', style: TextStyle(color: fs.ash)), - ) - : ListView.separated( - itemCount: queue.length, - separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron), - itemBuilder: (ctx, i) => _QueueRow( - item: queue[i], - index: i, - isCurrent: current?.id == queue[i].id, - onTap: () async { - final h = ref.read(audioHandlerProvider); - await h.skipToQueueItem(i); - await h.play(); - }, - ), - ), - ); - } -} - -class _QueueRow extends StatelessWidget { - const _QueueRow({ - required this.item, - required this.index, - required this.isCurrent, - required this.onTap, - }); - - final MediaItem item; - final int index; - final bool isCurrent; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final dur = item.duration; - String? durLabel; - if (dur != null) { - final m = (dur.inSeconds ~/ 60).toString().padLeft(2, '0'); - final s = (dur.inSeconds % 60).toString().padLeft(2, '0'); - durLabel = '$m:$s'; - } - return InkWell( - onTap: isCurrent ? null : onTap, - child: Container( - decoration: BoxDecoration( - border: isCurrent - ? Border(left: BorderSide(color: fs.accent, width: 2)) - : null, - color: isCurrent ? fs.iron : null, - ), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row(children: [ - if (isCurrent) - Padding( - padding: const EdgeInsets.only(right: 8), - child: Icon(LucideIcons.audio_lines, color: fs.accent, size: 16), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: isCurrent ? fs.accent : fs.parchment, - fontSize: 14, - fontWeight: isCurrent ? FontWeight.w500 : FontWeight.w400, - ), - ), - Text( - '${item.artist ?? ''} · ${item.album ?? ''}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ], - ), - ), - if (durLabel != null) - Text(durLabel, style: TextStyle(color: fs.ash, fontSize: 12)), - ]), - ), - ); - } -} diff --git a/flutter_client/lib/playlists/playlist_detail_screen.dart b/flutter_client/lib/playlists/playlist_detail_screen.dart deleted file mode 100644 index c91e733e..00000000 --- a/flutter_client/lib/playlists/playlist_detail_screen.dart +++ /dev/null @@ -1,396 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../models/playlist.dart'; -import '../models/track.dart'; -import '../player/player_provider.dart'; -import '../shared/live_events_provider.dart'; -import '../shared/widgets/track_actions/track_actions_button.dart'; -import '../theme/theme_extension.dart'; -import 'playlists_provider.dart'; - -class PlaylistDetailScreen extends ConsumerWidget { - const PlaylistDetailScreen({required this.id, this.seed, super.key}); - final String id; - - /// Optional Playlist passed via go_router extra so the header - /// (title, cover, track count, play/download CTAs) can render - /// before the full detail fetch resolves. Same pattern as album - /// + artist nav hydration. - final Playlist? seed; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - // #402 wire-up: invalidate the detail provider on playlist.updated / - // .tracks_changed events whose payload matches the visible id. On - // .deleted matching this id, navigate back so the user isn't left - // staring at a gone-from-server playlist. - ref.listen>(liveEventsProvider, (_, next) { - final e = next.asData?.value; - if (e == null) return; - final eventPlaylistId = e.data['playlist_id'] as String?; - if (eventPlaylistId != id) return; - switch (e.kind) { - case 'playlist.updated': - case 'playlist.tracks_changed': - ref.invalidate(playlistDetailProvider(id)); - case 'playlist.deleted': - if (context.mounted) context.pop(); - } - }); - final detail = ref.watch(playlistDetailProvider(id)); - - // Resolve the best playlist info available for the AppBar title. - final livePlaylist = detail.value?.playlist; - final headerName = (livePlaylist != null && livePlaylist.name.isNotEmpty) - ? livePlaylist.name - : (seed?.name ?? ''); - - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - leading: IconButton( - icon: Icon(LucideIcons.arrow_left, color: fs.parchment), - onPressed: () => context.pop(), - ), - title: headerName.isEmpty - ? const SizedBox.shrink() - : Text( - headerName, - style: TextStyle(color: fs.parchment), - overflow: TextOverflow.ellipsis, - ), - ), - // AnimatedSwitcher between the skeleton body and the real body - // smooths the cold-visit moment when bulk detail lands. 220ms - // matches the per-tile reveal feel used on home / liked tabs. - body: AnimatedSwitcher( - duration: const Duration(milliseconds: 220), - switchInCurve: Curves.easeOut, - child: detail.when( - loading: () => _SkeletonBody( - key: const ValueKey('skeleton'), - seed: seed, - ), - error: (e, _) => Center( - key: const ValueKey('error'), - child: Text('$e', style: TextStyle(color: fs.error)), - ), - data: (d) => _Body(key: const ValueKey('body'), detail: d), - ), - ), - ); - } -} - -/// Cold-visit body: header from the seed (if any) + N skeleton rows. -/// N comes from the seed's trackCount so the row count matches the -/// real list when it lands — no layout jump on swap. Without a seed -/// (deep link straight to a playlist with no prior cache) the -/// skeleton renders a small default and grows when real data arrives. -class _SkeletonBody extends StatelessWidget { - const _SkeletonBody({super.key, this.seed}); - final Playlist? seed; - - static const _defaultSkeletonCount = 8; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final count = seed?.trackCount ?? _defaultSkeletonCount; - return ListView.builder( - itemCount: count + 1, // +1 for header - itemBuilder: (ctx, i) { - if (i == 0) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (seed != null && seed!.description.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text( - seed!.description, - style: TextStyle(color: fs.ash, fontSize: 13), - ), - ), - Text( - seed == null - ? 'Loading…' - : '${seed!.trackCount} ${seed!.trackCount == 1 ? "track" : "tracks"}', - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ], - ), - ); - } - return const _PlaylistTrackSkeleton(); - }, - ); - } -} - -/// Skeleton matched to _PlaylistTrackRow's layout (no cover image — -/// playlist rows are text-only). Two text-shaped placeholders for -/// title + secondary line, plus a duration block on the right. -class _PlaylistTrackSkeleton extends StatelessWidget { - const _PlaylistTrackSkeleton(); - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Container(width: 200, height: 14, color: fs.slate), - const SizedBox(height: 6), - Container(width: 140, height: 12, color: fs.slate), - ], - ), - ), - const SizedBox(width: 16), - Container(width: 32, height: 12, color: fs.slate), - ], - ), - ); - } -} - -class _Body extends ConsumerWidget { - const _Body({super.key, required this.detail}); - final PlaylistDetail detail; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final tracks = detail.tracks; - final playable = tracks.where((t) => t.isAvailable).toList(); - - return RefreshIndicator( - onRefresh: () async => - ref.refresh(playlistDetailProvider(detail.playlist.id).future), - child: ListView.builder( - // Header + (track rows | empty hint). - itemCount: tracks.isEmpty ? 2 : tracks.length + 1, - itemBuilder: (ctx, i) { - if (i == 0) return _Header(detail: detail, playable: playable); - if (tracks.isEmpty) { - return Padding( - padding: const EdgeInsets.all(24), - child: Center( - child: Text( - 'No tracks yet. Add some via the "Add to playlist…" entry on any track row.', - style: TextStyle(color: fs.ash), - textAlign: TextAlign.center, - ), - ), - ); - } - final t = tracks[i - 1]; - return _PlaylistTrackRow( - row: t, - onTap: t.isAvailable - ? () { - final ref = ProviderScope.containerOf(ctx); - final liveTrack = _toTrackRef(t); - final playableRefs = - playable.map(_toTrackRef).toList(growable: false); - final startIdx = playable.indexWhere((p) => p.trackId == t.trackId); - ref.read(playerActionsProvider).playTracks( - playableRefs, - initialIndex: startIdx >= 0 ? startIdx : 0, - source: detail.playlist.refreshable - ? detail.playlist.systemVariant - : null, - ); - // Keep liveTrack referenced to avoid an unused-variable - // warning while we leave hooks for menu wiring later. - assert(liveTrack.id == t.trackId); - } - : null, - ); - }, - ), - ); - } -} - -TrackRef _toTrackRef(PlaylistTrack t) => TrackRef( - id: t.trackId ?? '', - title: t.title, - albumId: t.albumId ?? '', - albumTitle: t.albumTitle, - artistId: t.artistId ?? '', - artistName: t.artistName, - durationSec: t.durationSec, - streamUrl: t.streamUrl ?? '', - ); - -class _Header extends ConsumerWidget { - const _Header({required this.detail, required this.playable}); - final PlaylistDetail detail; - final List playable; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final p = detail.playlist; - return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (p.description.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text( - p.description, - style: TextStyle(color: fs.ash, fontSize: 13), - ), - ), - Row(children: [ - Text( - '${p.trackCount} ${p.trackCount == 1 ? "track" : "tracks"}', - style: TextStyle(color: fs.ash, fontSize: 12), - ), - const Spacer(), - if (playable.isNotEmpty) ...[ - if (p.refreshable) ...[ - OutlinedButton.icon( - key: const Key('regenerate_playlist_button'), - onPressed: () => _regenerate(context, ref), - icon: const Icon(LucideIcons.refresh_cw, size: 16), - label: const Text('Regenerate'), - ), - const SizedBox(width: 8), - ], - FilledButton.icon( - onPressed: () { - final refs = playable.map(_toTrackRef).toList(growable: false); - ref.read(playerActionsProvider).playTracks( - refs, - source: p.refreshable ? p.systemVariant : null, - ); - }, - icon: const Icon(LucideIcons.play), - label: const Text('Play'), - style: FilledButton.styleFrom( - backgroundColor: fs.accent, - foregroundColor: fs.parchment, - ), - ), - ], - ]), - ]), - ); - } - - /// Forces a server-side rebuild of this system playlist. The - /// rebuild rotates the playlist UUID, so the old detail route now - /// 404s — rebind by pushReplacement-ing to the new id. The - /// aggregate list is invalidated too so the home row's tile points - /// at the fresh UUID. ScaffoldMessenger + router captured before - /// the await so we don't touch a stale BuildContext after. - Future _regenerate(BuildContext context, WidgetRef ref) async { - final messenger = ScaffoldMessenger.of(context); - final router = GoRouter.of(context); - final p = detail.playlist; - try { - final api = await ref.read(playlistsApiProvider.future); - final newId = await api.refreshSystem(p.systemVariant!); - ref.invalidate(playlistsListProvider); - if (newId == null) { - messenger.showSnackBar(const SnackBar( - content: Text('Nothing to build yet — library is empty.'), - )); - return; - } - messenger.showSnackBar( - SnackBar(content: Text('${p.name} regenerated')), - ); - router.pushReplacement('/playlists/$newId'); - } catch (e) { - messenger.showSnackBar( - SnackBar(content: Text("Couldn't regenerate: $e")), - ); - } - } -} - -class _PlaylistTrackRow extends ConsumerWidget { - const _PlaylistTrackRow({required this.row, required this.onTap}); - final PlaylistTrack row; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final mins = (row.durationSec ~/ 60).toString().padLeft(2, '0'); - final secs = (row.durationSec % 60).toString().padLeft(2, '0'); - // "Now playing" highlight — matches _QueueRow and TrackRow so the - // user sees which playlist row is current without reading the - // player bar. Unavailable rows never match. - final currentId = ref.watch(mediaItemProvider).value?.id; - final isCurrent = row.isAvailable && - currentId != null && - currentId == row.trackId; - final baseColor = row.isAvailable ? fs.parchment : fs.ash; - final titleColor = isCurrent ? fs.accent : baseColor; - return Container( - decoration: BoxDecoration( - color: isCurrent ? fs.iron : null, - border: isCurrent - ? Border(left: BorderSide(color: fs.accent, width: 2)) - : null, - ), - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row(children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - row.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: titleColor, - fontSize: 14, - fontWeight: - isCurrent ? FontWeight.w500 : FontWeight.w400, - decoration: row.isAvailable - ? null - : TextDecoration.lineThrough, - ), - ), - Text( - '${row.artistName} · ${row.albumTitle}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ], - ), - ), - Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)), - if (row.trackId != null) - TrackActionsButton(track: _toTrackRef(row)), - ]), - ), - ), - ); - } -} diff --git a/flutter_client/lib/playlists/playlists_list_screen.dart b/flutter_client/lib/playlists/playlists_list_screen.dart deleted file mode 100644 index 23410842..00000000 --- a/flutter_client/lib/playlists/playlists_list_screen.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../models/playlist.dart'; -import '../shared/widgets/main_app_bar_actions.dart'; -import '../shared/widgets/server_image.dart'; -import '../theme/theme_extension.dart'; -import 'playlists_provider.dart'; - -class PlaylistsListScreen extends ConsumerWidget { - const PlaylistsListScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - // "all" returns user-created + system mixes. Most useful default - // on mobile where the user wants to see for-you/discover alongside - // their own playlists in one tap. - final list = ref.watch(playlistsListProvider('all')); - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - title: Text('Playlists', style: TextStyle(color: fs.parchment)), - actions: const [MainAppBarActions(currentRoute: '/playlists')], - ), - body: list.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Text('$e', style: TextStyle(color: fs.error)), - ), - data: (lists) { - final items = lists.all; - if (items.isEmpty) { - return Center( - child: Text( - 'No playlists yet.', - style: TextStyle(color: fs.ash), - ), - ); - } - return RefreshIndicator( - onRefresh: () async => ref.refresh(playlistsListProvider('all').future), - child: ListView.separated( - itemCount: items.length, - separatorBuilder: (_, __) => - Divider(height: 1, color: fs.iron), - itemBuilder: (ctx, i) { - final p = items[i]; - return _PlaylistTile( - playlist: p, - onTap: () => ctx.push('/playlists/${p.id}', extra: p), - ); - }, - ), - ); - }, - ), - ); - } -} - -class _PlaylistTile extends StatelessWidget { - const _PlaylistTile({required this.playlist, required this.onTap}); - final Playlist playlist; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row(children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Container( - width: 56, - height: 56, - color: fs.slate, - child: playlist.coverUrl.isEmpty - ? Icon(LucideIcons.list_music, color: fs.ash) - : ServerImage( - url: playlist.coverUrl, - fit: BoxFit.cover, - fallback: Icon(LucideIcons.list_music, color: fs.ash), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - Flexible( - child: Text( - playlist.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 15), - ), - ), - if (playlist.isSystem) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: fs.iron, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - playlist.systemVariant!.replaceAll('_', ' '), - style: TextStyle(color: fs.accent, fontSize: 11), - ), - ), - ], - ]), - const SizedBox(height: 2), - Text( - '${playlist.trackCount} ${playlist.trackCount == 1 ? "track" : "tracks"}', - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ], - ), - ), - Icon(LucideIcons.chevron_right, color: fs.ash), - ]), - ), - ); - } -} diff --git a/flutter_client/lib/playlists/playlists_provider.dart b/flutter_client/lib/playlists/playlists_provider.dart deleted file mode 100644 index 2c8be2df..00000000 --- a/flutter_client/lib/playlists/playlists_provider.dart +++ /dev/null @@ -1,403 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:dio/dio.dart'; -import 'package:drift/drift.dart' as drift; -import 'package:flutter/foundation.dart' show debugPrint; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/me.dart'; -import '../api/endpoints/playlists.dart'; -import '../auth/auth_provider.dart'; -import '../cache/adapters.dart'; -import '../cache/audio_cache_manager.dart' show appDbProvider; -import '../cache/cache_first.dart'; -import '../cache/connectivity_provider.dart'; -import '../cache/db.dart'; -import '../library/library_providers.dart' show dioProvider; -import '../models/playlist.dart'; -import '../models/system_playlists_status.dart'; - -final playlistsApiProvider = FutureProvider((ref) async { - return PlaylistsApi(await ref.watch(dioProvider.future)); -}); - -/// Drift-first per #357 plan C. Returns cached playlists filtered to -/// match the `kind` family arg: -/// - 'user' → only user-created (systemVariant null), owned by current user -/// - 'system' → only system-generated (systemVariant non-null), owned -/// - 'all' → everything: own user playlists + own system + others' public -/// -/// systemVariant column added in drift schema v2 (#357 follow-up); previous -/// behavior leaked system playlists into add-to-playlist sheet because we -/// couldn't filter locally. -final playlistsListProvider = - StreamProvider.family((ref, kind) { - final db = ref.watch(appDbProvider); - final user = ref.watch(authControllerProvider).value; - - return cacheFirst( - driftStream: db.select(db.cachedPlaylists).watch(), - fetchAndPopulate: () async { - final api = await ref.read(playlistsApiProvider.future); - final fresh = await api.list(kind: kind); - - // Reconcile: BuildSystemPlaylists rotates system-playlist UUIDs - // every rebuild, so insertOrReplace alone leaves stale rows in - // drift. Tapping one of those stale tiles 404s. Delete every - // owned drift row whose id isn't in the fresh response, then - // upsert the fresh set. - final freshOwnedIds = - fresh.owned.map((p) => p.id).toSet(); - await db.batch((b) { - if (user != null) { - b.deleteWhere(db.cachedPlaylists, (t) { - return t.userId.equals(user.id) & - t.id.isNotIn(freshOwnedIds); - }); - } - for (final p in fresh.all) { - b.insert(db.cachedPlaylists, p.toDrift(), - mode: drift.InsertMode.insertOrReplace); - } - }); - }, - toResult: (rows) { - if (user == null) return PlaylistsList.empty(); - final filtered = rows.where((r) { - if (kind == 'user') return r.systemVariant == null; - if (kind == 'system') return r.systemVariant != null; - return true; // 'all' - }).toList(); - final owned = filtered - .where((r) => r.userId == user.id) - .map((r) => r.toRef()) - .toList(); - final pub = filtered - .where((r) => r.userId != user.id && r.isPublic) - .map((r) => r.toRef()) - .toList(); - return PlaylistsList(owned: owned, public: pub); - }, - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - // Aggregate list — server may add system playlists (For-You / - // Discover) that the per-user delta sync doesn't always emit. - // Stale-while-revalidate ensures the home tile row reflects the - // current server state on every screen visit. - alwaysRefresh: true, - ); -}); - -/// Composite shape (playlist + tracks). async* over the playlist watch -/// stream + a one-shot tracks fetch per emission. -final playlistDetailProvider = - StreamProvider.family((ref, id) async* { - final db = ref.watch(appDbProvider); - final user = ref.watch(authControllerProvider).value; - - final playlistQuery = db.select(db.cachedPlaylists) - ..where((t) => t.id.equals(id)); - - final tracksQuery = (db.select(db.cachedPlaylistTracks) - ..where((t) => t.playlistId.equals(id)) - ..orderBy([(t) => drift.OrderingTerm.asc(t.position)])) - .join([ - drift.leftOuterJoin(db.cachedTracks, - db.cachedTracks.id.equalsExp(db.cachedPlaylistTracks.trackId)), - drift.leftOuterJoin(db.cachedArtists, - db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)), - drift.leftOuterJoin(db.cachedAlbums, - db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)), - ]); - - PlaylistDetail emptyDetail() => PlaylistDetail( - playlist: Playlist( - id: id, - userId: user?.id ?? '', - name: '', - description: '', - isPublic: false, - systemVariant: null, - trackCount: 0, - coverUrl: '', - ownerUsername: '', - createdAt: '', - updatedAt: '', - ), - tracks: const [], - ); - - Future isOnline() async { - try { - return await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true); - } catch (_) { - return true; - } - } - - Future fetchAndPopulate() async { - try { - final api = await ref.read(playlistsApiProvider.future); - final fresh = await api.get(id).timeout(const Duration(seconds: 10)); - - // Collect the track + artist + album rows referenced by these - // playlist entries. Without writing the cachedTracks rows - // themselves, the detail-screen JOIN against cachedTracks - // returns null on every row (only the playlist_tracks join - // succeeds), so the UI shows a list with empty titles and - // unplayable tracks. - final tracks = {}; - final artists = {}; - final albums = {}; - for (final t in fresh.tracks) { - final tId = t.trackId; - if (tId != null && tId.isNotEmpty) { - tracks.putIfAbsent( - tId, - () => _TrackRefRow( - id: tId, - title: t.title, - albumId: t.albumId ?? '', - artistId: t.artistId ?? '', - durationSec: t.durationSec, - ), - ); - } - final aId = t.artistId; - final aName = t.artistName; - if (aId != null && aId.isNotEmpty && aName.isNotEmpty) { - artists.putIfAbsent(aId, () => ArtistRefRow(aId, aName)); - } - final lbId = t.albumId; - final lbTitle = t.albumTitle; - if (lbId != null && lbId.isNotEmpty && lbTitle.isNotEmpty) { - albums.putIfAbsent(lbId, () => AlbumRefRow(lbId, lbTitle, aId ?? '')); - } - } - - await db.batch((b) { - b.insert(db.cachedPlaylists, fresh.playlist.toDrift(), - mode: drift.InsertMode.insertOrReplace); - - // Wipe + re-insert this playlist's track positions so deletions - // propagate. Without the wipe, removed tracks would linger in - // drift after the playlist mutated server-side. - b.deleteWhere( - db.cachedPlaylistTracks, (t) => t.playlistId.equals(id)); - - for (var i = 0; i < fresh.tracks.length; i++) { - final t = fresh.tracks[i]; - if (t.trackId == null) continue; - b.insert( - db.cachedPlaylistTracks, - CachedPlaylistTracksCompanion.insert( - playlistId: id, - trackId: t.trackId!, - position: drift.Value(i), - ), - mode: drift.InsertMode.insertOrReplace, - ); - } - - if (artists.isNotEmpty) { - b.insertAllOnConflictUpdate( - db.cachedArtists, - artists.values - .map((a) => CachedArtistsCompanion.insert( - id: a.id, - name: a.name, - sortName: a.name, - )) - .toList(), - ); - } - if (tracks.isNotEmpty) { - // Insert/update cachedTracks so the detail screen's JOIN - // produces real titles + durations. We don't have - // track_number / disc_number on the wire (PlaylistTrack - // omits them), so they default to 0 — UI doesn't surface - // them on this screen so it's fine. - b.insertAllOnConflictUpdate( - db.cachedTracks, - tracks.values - .map((t) => CachedTracksCompanion.insert( - id: t.id, - albumId: t.albumId, - artistId: t.artistId, - title: t.title, - durationMs: drift.Value(t.durationSec * 1000), - )) - .toList(), - ); - } - if (albums.isNotEmpty) { - b.insertAllOnConflictUpdate( - db.cachedAlbums, - albums.values - .map((a) => CachedAlbumsCompanion.insert( - id: a.id, - artistId: a.artistId, - title: a.title, - sortTitle: a.title, - )) - .toList(), - ); - } - }); - return true; - } catch (e, st) { - // 404 = the playlist row in drift is stale (BuildSystemPlaylists - // rotates UUIDs on each rebuild, so old For-You / Songs-Like - // tiles can outlive the actual server-side playlist). Wipe the - // stale row + its track positions so the home tile disappears - // on next render and we don't keep trying. - if (e is DioException && e.response?.statusCode == 404) { - await db.batch((b) { - b.deleteWhere( - db.cachedPlaylistTracks, (t) => t.playlistId.equals(id)); - b.deleteWhere(db.cachedPlaylists, (t) => t.id.equals(id)); - }); - return false; - } - debugPrint('playlistDetailProvider($id): fetch failed: $e\n$st'); - return false; - } - } - - // Once-per-subscription guard so an empty server response doesn't - // cause repeated re-fetches. - var fetchAttempted = false; - - await for (final playlistRows in playlistQuery.watch()) { - if (playlistRows.isEmpty) { - if (fetchAttempted) { - yield emptyDetail(); - continue; - } - fetchAttempted = true; - if (!await isOnline()) { - yield emptyDetail(); - continue; - } - final ok = await fetchAndPopulate(); - if (!ok) yield emptyDetail(); - continue; - } - - final playlist = playlistRows.first.toRef(); - final trackRows = await tracksQuery.get(); - - // Same pattern as albumProvider: playlist row exists but no tracks - // (e.g., playlistsListProvider wrote the row, sync hasn't carried - // tracks for system playlists). Trigger the same fetch, drift - // watch re-emits with populated tracks. - if (trackRows.isEmpty && !fetchAttempted) { - fetchAttempted = true; - if (await isOnline()) { - final ok = await fetchAndPopulate(); - if (ok) continue; // wait for watch re-emit - } - } - - final tracks = trackRows.asMap().entries.map((e) { - final r = e.value; - final track = r.readTableOrNull(db.cachedTracks); - final artist = r.readTableOrNull(db.cachedArtists); - final album = r.readTableOrNull(db.cachedAlbums); - return PlaylistTrack( - position: e.key, - trackId: track?.id, - title: track?.title ?? '', - albumId: album?.id, - albumTitle: album?.title ?? '', - artistId: artist?.id, - artistName: artist?.name ?? '', - durationSec: track == null ? 0 : track.durationMs ~/ 1000, - streamUrl: null, - ); - }).toList(); - - yield PlaylistDetail(playlist: playlist, tracks: tracks); - - // No SWR refresh here. The aggregate playlistsListProvider does - // alwaysRefresh (system playlists rotate UUIDs), but per-detail - // refresh on every visit was multiplying with the prefetcher's - // parallel fetches and starving user-initiated playback. Pull- - // to-refresh on the detail page invalidates the provider, which - // is the right path for an explicit refresh. - } -}); - -/// Lightweight tuples used inside fetchAndPopulate to dedupe artist -/// and album rows referenced by playlist tracks before we batch-write -/// them to drift. -class ArtistRefRow { - ArtistRefRow(this.id, this.name); - final String id; - final String name; -} - -class AlbumRefRow { - AlbumRefRow(this.id, this.title, this.artistId); - final String id; - final String title; - final String artistId; -} - -class _TrackRefRow { - _TrackRefRow({ - required this.id, - required this.title, - required this.albumId, - required this.artistId, - required this.durationSec, - }); - final String id; - final String title; - final String albumId; - final String artistId; - final int durationSec; -} - -/// Drift-first per #357 pattern. Reads from cached_system_playlists_ -/// status (single-row JSON blob populated by /api/me/system-playlists- -/// status). Home Playlists row reads this every render to choose -/// between real cards and "building / pending / failed" placeholders -/// — drift-first means the row paints with the prior status instantly -/// rather than flickering through SystemPlaylistsStatus.empty() while -/// the REST round-trip resolves. SWR refresh on every visit keeps it -/// current. -final systemPlaylistsStatusProvider = - StreamProvider((ref) { - final db = ref.watch(appDbProvider); - return cacheFirst( - driftStream: db.select(db.cachedSystemPlaylistsStatus).watch(), - fetchAndPopulate: () async { - final dio = await ref.read(dioProvider.future); - final fresh = await MeApi(dio).systemPlaylistsStatus(); - await db.into(db.cachedSystemPlaylistsStatus).insertOnConflictUpdate( - CachedSystemPlaylistsStatusCompanion.insert( - json: jsonEncode({ - 'in_flight': fresh.inFlight, - 'last_run_at': fresh.lastRunAt, - 'last_error': fresh.lastError, - }), - updatedAt: drift.Value(DateTime.now()), - ), - ); - }, - toResult: (rows) => rows.isEmpty - ? SystemPlaylistsStatus.empty() - : SystemPlaylistsStatus.fromJson( - jsonDecode(rows.first.json) as Map), - isOnline: () async => (await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true)), - alwaysRefresh: true, - tag: 'systemPlaylistsStatus', - ); -}); diff --git a/flutter_client/lib/playlists/widgets/playlist_card.dart b/flutter_client/lib/playlists/widgets/playlist_card.dart deleted file mode 100644 index 7c948b41..00000000 --- a/flutter_client/lib/playlists/widgets/playlist_card.dart +++ /dev/null @@ -1,249 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../cache/offline_provider.dart'; -import '../../library/widgets/play_circle_button.dart'; -import '../../models/playlist.dart'; -import '../../models/track.dart'; -import '../../player/player_provider.dart'; -import '../../shared/widgets/server_image.dart'; -import '../../theme/theme_extension.dart'; -import '../playlists_provider.dart'; - -/// Mirrors the web PlaylistCard. ~176dp wide, square cover (~144dp), -/// name + optional system-variant badge below. Tap pushes -/// `/playlists/{id}`. The bottom-right play button fetches the -/// playlist and starts playback from track 0; disabled when the -/// playlist has no tracks. -class PlaylistCard extends ConsumerWidget { - const PlaylistCard({super.key, required this.playlist}); - - final Playlist playlist; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - // #427 S4: refreshable system playlists need the live build/ - // shuffle endpoints — unavailable offline. Disable their play - // (use Shuffle all instead). User playlists play from cache. - final offline = ref.watch(offlineProvider); - final hasTracks = playlist.trackCount > 0 && - !(offline && playlist.refreshable); - return SizedBox( - width: 176, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => - context.push('/playlists/${playlist.id}', extra: playlist), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Stack: collage + overlaid play button at bottom-right. - Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Container( - width: 144, - height: 144, - color: fs.slate, - // coverUrl is deterministic per playlist (see - // CachedPlaylistAdapter.toRef). When the server's - // collage isn't built yet, ServerImage's - // errorBuilder shows the queue_music icon over the - // slate background. - child: playlist.coverUrl.isEmpty - ? Icon(LucideIcons.list_music, color: fs.ash, size: 56) - : ServerImage( - url: playlist.coverUrl, - fit: BoxFit.cover, - fallback: - Icon(LucideIcons.list_music, color: fs.ash, size: 56), - ), - ), - ), - Positioned( - bottom: 6, - right: 6, - child: PlayCircleButton( - enabled: hasTracks, - onPressed: () => _playPlaylist(context, ref), - ), - ), - // System playlists get a refresh affordance so the - // user can force a fresh mix instead of waiting for - // the daily 03:00 rebuild. Mirrors the web kebab. - if (playlist.refreshable) - Positioned( - top: 2, - right: 2, - child: PopupMenuButton( - icon: Icon(LucideIcons.ellipsis_vertical, color: fs.parchment, size: 18), - tooltip: 'Playlist actions', - color: fs.iron, - onSelected: (_) => _refresh(context, ref), - itemBuilder: (_) => [ - PopupMenuItem( - value: 'refresh', - child: Row(children: [ - Icon(LucideIcons.refresh_cw, color: fs.parchment, size: 18), - const SizedBox(width: 8), - Text(_refreshLabel, - style: TextStyle(color: fs.parchment)), - ]), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - playlist.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14), - ), - if (playlist.isSystem) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Container( - padding: - const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: fs.iron, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - playlist.systemVariant!.replaceAll('_', ' '), - style: TextStyle(color: fs.accent, fontSize: 11), - ), - ), - ), - if (playlist.isSystem && _refreshedLabel(playlist.createdAt) != '') - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - _refreshedLabel(playlist.createdAt), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.ash, fontSize: 11), - ), - ), - ]), - ), - ), - ), - ); - } - - String get _refreshLabel => 'Refresh ${playlist.name}'; - - /// #417: system playlists atomic-replace on rebuild, so createdAt - /// is the last-rotated time. Friendly wording mirrors the web - /// PlaylistCard. Empty string when the timestamp is unparseable. - String _refreshedLabel(String iso) { - final t = DateTime.tryParse(iso); - if (t == null) return ''; - final now = DateTime.now(); - final mins = now.difference(t).inMinutes; - if (mins < 5) return 'Refreshed just now'; - final startOfToday = DateTime(now.year, now.month, now.day); - if (!t.isBefore(startOfToday)) return 'Refreshed today'; - final days = startOfToday.difference(DateTime(t.year, t.month, t.day)).inDays; - if (days <= 1) return 'Refreshed yesterday'; - if (days < 7) return 'Refreshed $days days ago'; - const months = [ - 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', - ]; - return 'Refreshed ${months[t.month - 1]} ${t.day}'; - } - - /// Forces a server-side rebuild of this system playlist, then - /// invalidates the aggregate list so the rotated UUID + new tracks - /// land on the next read. ScaffoldMessenger is captured before the - /// await so we don't touch a stale BuildContext afterward. - Future _refresh(BuildContext context, WidgetRef ref) async { - final messenger = ScaffoldMessenger.of(context); - try { - final api = await ref.read(playlistsApiProvider.future); - await api.refreshSystem(playlist.systemVariant!); - ref.invalidate(playlistsListProvider); - messenger.showSnackBar( - SnackBar(content: Text('${playlist.name} refreshed')), - ); - } catch (e) { - messenger.showSnackBar( - SnackBar(content: Text("Couldn't refresh: $e")), - ); - } - } - - /// Fetches the playlist via /api/playlists/{id}, materializes each - /// PlaylistTrack into a TrackRef (filtering out unavailable rows - /// whose `trackId` is null after a track-delete), and plays from - /// index 0. Mirrors the web PlaylistCard's onPlayClick. - /// - /// Failure modes are surfaced via SnackBar so a not-yet-built mix - /// or a slow server doesn't look like the tap was ignored. - Future _playPlaylist(BuildContext context, WidgetRef ref) async { - final messenger = ScaffoldMessenger.of(context); - final api = await ref.read(playlistsApiProvider.future); - final PlaylistDetail detail; - try { - // Refreshable (singleton) system playlists: fetch the server's - // rotation-aware order (#415) and play as-is, tagged with the - // source so the reporter advances rotation. User playlists AND - // non-singleton system kinds (songs_like_artist — no by-kind - // endpoint) play the stored order via get(), untagged. - detail = await (playlist.refreshable - ? api.systemShuffle(playlist.systemVariant!) - : api.get(playlist.id)) - .timeout(const Duration(seconds: 8)); - } on TimeoutException { - messenger.showSnackBar(const SnackBar( - content: Text("Couldn't load playlist — check your connection"), - )); - return; - } catch (e) { - messenger.showSnackBar(SnackBar( - content: Text('Playlist load failed: $e'), - )); - return; - } - final refs = []; - for (final t in detail.tracks) { - if (t.trackId == null) continue; - refs.add(TrackRef( - id: t.trackId!, - title: t.title, - albumId: t.albumId ?? '', - albumTitle: t.albumTitle, - artistId: t.artistId ?? '', - artistName: t.artistName, - durationSec: t.durationSec, - streamUrl: t.streamUrl ?? '', - )); - } - if (refs.isEmpty) { - messenger.showSnackBar(const SnackBar( - content: Text("Mix isn't ready yet — try again in a moment"), - )); - return; - } - // Rotation-aware kinds arrive pre-ordered from the server — play - // as-is, tagged so the reporter advances rotation. No client - // shuffle. Everything else plays stored order, untagged. - await ref.read(playerActionsProvider).playTracks( - refs, - initialIndex: 0, - source: playlist.refreshable ? playlist.systemVariant : null, - ); - } -} diff --git a/flutter_client/lib/playlists/widgets/playlist_placeholder_card.dart b/flutter_client/lib/playlists/widgets/playlist_placeholder_card.dart deleted file mode 100644 index d2192c26..00000000 --- a/flutter_client/lib/playlists/widgets/playlist_placeholder_card.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; - -import '../../theme/theme_extension.dart'; - -/// Same dimensions as PlaylistCard so the home Playlists row keeps -/// visual rhythm whether real or placeholder. Mirrors the web -/// PlaylistPlaceholderCard. Variant decides the state copy below -/// the label. -class PlaylistPlaceholderCard extends StatelessWidget { - const PlaylistPlaceholderCard({ - super.key, - required this.label, - required this.variant, - }); - - /// "For You" or "Songs like…" — what the slot is reserved for. - final String label; - - /// One of: 'building' | 'failed' | 'pending' | 'seed-needed'. - final String variant; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return SizedBox( - width: 176, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 144, - height: 144, - decoration: BoxDecoration( - color: fs.iron, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: fs.slate, width: 1), - ), - child: Center(child: _stateIcon(fs)), - ), - const SizedBox(height: 8), - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fs.parchment, fontSize: 14), - ), - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - _stateText(), - style: TextStyle(color: fs.ash, fontSize: 11), - ), - ), - ]), - ), - ); - } - - Widget _stateIcon(FabledSwordTheme fs) { - switch (variant) { - case 'building': - return SizedBox( - width: 28, - height: 28, - child: CircularProgressIndicator(strokeWidth: 2, color: fs.accent), - ); - case 'failed': - return Icon(LucideIcons.triangle_alert, color: fs.error, size: 32); - default: - return Icon(LucideIcons.list_music, color: fs.ash, size: 40); - } - } - - String _stateText() { - switch (variant) { - case 'building': - return 'Building…'; - case 'failed': - return "Couldn't generate"; - case 'seed-needed': - return 'Like more music'; - default: - return 'Coming soon'; - } - } -} diff --git a/flutter_client/lib/quarantine/quarantine_provider.dart b/flutter_client/lib/quarantine/quarantine_provider.dart deleted file mode 100644 index d84fa334..00000000 --- a/flutter_client/lib/quarantine/quarantine_provider.dart +++ /dev/null @@ -1,184 +0,0 @@ -import 'dart:async'; - -import 'package:drift/drift.dart' as drift; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/me.dart'; -import '../api/endpoints/quarantine.dart'; -import '../cache/audio_cache_manager.dart' show appDbProvider; -import '../cache/connectivity_provider.dart'; -import '../cache/db.dart'; -import '../cache/mutation_queue.dart'; -import '../library/library_providers.dart' show dioProvider; -import '../models/quarantine_mine.dart'; -import '../models/track.dart'; - -final quarantineApiProvider = FutureProvider((ref) async { - return QuarantineApi(await ref.watch(dioProvider.future)); -}); - -/// Drift-first ("hidden") tab controller. Reads from cached_quarantine_ -/// mine via a drift watch() subscription set up in build(), so any drift -/// mutation (incoming sync, optimistic flag/unflag, SSE-triggered -/// refresh) re-emits the list automatically. -/// -/// API surface unchanged from the prior FutureProvider-backed controller -/// — call sites still do `ref.read(myQuarantineProvider.notifier).flag()` -/// / `.unflag()` / `.isHidden()`. Internal storage moved from in-memory -/// AsyncNotifier state to drift so the Hidden tab paints from disk on -/// cold open and the quarantine list is queryable offline. -class MyQuarantineController extends AsyncNotifier> { - @override - Future> build() async { - final db = ref.watch(appDbProvider); - - // Subscribe to drift first so any mutation (incoming sync, flag/ - // unflag, etc.) propagates without needing a manual invalidate. - // Disposed when the provider rebuilds or the listener detaches. - final sub = db.select(db.cachedQuarantineMine).watch().listen((rows) { - state = AsyncData(rows.map(_rowToModel).toList()); - }); - ref.onDispose(sub.cancel); - - // SWR refresh on every build so the freshest server-side state - // overtakes drift in the background. Don't await — UI gets the - // cached snapshot first, freshness lands later via the watch(). - unawaited(_refreshFromServer()); - - final initial = await db.select(db.cachedQuarantineMine).get(); - return initial.map(_rowToModel).toList(); - } - - /// Hits /api/quarantine/mine and replaces the local table with the - /// authoritative server set. Best-effort: offline / 5xx / unresolved - /// dependencies (e.g. uninitialised serverUrl in tests) all collapse - /// to a no-op — drift stays on the prior cached state. - Future _refreshFromServer() async { - try { - final online = await ref - .read(connectivityProvider.future) - .timeout(const Duration(seconds: 3), onTimeout: () => true); - if (!online) return; - final dio = await ref.read(dioProvider.future); - final fresh = await MeApi(dio).quarantineMine(); - final db = ref.read(appDbProvider); - await db.transaction(() async { - // Full replace — quarantine list is small and we want server - // unflags from other devices to take effect. Doing this in a - // transaction means the watch() sees exactly one emission with - // the merged state, not a delete-then-insert flicker. - await db.delete(db.cachedQuarantineMine).go(); - for (final r in fresh) { - await db - .into(db.cachedQuarantineMine) - .insertOnConflictUpdate(_modelToCompanion(r)); - } - }); - } catch (_) { - // Swallow — cached state stays visible. - } - } - - /// True when this track is in the caller's quarantine list. - bool isHidden(String trackId) => - (state.value ?? const []).any((r) => r.trackId == trackId); - - /// Optimistic flag: inserts the synthetic row into drift (watch fires - /// immediately so the UI updates), calls server, rolls back on error. - Future flag(TrackRef track, String reason, String notes) async { - final db = ref.read(appDbProvider); - final existed = await (db.select(db.cachedQuarantineMine) - ..where((t) => t.trackId.equals(track.id))) - .getSingleOrNull(); - if (existed != null) return; // already hidden - - final companion = CachedQuarantineMineCompanion.insert( - trackId: track.id, - reason: reason, - notes: notes.isEmpty - ? const drift.Value.absent() - : drift.Value(notes), - createdAt: DateTime.now().toUtc().toIso8601String(), - trackTitle: track.title, - trackDurationMs: drift.Value(track.durationSec * 1000), - albumId: track.albumId, - albumTitle: track.albumTitle, - artistId: track.artistId, - artistName: track.artistName, - ); - await db.into(db.cachedQuarantineMine).insertOnConflictUpdate(companion); - - try { - final api = await ref.read(quarantineApiProvider.future); - await api.flag(track.id, reason, notes: notes); - } catch (_) { - // REST failed — don't roll back; queue for replay so the user's - // "hide this track" intent persists offline. - await ref.read(mutationQueueProvider).enqueue( - MutationKinds.quarantineFlag, - {'trackId': track.id, 'reason': reason, 'notes': notes}, - ); - } - } - - /// Optimistic unflag: removes the drift row, calls server, queues - /// the call on failure so the unhide intent persists offline. - Future unflag(String trackId) async { - final db = ref.read(appDbProvider); - final existing = await (db.select(db.cachedQuarantineMine) - ..where((t) => t.trackId.equals(trackId))) - .getSingleOrNull(); - if (existing == null) return; - - await (db.delete(db.cachedQuarantineMine) - ..where((t) => t.trackId.equals(trackId))) - .go(); - - try { - final api = await ref.read(quarantineApiProvider.future); - await api.unflag(trackId); - } catch (_) { - // Don't restore the drift row; queue the unflag for replay. - await ref - .read(mutationQueueProvider) - .enqueue(MutationKinds.quarantineUnflag, {'trackId': trackId}); - } - } - - static QuarantineMineRow _rowToModel(CachedQuarantineMineData r) => - QuarantineMineRow( - trackId: r.trackId, - reason: r.reason, - notes: r.notes, - createdAt: r.createdAt, - trackTitle: r.trackTitle, - trackDurationMs: r.trackDurationMs, - albumId: r.albumId, - albumTitle: r.albumTitle, - albumCoverArtPath: r.albumCoverArtPath, - artistId: r.artistId, - artistName: r.artistName, - ); - - static CachedQuarantineMineCompanion _modelToCompanion(QuarantineMineRow r) => - CachedQuarantineMineCompanion.insert( - trackId: r.trackId, - reason: r.reason, - notes: r.notes == null ? const drift.Value.absent() : drift.Value(r.notes), - createdAt: r.createdAt, - trackTitle: r.trackTitle, - trackDurationMs: drift.Value(r.trackDurationMs), - albumId: r.albumId, - albumTitle: r.albumTitle, - albumCoverArtPath: r.albumCoverArtPath == null - ? const drift.Value.absent() - : drift.Value(r.albumCoverArtPath), - artistId: r.artistId, - artistName: r.artistName, - ); -} - -final myQuarantineProvider = - AsyncNotifierProvider>( - MyQuarantineController.new, -); diff --git a/flutter_client/lib/requests/requests_provider.dart b/flutter_client/lib/requests/requests_provider.dart deleted file mode 100644 index a00c47d4..00000000 --- a/flutter_client/lib/requests/requests_provider.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/requests.dart'; -import '../cache/mutation_queue.dart'; -import '../library/library_providers.dart' show dioProvider; -import '../models/admin_request.dart'; - -final requestsApiProvider = FutureProvider((ref) async { - return RequestsApi(await ref.watch(dioProvider.future)); -}); - -/// Mirrors the web's createMyRequestsQuery auto-poll (#369): refresh -/// every 12s while any row is mid-ingest (status='approved'), stop when -/// all rows settle. Polls regardless of app foreground/background — a -/// future enhancement could pause via WidgetsBindingObserver, but for -/// v1 the small extra refresh is acceptable. -class MyRequestsController extends AsyncNotifier> { - static const Duration _pollInterval = Duration(seconds: 12); - - Timer? _pollTimer; - - @override - Future> build() async { - ref.onDispose(() { - _pollTimer?.cancel(); - _pollTimer = null; - }); - final api = await ref.watch(requestsApiProvider.future); - final rows = await api.listMine(); - _maybeStartPolling(rows); - return rows; - } - - void _maybeStartPolling(List rows) { - _pollTimer?.cancel(); - if (rows.any((r) => r.status == 'approved')) { - _pollTimer = Timer.periodic(_pollInterval, (_) { - ref.invalidateSelf(); - }); - } - } - - /// Optimistic remove + REST cancel. On failure the row stays - /// removed in-memory and the cancel is queued for replay — this - /// keeps the user's intent visible across network loss instead of - /// restoring a row they explicitly asked to remove. - Future cancel(String id) async { - final api = await ref.read(requestsApiProvider.future); - final current = state.value ?? const []; - state = AsyncData(current.where((r) => r.id != id).toList()); - try { - await api.cancel(id); - } catch (_) { - await ref - .read(mutationQueueProvider) - .enqueue(MutationKinds.requestCancel, {'id': id}); - } - } -} - -final myRequestsProvider = - AsyncNotifierProvider>( - MyRequestsController.new); diff --git a/flutter_client/lib/requests/requests_screen.dart b/flutter_client/lib/requests/requests_screen.dart deleted file mode 100644 index d4a15d38..00000000 --- a/flutter_client/lib/requests/requests_screen.dart +++ /dev/null @@ -1,253 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../models/admin_request.dart'; -import '../shared/live_events_provider.dart'; -import '../shared/widgets/main_app_bar_actions.dart'; -import '../theme/theme_extension.dart'; -import 'requests_provider.dart'; - -class RequestsScreen extends ConsumerWidget { - const RequestsScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - // #402 wire-up: refresh the user's own requests list on - // request.status_changed. Server-side events are user-scoped - // (admin actions on someone else's request route to that other - // user's stream); the dispatcher's filter already drops mismatches. - ref.listen>(liveEventsProvider, (_, next) { - final e = next.asData?.value; - if (e?.kind == 'request.status_changed') { - ref.invalidate(myRequestsProvider); - } - }); - final requests = ref.watch(myRequestsProvider); - - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - leading: IconButton( - icon: Icon(LucideIcons.arrow_left, color: fs.parchment), - onPressed: () => context.pop(), - ), - title: Text('Your requests', style: TextStyle(color: fs.parchment)), - actions: const [MainAppBarActions(currentRoute: '/requests')], - ), - body: SafeArea( - child: requests.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text('$e', style: TextStyle(color: fs.error)), - ), - ), - data: (rows) { - if (rows.isEmpty) { - return Center( - child: Text('Nothing requested yet.', - style: TextStyle(color: fs.ash)), - ); - } - final notifier = ref.read(myRequestsProvider.notifier); - return RefreshIndicator( - onRefresh: () async => ref.refresh(myRequestsProvider.future), - child: ListView.separated( - itemCount: rows.length, - padding: const EdgeInsets.symmetric(vertical: 8), - separatorBuilder: (_, __) => Divider(color: fs.iron, height: 1), - itemBuilder: (_, i) => _RequestRow( - key: Key('request_row_${rows[i].id}'), - request: rows[i], - onCancel: () => notifier.cancel(rows[i].id), - ), - ), - ); - }, - ), - ), - ); - } -} - -class _RequestRow extends StatelessWidget { - const _RequestRow({ - super.key, - required this.request, - required this.onCancel, - }); - - final AdminRequest request; - final VoidCallback onCancel; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final href = _listenHref(request); - return ListTile( - leading: _kindAvatar(fs), - title: Text( - request.displayName, - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 16, - ), - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 4), - Row( - children: [ - _KindPill(label: request.kind), - const SizedBox(width: 6), - _StatusPill(status: request.status), - ], - ), - if (request.importedAlbumCount > 0 || - request.importedTrackCount > 0) ...[ - const SizedBox(height: 4), - Text( - _ingestProgressText(request), - style: TextStyle(color: fs.accent, fontSize: 13), - ), - ], - if (request.status == 'rejected' && (request.notes ?? '').isNotEmpty) ...[ - const SizedBox(height: 4), - Text(request.notes!, style: TextStyle(color: fs.ash, fontSize: 13)), - ], - ], - ), - trailing: _trailing(context, fs, href), - ); - } - - Widget _kindAvatar(FabledSwordTheme fs) { - final icon = switch (request.kind) { - 'artist' => LucideIcons.disc_3, - 'album' => LucideIcons.library_big, - _ => LucideIcons.music, - }; - return CircleAvatar( - backgroundColor: fs.iron, - child: Icon(icon, size: 20, color: fs.ash), - ); - } - - Widget? _trailing(BuildContext context, FabledSwordTheme fs, String? href) { - if (request.status == 'pending') { - return TextButton.icon( - onPressed: () async { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Cancel request?'), - content: Text('Cancel "${request.displayName}"?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Keep'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, true), - style: TextButton.styleFrom(foregroundColor: fs.oxblood), - child: const Text('Cancel request'), - ), - ], - ), - ); - if (ok == true) onCancel(); - }, - icon: const Icon(LucideIcons.x, size: 16), - label: const Text('Cancel'), - style: TextButton.styleFrom(foregroundColor: fs.ash), - ); - } - if (request.status == 'completed' && href != null) { - return TextButton.icon( - onPressed: () => context.push(href), - icon: const Icon(LucideIcons.play, size: 16), - label: const Text('Listen'), - style: TextButton.styleFrom(foregroundColor: fs.accent), - ); - } - return null; - } - - String _ingestProgressText(AdminRequest r) { - if (r.kind == 'artist') { - final albums = '${r.importedAlbumCount} ' - '${r.importedAlbumCount == 1 ? 'album' : 'albums'}'; - final tracks = '${r.importedTrackCount} ' - '${r.importedTrackCount == 1 ? 'track' : 'tracks'} ingested'; - return '$albums · $tracks'; - } - if (r.kind == 'album') { - return '${r.importedTrackCount} ' - '${r.importedTrackCount == 1 ? 'track' : 'tracks'} ingested'; - } - return 'Track ingested'; - } - - String? _listenHref(AdminRequest r) { - if (r.matchedTrackId != null) return '/albums/${r.matchedAlbumId ?? r.matchedTrackId}'; - if (r.matchedAlbumId != null) return '/albums/${r.matchedAlbumId}'; - if (r.matchedArtistId != null) return '/artists/${r.matchedArtistId}'; - return null; - } -} - -class _KindPill extends StatelessWidget { - const _KindPill({required this.label}); - final String label; - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: fs.accent.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(999), - ), - child: Text( - label, - style: TextStyle(color: fs.accent, fontSize: 11), - ), - ); - } -} - -class _StatusPill extends StatelessWidget { - const _StatusPill({required this.status}); - final String status; - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final color = switch (status) { - 'pending' => fs.ash, - 'approved' => fs.bronze, - 'completed' => fs.moss, - 'rejected' => fs.oxblood, - 'failed' => fs.oxblood, - _ => fs.ash, - }; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(999), - ), - child: Text( - status, - style: TextStyle(color: color, fontSize: 11), - ), - ); - } -} diff --git a/flutter_client/lib/search/search_provider.dart b/flutter_client/lib/search/search_provider.dart deleted file mode 100644 index b02021d1..00000000 --- a/flutter_client/lib/search/search_provider.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../api/endpoints/search.dart'; -import '../library/library_providers.dart' show dioProvider; -import '../models/search_response.dart'; - -final searchApiProvider = FutureProvider((ref) async { - return SearchApi(await ref.watch(dioProvider.future)); -}); - -/// Current search query text. The screen's TextField writes here on -/// every keystroke; the resultsProvider below debounces. -class SearchQueryNotifier extends Notifier { - @override - String build() => ''; - void set(String q) => state = q; -} - -final searchQueryProvider = - NotifierProvider(SearchQueryNotifier.new); - -/// Debounced search results. Returns null for empty queries so the -/// screen can show a neutral empty state instead of "0 results." -/// -/// Debounce: 250ms. After the wait, re-reads the live query — if the -/// user has typed more in that window, this attempt is silently -/// abandoned (returns null) and a newer attempt fires on the next -/// rebuild. No request hits the server while the user is mid-typing. -final searchResultsProvider = FutureProvider((ref) async { - final q = ref.watch(searchQueryProvider).trim(); - if (q.isEmpty) return null; - await Future.delayed(const Duration(milliseconds: 250)); - // Re-read the (possibly newer) query; if the user typed more, bail. - if (ref.read(searchQueryProvider).trim() != q) return null; - final api = await ref.watch(searchApiProvider.future); - return api.search(q); -}); diff --git a/flutter_client/lib/search/search_screen.dart b/flutter_client/lib/search/search_screen.dart deleted file mode 100644 index 06315eb6..00000000 --- a/flutter_client/lib/search/search_screen.dart +++ /dev/null @@ -1,201 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../library/widgets/album_card.dart'; -import '../library/widgets/artist_card.dart'; -import '../library/widgets/track_row.dart'; -import '../models/search_response.dart'; -import '../player/player_provider.dart'; -import '../shared/widgets/main_app_bar_actions.dart'; -import '../theme/theme_extension.dart'; -import 'search_provider.dart'; - -class SearchScreen extends ConsumerStatefulWidget { - const SearchScreen({super.key}); - - @override - ConsumerState createState() => _SearchScreenState(); -} - -class _SearchScreenState extends ConsumerState { - final _controller = TextEditingController(); - final _focus = FocusNode(); - - @override - void initState() { - super.initState(); - // Autofocus the field on screen entry; keyboard pops up so the - // user can start typing immediately. - WidgetsBinding.instance.addPostFrameCallback((_) => _focus.requestFocus()); - } - - @override - void dispose() { - _controller.dispose(); - _focus.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final results = ref.watch(searchResultsProvider); - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - leading: IconButton( - icon: Icon(LucideIcons.arrow_left, color: fs.parchment), - onPressed: () => context.pop(), - ), - title: TextField( - controller: _controller, - focusNode: _focus, - autofocus: true, - style: TextStyle(color: fs.parchment), - cursorColor: fs.accent, - decoration: InputDecoration( - hintText: 'Search artists, albums, tracks', - hintStyle: TextStyle(color: fs.ash), - border: InputBorder.none, - ), - onChanged: (v) => ref.read(searchQueryProvider.notifier).set(v), - textInputAction: TextInputAction.search, - ), - actions: [ - if (_controller.text.isNotEmpty) - IconButton( - icon: Icon(LucideIcons.x, color: fs.ash), - onPressed: () { - _controller.clear(); - ref.read(searchQueryProvider.notifier).set(''); - _focus.requestFocus(); - }, - ), - const MainAppBarActions(currentRoute: '/search'), - ], - ), - body: results.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text('$e', style: TextStyle(color: fs.error)), - ), - ), - data: (r) => r == null - ? const _Hint(message: 'Type to search your library.') - : r.isEmpty - ? const _Hint(message: 'No matches for that query.') - : _Results(results: r), - ), - ); - } -} - -class _Hint extends StatelessWidget { - const _Hint({required this.message}); - final String message; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Center( - child: Text(message, style: TextStyle(color: fs.ash)), - ); - } -} - -class _Results extends ConsumerWidget { - const _Results({required this.results}); - final SearchResponse results; - - @override - Widget build(BuildContext context, WidgetRef ref) { - return ListView( - children: [ - if (results.artists.items.isNotEmpty) ...[ - _Header(label: 'Artists', count: results.artists.total), - SizedBox( - height: 168, - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 8), - itemCount: results.artists.items.length, - itemBuilder: (ctx, i) { - final a = results.artists.items[i]; - return ArtistCard( - artist: a, - onTap: () => ctx.push('/artists/${a.id}'), - ); - }, - ), - ), - ], - if (results.albums.items.isNotEmpty) ...[ - _Header(label: 'Albums', count: results.albums.total), - SizedBox( - height: 200, - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 8), - itemCount: results.albums.items.length, - itemBuilder: (ctx, i) { - final a = results.albums.items[i]; - return AlbumCard( - album: a, - onTap: () => ctx.push('/albums/${a.id}'), - ); - }, - ), - ), - ], - if (results.tracks.items.isNotEmpty) ...[ - _Header(label: 'Tracks', count: results.tracks.total), - ...results.tracks.items.asMap().entries.map((e) { - final i = e.key; - final t = e.value; - return TrackRow( - track: t, - onTap: () => ref - .read(playerActionsProvider) - .playTracks(results.tracks.items, initialIndex: i), - ); - }), - ], - const SizedBox(height: 96), - ], - ); - } -} - -class _Header extends StatelessWidget { - const _Header({required this.label, required this.count}); - final String label; - final int count; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Row( - children: [ - Text( - label, - style: TextStyle( - color: fs.parchment, - fontSize: 18, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(width: 8), - Text('$count', style: TextStyle(color: fs.ash, fontSize: 13)), - ], - ), - ); - } -} diff --git a/flutter_client/lib/settings/about_section.dart b/flutter_client/lib/settings/about_section.dart deleted file mode 100644 index 9a6131be..00000000 --- a/flutter_client/lib/settings/about_section.dart +++ /dev/null @@ -1,228 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:package_info_plus/package_info_plus.dart'; - -import '../theme/theme_extension.dart'; -import '../update/client_update_provider.dart'; -import '../update/update_info.dart'; - -final _packageInfoProvider = FutureProvider((_) { - return PackageInfo.fromPlatform(); -}); - -class AboutSection extends ConsumerStatefulWidget { - const AboutSection({super.key}); - - @override - ConsumerState createState() => _AboutSectionState(); -} - -enum _InstallStage { idle, downloading, error } - -class _AboutSectionState extends ConsumerState { - DateTime? _lastChecked; - bool _checking = false; - - _InstallStage _installStage = _InstallStage.idle; - double _installProgress = 0; - String? _installError; - - Future _checkNow() async { - setState(() => _checking = true); - ref.invalidate(clientUpdateProvider); - try { - await ref.read(clientUpdateProvider.future); - } finally { - if (mounted) { - setState(() { - _checking = false; - _lastChecked = DateTime.now(); - }); - } - } - } - - Future _install(UpdateInfo info) async { - setState(() { - _installStage = _InstallStage.downloading; - _installProgress = 0; - _installError = null; - }); - try { - final installer = await ref.read(updateInstallerProvider.future); - final path = await installer.download( - info.apkUrl, - onProgress: (p) { - if (mounted) setState(() => _installProgress = p); - }, - ); - await installer.install(path); - } catch (e) { - if (!mounted) return; - setState(() { - _installStage = _InstallStage.error; - _installError = '$e'; - }); - } - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final pkg = ref.watch(_packageInfoProvider); - final update = ref.watch(clientUpdateProvider); - - final installed = pkg.value == null - ? '…' - : '${pkg.value!.version}+${pkg.value!.buildNumber}'; - - final UpdateInfo? available = update.value; - final hasUpdate = available != null; - - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Padding( - padding: EdgeInsets.fromLTRB(16, 4, 16, 8), - child: _SectionHeader('About'), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), - child: Row( - children: [ - Icon(LucideIcons.info, color: fs.parchment, size: 18), - const SizedBox(width: 8), - Text('Installed version', - style: TextStyle(color: fs.parchment, fontSize: 14)), - const Spacer(), - Text(installed, - style: TextStyle( - color: fs.ash, - fontSize: 13, - fontFamily: 'JetBrainsMono')), - ], - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 4), - child: Row( - children: [ - Icon(LucideIcons.cloud, color: fs.parchment, size: 18), - const SizedBox(width: 8), - Text('Latest version', - style: TextStyle(color: fs.parchment, fontSize: 14)), - const Spacer(), - Text( - hasUpdate ? available.version : _statusFor(update, installed), - style: TextStyle( - color: hasUpdate ? fs.accent : fs.ash, - fontSize: 13, - fontFamily: 'JetBrainsMono'), - ), - ], - ), - ), - if (_lastChecked != null) - Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), - child: Text( - 'Last checked ${_formatTime(_lastChecked!)}', - style: TextStyle(color: fs.ash, fontSize: 11), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: Row( - children: [ - FilledButton.icon( - key: const Key('about_check_for_updates'), - onPressed: _checking ? null : _checkNow, - style: FilledButton.styleFrom( - backgroundColor: fs.accent, - foregroundColor: fs.parchment, - ), - icon: _checking - ? SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(fs.parchment), - ), - ) - : const Icon(LucideIcons.refresh_cw, size: 18), - label: Text(_checking ? 'Checking…' : 'Check for updates'), - ), - if (hasUpdate) ...[ - const SizedBox(width: 12), - FilledButton.icon( - key: const Key('about_install_update'), - onPressed: _installStage == _InstallStage.downloading - ? null - : () => _install(available), - style: FilledButton.styleFrom( - backgroundColor: fs.moss, - foregroundColor: fs.parchment, - ), - icon: _installStage == _InstallStage.downloading - ? SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, - value: _installProgress > 0 ? _installProgress : null, - valueColor: AlwaysStoppedAnimation(fs.parchment), - ), - ) - : const Icon(LucideIcons.download, size: 18), - label: Text( - _installStage == _InstallStage.downloading - ? 'Downloading…' - : _installStage == _InstallStage.error - ? 'Retry install' - : 'Install ${available.version}', - ), - ), - ], - ], - ), - ), - if (_installStage == _InstallStage.error && _installError != null) - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: Text(_installError!, - style: TextStyle(color: fs.error, fontSize: 12), - maxLines: 2, - overflow: TextOverflow.ellipsis), - ), - ]); - } - - String _statusFor(AsyncValue update, String installed) { - if (update.isLoading) return 'Checking…'; - if (update.hasError) return 'Check failed'; - return 'Up to date'; - } - - String _formatTime(DateTime t) { - final h = t.hour.toString().padLeft(2, '0'); - final m = t.minute.toString().padLeft(2, '0'); - return '$h:$m'; - } -} - -class _SectionHeader extends StatelessWidget { - const _SectionHeader(this.label); - final String label; - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Text( - label, - style: TextStyle( - color: fs.parchment, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ); - } -} diff --git a/flutter_client/lib/settings/settings_screen.dart b/flutter_client/lib/settings/settings_screen.dart deleted file mode 100644 index ba611366..00000000 --- a/flutter_client/lib/settings/settings_screen.dart +++ /dev/null @@ -1,575 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../api/endpoints/settings.dart'; -import '../api/errors.dart'; -import '../library/library_providers.dart' show dioProvider; -import '../models/my_profile.dart'; -import '../shared/widgets/main_app_bar_actions.dart'; -import '../theme/theme_extension.dart'; -import 'about_section.dart'; -import 'storage_section.dart'; -import '../theme/theme_mode_provider.dart'; - -final _settingsApiProvider = FutureProvider((ref) async { - return SettingsApi(await ref.watch(dioProvider.future)); -}); - -final _profileProvider = FutureProvider((ref) async { - return (await ref.watch(_settingsApiProvider.future)).getProfile(); -}); - -final _lbStatusProvider = FutureProvider((ref) async { - return (await ref.watch(_settingsApiProvider.future)).getListenBrainz(); -}); - -class SettingsScreen extends ConsumerWidget { - const SettingsScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - return Scaffold( - backgroundColor: fs.obsidian, - appBar: AppBar( - backgroundColor: fs.obsidian, - elevation: 0, - leading: IconButton( - icon: Icon(LucideIcons.arrow_left, color: fs.parchment), - onPressed: () => context.pop(), - ), - title: Text('Settings', style: TextStyle(color: fs.parchment)), - actions: const [MainAppBarActions(currentRoute: '/settings')], - ), - body: ListView( - padding: const EdgeInsets.symmetric(vertical: 8), - children: const [ - _ProfileSection(), - _Divider(), - _RequestsSection(), - _Divider(), - _AppearanceSection(), - _Divider(), - StorageSection(), - _Divider(), - _PasswordSection(), - _Divider(), - _ListenBrainzSection(), - _Divider(), - AboutSection(), - _AdminSection(), - SizedBox(height: 96), - ], - ), - ); - } -} - -class _Divider extends StatelessWidget { - const _Divider(); - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Container(height: 1, color: fs.iron), - ); - } -} - -class _SectionHeader extends StatelessWidget { - const _SectionHeader(this.label); - final String label; - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), - child: Text( - label, - style: TextStyle( - color: fs.parchment, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ); - } -} - -class _RequestsSection extends StatelessWidget { - const _RequestsSection(); - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return ListTile( - key: const Key('settings_requests_card'), - leading: Icon(LucideIcons.list_music, color: fs.parchment), - title: Text( - 'My requests', - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 18, - ), - ), - subtitle: Text( - "Track what you've asked Minstrel to add", - style: TextStyle(color: fs.ash), - ), - trailing: Icon(LucideIcons.chevron_right, color: fs.ash), - onTap: () => context.push('/requests'), - ); - } -} - -/// Renders an "Admin" entry only when the current profile has -/// `is_admin = true`. Returns an empty SizedBox otherwise so the -/// const-children layout above stays valid. -class _AdminSection extends ConsumerWidget { - const _AdminSection(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final profile = ref.watch(_profileProvider).value; - if (profile == null || !profile.isAdmin) return const SizedBox.shrink(); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const _Divider(), - ListTile( - key: const Key('settings_admin_card'), - leading: Icon(LucideIcons.shield, color: fs.parchment), - title: Text( - 'Admin', - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 18, - ), - ), - subtitle: Text( - 'Manage requests, quarantine, and users', - style: TextStyle(color: fs.ash), - ), - trailing: Icon(LucideIcons.chevron_right, color: fs.ash), - onTap: () => context.push('/admin'), - ), - ], - ); - } -} - -class _ProfileSection extends ConsumerStatefulWidget { - const _ProfileSection(); - @override - ConsumerState<_ProfileSection> createState() => _ProfileSectionState(); -} - -class _ProfileSectionState extends ConsumerState<_ProfileSection> { - final _displayName = TextEditingController(); - final _email = TextEditingController(); - bool _initialized = false; - bool _saving = false; - - @override - void dispose() { - _displayName.dispose(); - _email.dispose(); - super.dispose(); - } - - Future _save() async { - final fs = Theme.of(context).extension()!; - setState(() => _saving = true); - try { - final api = await ref.read(_settingsApiProvider.future); - await api.updateProfile( - displayName: _displayName.text.trim(), - email: _email.text.trim(), - ); - ref.invalidate(_profileProvider); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Profile saved.'), - backgroundColor: fs.iron, - ), - ); - } - } on DioException catch (e) { - if (mounted) { - final msg = ApiError.fromDio(e).code; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Save failed: $msg'), - backgroundColor: fs.error, - )); - } - } finally { - if (mounted) setState(() => _saving = false); - } - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final profile = ref.watch(_profileProvider); - return profile.when( - loading: () => const Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ), - error: (e, _) => Padding( - padding: const EdgeInsets.all(16), - child: Text('$e', style: TextStyle(color: fs.error)), - ), - data: (p) { - if (!_initialized) { - _displayName.text = p.displayName ?? ''; - _email.text = p.email ?? ''; - _initialized = true; - } - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const _SectionHeader('Profile'), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), - child: Text( - 'Signed in as ${p.username}${p.isAdmin ? " · admin" : ""}', - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: TextField( - controller: _displayName, - style: TextStyle(color: fs.parchment), - decoration: _inputDecoration(fs, 'Display name'), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: TextField( - controller: _email, - style: TextStyle(color: fs.parchment), - keyboardType: TextInputType.emailAddress, - decoration: _inputDecoration(fs, 'Email (for password reset)'), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: FilledButton( - onPressed: _saving ? null : _save, - style: FilledButton.styleFrom( - backgroundColor: fs.accent, - foregroundColor: fs.parchment, - ), - child: Text(_saving ? 'Saving…' : 'Save profile'), - ), - ), - ]); - }, - ); - } -} - -class _PasswordSection extends ConsumerStatefulWidget { - const _PasswordSection(); - @override - ConsumerState<_PasswordSection> createState() => _PasswordSectionState(); -} - -class _PasswordSectionState extends ConsumerState<_PasswordSection> { - final _current = TextEditingController(); - final _next = TextEditingController(); - final _confirm = TextEditingController(); - bool _saving = false; - - @override - void dispose() { - _current.dispose(); - _next.dispose(); - _confirm.dispose(); - super.dispose(); - } - - Future _change() async { - final fs = Theme.of(context).extension()!; - if (_next.text != _confirm.text) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('New passwords do not match.'), - backgroundColor: fs.error, - )); - return; - } - if (_next.text.length < 8) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('Password must be at least 8 characters.'), - backgroundColor: fs.error, - )); - return; - } - setState(() => _saving = true); - try { - final api = await ref.read(_settingsApiProvider.future); - await api.changePassword(current: _current.text, next: _next.text); - _current.clear(); - _next.clear(); - _confirm.clear(); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('Password changed.'), - backgroundColor: fs.iron, - )); - } - } on DioException catch (e) { - if (mounted) { - final msg = ApiError.fromDio(e).code; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Change failed: $msg'), - backgroundColor: fs.error, - )); - } - } finally { - if (mounted) setState(() => _saving = false); - } - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const _SectionHeader('Password'), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: TextField( - controller: _current, - obscureText: true, - style: TextStyle(color: fs.parchment), - decoration: _inputDecoration(fs, 'Current password'), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: TextField( - controller: _next, - obscureText: true, - style: TextStyle(color: fs.parchment), - decoration: _inputDecoration(fs, 'New password'), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: TextField( - controller: _confirm, - obscureText: true, - style: TextStyle(color: fs.parchment), - decoration: _inputDecoration(fs, 'Confirm new password'), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: FilledButton( - onPressed: _saving ? null : _change, - style: FilledButton.styleFrom( - backgroundColor: fs.accent, - foregroundColor: fs.parchment, - ), - child: Text(_saving ? 'Changing…' : 'Change password'), - ), - ), - ]); - } -} - -class _ListenBrainzSection extends ConsumerStatefulWidget { - const _ListenBrainzSection(); - @override - ConsumerState<_ListenBrainzSection> createState() => - _ListenBrainzSectionState(); -} - -class _ListenBrainzSectionState extends ConsumerState<_ListenBrainzSection> { - final _token = TextEditingController(); - bool _saving = false; - - @override - void dispose() { - _token.dispose(); - super.dispose(); - } - - Future _saveToken() async { - final fs = Theme.of(context).extension()!; - if (_token.text.trim().isEmpty) return; - setState(() => _saving = true); - try { - final api = await ref.read(_settingsApiProvider.future); - await api.setListenBrainzToken(_token.text.trim()); - _token.clear(); - ref.invalidate(_lbStatusProvider); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('Token saved.'), - backgroundColor: fs.iron, - )); - } - } on DioException catch (e) { - if (mounted) { - final msg = ApiError.fromDio(e).code; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Save failed: $msg'), - backgroundColor: fs.error, - )); - } - } finally { - if (mounted) setState(() => _saving = false); - } - } - - Future _toggleEnabled(bool value) async { - final fs = Theme.of(context).extension()!; - try { - final api = await ref.read(_settingsApiProvider.future); - await api.setListenBrainzEnabled(value); - ref.invalidate(_lbStatusProvider); - } on DioException catch (e) { - if (mounted) { - final msg = ApiError.fromDio(e).code; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Toggle failed: $msg'), - backgroundColor: fs.error, - )); - } - } - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final status = ref.watch(_lbStatusProvider); - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const _SectionHeader('ListenBrainz'), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: Text( - 'Get a token at listenbrainz.org/profile. Tokens are stored ' - 'unencrypted on this server — treat as sensitive.', - style: TextStyle(color: fs.ash, fontSize: 12), - ), - ), - status.when( - loading: () => const Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ), - error: (e, _) => Padding( - padding: const EdgeInsets.all(16), - child: Text('$e', style: TextStyle(color: fs.error)), - ), - data: (s) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: TextField( - controller: _token, - obscureText: true, - style: TextStyle(color: fs.parchment), - decoration: _inputDecoration( - fs, - s.tokenSet ? 'Update token (current is set)' : 'Paste token', - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: FilledButton( - onPressed: _saving ? null : _saveToken, - style: FilledButton.styleFrom( - backgroundColor: fs.accent, - foregroundColor: fs.parchment, - ), - child: Text(_saving ? 'Saving…' : 'Save token'), - ), - ), - SwitchListTile( - title: Text( - 'Send my plays to ListenBrainz', - style: TextStyle(color: fs.parchment), - ), - subtitle: s.lastScrobbledAt != null - ? Text( - 'Last scrobble: ${s.lastScrobbledAt}', - style: TextStyle(color: fs.ash, fontSize: 11), - ) - : null, - value: s.enabled, - activeThumbColor: fs.accent, - onChanged: s.tokenSet ? _toggleEnabled : null, - ), - ], - ), - ), - ]); - } -} - -InputDecoration _inputDecoration(FabledSwordTheme fs, String label) { - return InputDecoration( - labelText: label, - labelStyle: TextStyle(color: fs.ash), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: fs.iron), - ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide(color: fs.accent), - ), - ); -} - -class _AppearanceSection extends ConsumerWidget { - const _AppearanceSection(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final current = ref.watch(themeModeProvider).value ?? AppThemeMode.system; - // RadioGroup is the post-Flutter-3.32 API: the ancestor owns - // groupValue/onChanged so individual RadioListTiles don't have - // to repeat them. Pre-3.32 RadioListTile.groupValue/onChanged - // are deprecated. - return RadioGroup( - groupValue: current, - onChanged: (m) { - if (m != null) { - ref.read(themeModeProvider.notifier).set(m); - } - }, - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const _SectionHeader('Appearance'), - for (final mode in AppThemeMode.values) - RadioListTile( - key: Key('appearance_${mode.name}'), - title: Text(_label(mode), style: TextStyle(color: fs.parchment)), - subtitle: mode == AppThemeMode.system - ? Text('Match the device setting', - style: TextStyle(color: fs.ash)) - : null, - value: mode, - activeColor: fs.accent, - ), - ]), - ); - } - - String _label(AppThemeMode m) => switch (m) { - AppThemeMode.system => 'System', - AppThemeMode.light => 'Light', - AppThemeMode.dark => 'Dark', - }; -} diff --git a/flutter_client/lib/settings/storage_section.dart b/flutter_client/lib/settings/storage_section.dart deleted file mode 100644 index 8bee9d7a..00000000 --- a/flutter_client/lib/settings/storage_section.dart +++ /dev/null @@ -1,255 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../cache/audio_cache_manager.dart'; -import '../cache/cache_settings_provider.dart'; -import '../cache/sync_controller.dart'; -import '../likes/likes_provider.dart' show likedIdsProvider; -import '../theme/theme_extension.dart'; - -/// Settings card: per-bucket usage, two cap selectors (Liked + -/// Recently-played), prefetch window, cache-liked toggle, Clear -/// cache + Sync now. #427 S2/S3: two independent budgets. -class StorageSection extends ConsumerStatefulWidget { - const StorageSection({super.key}); - - @override - ConsumerState createState() => _StorageSectionState(); -} - -class _StorageSectionState extends ConsumerState { - BucketUsage? _usage; - bool _syncing = false; - - @override - void initState() { - super.initState(); - _refreshUsage(); - } - - Set _likedSet() => - ref.read(likedIdsProvider).value?.tracks ?? const {}; - - Future _refreshUsage() async { - final mgr = ref.read(audioCacheManagerProvider); - final u = await mgr.bucketUsage(_likedSet()); - if (mounted) setState(() => _usage = u); - } - - String _fmtBytes(int? n) { - if (n == null) return '—'; - if (n == 0) return '0 B'; - if (n < 1024) return '$n B'; - if (n < 1024 * 1024) return '${(n / 1024).toStringAsFixed(1)} KB'; - if (n < 1024 * 1024 * 1024) { - return '${(n / 1024 / 1024).toStringAsFixed(1)} MB'; - } - return '${(n / 1024 / 1024 / 1024).toStringAsFixed(2)} GB'; - } - - String _cap(int bytes) => bytes == 0 ? 'unlimited' : _fmtBytes(bytes); - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final settings = ref.watch(cacheSettingsProvider); - return Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: Card( - color: fs.iron, - child: Padding( - padding: const EdgeInsets.all(16), - child: settings.when( - loading: () => const Padding( - padding: EdgeInsets.all(8), - child: Center(child: CircularProgressIndicator()), - ), - error: (e, _) => Text('$e', style: TextStyle(color: fs.error)), - data: (s) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Storage', - style: TextStyle( - color: fs.parchment, - fontSize: 18, - fontWeight: FontWeight.w500)), - const SizedBox(height: 12), - _usageRow('Liked', _usage?.liked, s.likedCapBytes, fs), - const SizedBox(height: 4), - _usageRow('Recently played', _usage?.rolling, - s.rollingCapBytes, fs), - const SizedBox(height: 16), - _capSelector( - 'Liked cache limit', - const Key('liked_cap_selector'), - s.likedCapBytes, - (v) => ref - .read(cacheSettingsProvider.notifier) - .setLikedCapBytes(v), - s, - fs, - ), - const SizedBox(height: 8), - _capSelector( - 'Recently-played cache limit', - const Key('rolling_cap_selector'), - s.rollingCapBytes, - (v) => ref - .read(cacheSettingsProvider.notifier) - .setRollingCapBytes(v), - s, - fs, - ), - const SizedBox(height: 8), - _prefetchSelector(s, fs), - const SizedBox(height: 8), - SwitchListTile( - key: const Key('cache_liked_toggle'), - contentPadding: EdgeInsets.zero, - title: Text('Cache liked tracks', - style: TextStyle(color: fs.parchment)), - value: s.cacheLikedTracks, - onChanged: (v) => ref - .read(cacheSettingsProvider.notifier) - .setCacheLikedTracks(v), - ), - const SizedBox(height: 12), - Wrap(spacing: 8, runSpacing: 8, children: [ - OutlinedButton( - key: const Key('clear_cache_button'), - onPressed: _confirmClear, - child: const Text('Clear cache'), - ), - OutlinedButton.icon( - key: const Key('sync_now_button'), - onPressed: _syncing - ? null - : () async { - setState(() => _syncing = true); - try { - await ref - .read(syncControllerProvider.notifier) - .sync(); - } finally { - if (mounted) setState(() => _syncing = false); - } - }, - icon: _syncing - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(LucideIcons.refresh_cw, size: 16), - label: const Text('Sync now'), - ), - ]), - ], - ), - ), - ), - ), - ); - } - - Widget _usageRow(String label, int? used, int cap, FabledSwordTheme fs) { - return Row(children: [ - Text(label, style: TextStyle(color: fs.ash)), - const Spacer(), - Text('${_fmtBytes(used)} / ${_cap(cap)}', - style: TextStyle(color: fs.parchment)), - ]); - } - - static const _capOptions = [ - (1024 * 1024 * 1024, '1 GB'), - (5 * 1024 * 1024 * 1024, '5 GB'), - (10 * 1024 * 1024 * 1024, '10 GB'), - (25 * 1024 * 1024 * 1024, '25 GB'), - (0, 'Unlimited'), - ]; - - Widget _capSelector( - String label, - Key key, - int current, - Future Function(int) setCap, - CacheSettings s, - FabledSwordTheme fs, - ) { - return Row(children: [ - Expanded(child: Text(label, style: TextStyle(color: fs.ash))), - DropdownButton( - key: key, - value: _capOptions.any((o) => o.$1 == current) - ? current - : 5 * 1024 * 1024 * 1024, - items: _capOptions - .map((o) => DropdownMenuItem(value: o.$1, child: Text(o.$2))) - .toList(), - onChanged: (v) async { - if (v == null) return; - await setCap(v); - // Enforce immediately against the freshest settings. - final fresh = ref.read(cacheSettingsProvider).value; - if (fresh != null) { - await ref.read(audioCacheManagerProvider).evictBuckets( - likedCap: fresh.likedCapBytes, - rollingCap: fresh.rollingCapBytes, - liked: _likedSet(), - ); - } - await _refreshUsage(); - }, - ), - ]); - } - - Widget _prefetchSelector(CacheSettings s, FabledSwordTheme fs) { - const options = [1, 3, 5, 7, 10]; - return Row(children: [ - Expanded( - child: Text('Pre-fetch ahead', style: TextStyle(color: fs.ash))), - DropdownButton( - key: const Key('prefetch_selector'), - value: options.contains(s.prefetchWindow) ? s.prefetchWindow : 5, - items: options - .map((n) => DropdownMenuItem(value: n, child: Text('$n tracks'))) - .toList(), - onChanged: (v) async { - if (v == null) return; - await ref.read(cacheSettingsProvider.notifier).setPrefetchWindow(v); - }, - ), - ]); - } - - Future _confirmClear() async { - final fs = Theme.of(context).extension()!; - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Clear cache?'), - content: const Text( - 'This deletes all cached audio (liked and recently-played). ' - 'The next play of any track re-downloads from the server.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel'), - ), - FilledButton( - style: FilledButton.styleFrom(backgroundColor: fs.error), - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Clear'), - ), - ], - ), - ); - if (confirmed != true) return; - await ref.read(audioCacheManagerProvider).clearAll(); - await _refreshUsage(); - } -} diff --git a/flutter_client/lib/shared/delayed_loading.dart b/flutter_client/lib/shared/delayed_loading.dart deleted file mode 100644 index 01b7eebc..00000000 --- a/flutter_client/lib/shared/delayed_loading.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'dart:async'; -import 'package:flutter/widgets.dart'; - -/// Renders [whileDelayed] only after [isLoading] has been true -/// continuously for [delay], then renders [whenReady] once loading -/// completes. Mirrors the web `useDelayed` hook: a brief loading flash -/// doesn't trigger a skeleton; sustained loading does. -/// -/// Once [isLoading] flips back to false, the timer resets. -class DelayedLoading extends StatefulWidget { - const DelayedLoading({ - super.key, - required this.isLoading, - required this.whileDelayed, - required this.whenReady, - this.delay = const Duration(milliseconds: 200), - }); - - final bool isLoading; - final Widget whileDelayed; - final Widget whenReady; - final Duration delay; - - @override - State createState() => _DelayedLoadingState(); -} - -class _DelayedLoadingState extends State { - Timer? _timer; - bool _delayPassed = false; - - @override - void initState() { - super.initState(); - _maybeStart(); - } - - @override - void didUpdateWidget(DelayedLoading old) { - super.didUpdateWidget(old); - if (widget.isLoading != old.isLoading) { - _timer?.cancel(); - _delayPassed = false; - _maybeStart(); - } - } - - void _maybeStart() { - if (!widget.isLoading) return; - _timer = Timer(widget.delay, () { - if (mounted) setState(() => _delayPassed = true); - }); - } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - if (!widget.isLoading) return widget.whenReady; - return _delayPassed ? widget.whileDelayed : const SizedBox.shrink(); - } -} diff --git a/flutter_client/lib/shared/live_events_dispatcher.dart b/flutter_client/lib/shared/live_events_dispatcher.dart deleted file mode 100644 index d38d75ae..00000000 --- a/flutter_client/lib/shared/live_events_dispatcher.dart +++ /dev/null @@ -1,104 +0,0 @@ -// Maps incoming LiveEvent kinds to provider invalidations. -// -// Activated by ref.read(liveEventsDispatcherProvider) in app.dart; once -// activated, the dispatcher listens to liveEventsProvider for the -// lifetime of the ProviderScope and invalidates the small set of -// public-scoped providers we know about. -// -// Screen-scoped providers (file-private providers in library_screen.dart, -// admin handlers, etc.) opt in to live-refresh by themselves listening -// to liveEventsProvider — the dispatcher only handles cross-screen, -// publicly-importable providers. This keeps the dispatcher small and -// avoids a back-edge dependency from /shared onto every feature folder. -// -// Includes an AppLifecycleState resume handler that defensively -// invalidates the same set on app foreground. SSE will catch up on its -// own, but on cold-start or after a long background period the -// re-invalidate is fast and avoids stale data flashing before the -// stream reconnects. - -import 'package:flutter/widgets.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../library/library_providers.dart' show homeIndexProvider; -import '../quarantine/quarantine_provider.dart'; -import 'live_events_provider.dart'; - -/// Activates the SSE → invalidation dispatcher for the lifetime of the -/// containing ProviderScope. Read this once at startup (from app.dart); -/// the provider's body runs once, sets up listeners, and returns a -/// sentinel value. -final liveEventsDispatcherProvider = Provider<_LiveEventsDispatcher>((ref) { - final d = _LiveEventsDispatcher(ref); - ref.onDispose(d.dispose); - return d; -}); - -class _LiveEventsDispatcher with WidgetsBindingObserver { - _LiveEventsDispatcher(this._ref) { - // Subscribe to the event stream. Each emitted event invokes - // _handle. Errors / disconnects auto-rebuild the underlying - // provider; we don't need to react to them here. - _sub = _ref.listen>( - liveEventsProvider, - (_, next) => next.whenData(_handle), - fireImmediately: false, - ); - WidgetsBinding.instance.addObserver(this); - } - - final Ref _ref; - late final ProviderSubscription> _sub; - - void _handle(LiveEvent e) { - // Map event kinds to provider invalidations. Keep this list short: - // only providers reachable from /shared. Screen-private providers - // listen to liveEventsProvider themselves. - switch (e.kind) { - case 'quarantine.flagged': - case 'quarantine.unflagged': - case 'quarantine.resolved': - case 'quarantine.file_deleted': - case 'quarantine.deleted_via_lidarr': - _ref.invalidate(myQuarantineProvider); - // Hidden / liked / album rows referencing a deleted track may - // now be stale — homeIndexProvider re-fetch refreshes the cards. - _ref.invalidate(homeIndexProvider); - case 'playlist.created': - case 'playlist.updated': - case 'playlist.deleted': - case 'playlist.tracks_changed': - // Home renders a Playlists row; refresh it so a delete or add - // is reflected immediately. Detail screens that need the - // mutated row will get a separate invalidate from their own - // listener (filed as a follow-up). - _ref.invalidate(homeIndexProvider); - case 'scan.run_started': - case 'scan.run_finished': - // Admin scan card provider lives in the admin feature folder - // and is file-private today; will be invalidated by the admin - // dashboard's own listener once that exists. - break; - default: - // track.liked / track.unliked / request.status_changed reach - // screen-private providers; their screens listen directly. - break; - } - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) { - // Defensive cold-start invalidation. SSE will catch up on its - // own, but the re-invalidate flushes any stale data that - // landed while the app was backgrounded. - _ref.invalidate(myQuarantineProvider); - _ref.invalidate(homeIndexProvider); - } - } - - void dispose() { - WidgetsBinding.instance.removeObserver(this); - _sub.close(); - } -} diff --git a/flutter_client/lib/shared/live_events_provider.dart b/flutter_client/lib/shared/live_events_provider.dart deleted file mode 100644 index 22b840bd..00000000 --- a/flutter_client/lib/shared/live_events_provider.dart +++ /dev/null @@ -1,126 +0,0 @@ -// Subscribes to the server's Server-Sent Events stream -// (GET /api/events/stream, see Fable #392) and exposes a parsed event -// stream as a Riverpod StreamProvider. Consumers wire invalidation -// behavior in live_events_dispatcher.dart. -// -// On disconnect (network blip, server restart, token rotation), the -// provider's StreamController completes; the dispatcher's auto-rebuild -// when the auth state changes re-subscribes. Explicit exponential -// backoff lives at the dispatcher layer so we don't re-create the dio -// connection too aggressively. - -import 'dart:async'; -import 'dart:convert'; - -import 'package:dio/dio.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../auth/auth_provider.dart'; -import '../library/library_providers.dart' show dioProvider; - -/// Parsed event from the server's SSE stream. `kind` follows -/// "domain.action" naming; `data` carries the payload map. -class LiveEvent { - const LiveEvent({required this.kind, required this.userId, required this.data}); - - final String kind; - final String userId; - final Map data; - - @override - String toString() => 'LiveEvent($kind, user=$userId, data=$data)'; -} - -/// Streams parsed LiveEvent values from /api/events/stream. Errors and -/// disconnects surface as stream errors; the dispatcher decides whether -/// to retry. -final liveEventsProvider = StreamProvider((ref) async* { - // Gate the subscription on having both a server URL and a session - // token. If either is missing, emit nothing and let the provider - // auto-rebuild when auth state lands. - final token = await ref.watch(sessionTokenProvider.future); - if (token == null || token.isEmpty) { - return; - } - final dio = await ref.watch(dioProvider.future); - - final controller = StreamController(); - ref.onDispose(controller.close); - - // ignore: unawaited_futures - _runSubscription(dio, controller); - - yield* controller.stream; -}); - -/// Runs the dio streaming request and pushes parsed events into -/// [controller]. Closes the controller when the stream ends or errors. -Future _runSubscription(Dio dio, StreamController controller) async { - try { - final resp = await dio.get( - '/api/events/stream', - options: Options( - responseType: ResponseType.stream, - // No timeout — the server emits 15s heartbeats; idle timeouts - // on the client side would tear down a healthy connection. - receiveTimeout: Duration.zero, - headers: const {'Accept': 'text/event-stream'}, - ), - ); - - // SSE frames are delimited by blank lines. Accumulate raw bytes - // into a string buffer; flush parsed events on each "\n\n". - var buffer = ''; - await for (final chunk in resp.data!.stream) { - buffer += utf8.decode(chunk, allowMalformed: true); - while (true) { - final i = buffer.indexOf('\n\n'); - if (i < 0) break; - final frame = buffer.substring(0, i); - buffer = buffer.substring(i + 2); - final event = _parseFrame(frame); - if (event != null && !controller.isClosed) { - controller.add(event); - } - } - } - if (!controller.isClosed) { - await controller.close(); - } - } catch (e, st) { - if (!controller.isClosed) { - controller.addError(e, st); - await controller.close(); - } - } -} - -/// Parses one SSE frame. Heartbeat comments (starting with ":") and -/// frames without a `data:` line return null. Frames with a `data:` -/// payload that doesn't parse as JSON are also dropped (logged at -/// debug level by the caller if needed). -LiveEvent? _parseFrame(String frame) { - String? kind; - String? dataLine; - for (final line in frame.split('\n')) { - if (line.isEmpty || line.startsWith(':')) { - continue; - } - if (line.startsWith('event:')) { - kind = line.substring(6).trim(); - } else if (line.startsWith('data:')) { - dataLine = line.substring(5).trim(); - } - } - if (dataLine == null) return null; - try { - final decoded = jsonDecode(dataLine) as Map; - return LiveEvent( - kind: kind ?? (decoded['kind'] as String? ?? ''), - userId: decoded['user_id'] as String? ?? '', - data: (decoded['data'] as Map?) ?? const {}, - ); - } catch (_) { - return null; - } -} diff --git a/flutter_client/lib/shared/routing.dart b/flutter_client/lib/shared/routing.dart deleted file mode 100644 index ef14e7a2..00000000 --- a/flutter_client/lib/shared/routing.dart +++ /dev/null @@ -1,174 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../auth/auth_provider.dart'; -import '../auth/login_screen.dart'; -import '../auth/server_url_screen.dart'; -import '../library/album_detail_screen.dart'; -import '../library/artist_detail_screen.dart'; -import '../models/album.dart'; -import '../models/artist.dart'; -import '../models/playlist.dart'; -import '../discover/discover_screen.dart'; -import '../library/home_screen.dart'; -import '../library/library_screen.dart'; -import '../player/now_playing_screen.dart'; -import '../player/player_bar.dart'; -import '../player/queue_screen.dart'; -import '../playlists/playlist_detail_screen.dart'; -import '../playlists/playlists_list_screen.dart'; -import '../requests/requests_screen.dart'; -import '../search/search_screen.dart'; -import '../settings/settings_screen.dart'; -import '../update/client_update_provider.dart'; -import '../update/update_banner.dart'; -import '../admin/admin_landing_screen.dart'; -import '../admin/admin_requests_screen.dart'; -import '../admin/admin_quarantine_screen.dart'; -import '../admin/admin_users_screen.dart'; -import 'widgets/version_gate.dart'; - -/// Exposed as a Provider so its single argument is a real `Ref` (the -/// constructor's redirect closures use `ref.read`). Widgets consume the -/// router via `ref.watch(routerProvider)` instead of constructing it -/// themselves with a `WidgetRef`, which can't satisfy the `Ref` type. -final routerProvider = Provider((ref) => buildRouter(ref)); - -GoRouter buildRouter(Ref ref) { - return GoRouter( - initialLocation: '/', - redirect: (ctx, state) async { - final url = await ref.read(serverUrlProvider.future); - if (url == null) return '/server-url'; - final user = await ref.read(authControllerProvider.future); - final loc = state.matchedLocation; - if (user == null && loc != '/login' && loc != '/server-url') { - return '/login'; - } - if (user != null && (loc == '/login' || loc == '/server-url')) { - return '/home'; - } - if (loc.startsWith('/admin') && !user!.isAdmin) { - return '/home'; - } - return null; - }, - routes: [ - GoRoute(path: '/', redirect: (_, __) => '/home'), - GoRoute(path: '/server-url', builder: (_, __) => const ServerUrlScreen()), - GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), - // /now-playing lives outside the ShellRoute on purpose. The full - // player IS the player UI when active, and we don't want the mini - // bar from the shell underneath fighting for the bottom strip. - // Pushing this route unmounts the shell entirely; the slide-up - // transition still feels right because the shell stays painted - // for the duration of the animation. - GoRoute( - path: '/now-playing', - pageBuilder: (_, __) => CustomTransitionPage( - child: const NowPlayingScreen(), - transitionDuration: const Duration(milliseconds: 280), - reverseTransitionDuration: const Duration(milliseconds: 240), - transitionsBuilder: (_, anim, __, child) { - final eased = CurvedAnimation( - parent: anim, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, - ); - return SlideTransition( - position: Tween( - begin: const Offset(0, 1), - end: Offset.zero, - ).animate(eased), - child: child, - ); - }, - ), - ), - // /queue lives outside the ShellRoute too. Why: pushing /queue - // from /now-playing (which is also outside the shell) used to - // cause go_router to mount a second ShellRoute instance under - // the existing one, producing a duplicate page-key assertion - // (NavigatorState._debugCheckDuplicatedPageKeys). Top-level - // routes can stack on each other freely; shell-children can't - // when something on top of the shell is already routing. - GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()), - ShellRoute( - builder: (ctx, state, child) => VersionGate(child: _ShellWithPlayerBar(child: child)), - routes: [ - GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), - GoRoute( - path: '/artists/:id', - // `extra` carries an optional ArtistRef so the detail - // header renders immediately while tracks/albums load. - builder: (_, s) => ArtistDetailScreen( - id: s.pathParameters['id']!, - seed: s.extra is ArtistRef ? s.extra as ArtistRef : null, - ), - ), - GoRoute( - path: '/albums/:id', - builder: (_, s) => AlbumDetailScreen( - id: s.pathParameters['id']!, - seed: s.extra is AlbumRef ? s.extra as AlbumRef : null, - ), - ), - GoRoute(path: '/search', builder: (_, __) => const SearchScreen()), - GoRoute(path: '/library', builder: (_, __) => const LibraryScreen()), - GoRoute(path: '/discover', builder: (_, __) => const DiscoverScreen()), - GoRoute(path: '/settings', builder: (_, __) => const SettingsScreen()), - GoRoute(path: '/playlists', builder: (_, __) => const PlaylistsListScreen()), - GoRoute( - path: '/playlists/:id', - builder: (_, s) => PlaylistDetailScreen( - id: s.pathParameters['id']!, - seed: s.extra is Playlist ? s.extra as Playlist : null, - ), - ), - GoRoute(path: '/requests', builder: (_, __) => const RequestsScreen()), - GoRoute(path: '/admin', builder: (_, __) => const AdminLandingScreen()), - GoRoute(path: '/admin/requests', builder: (_, __) => const AdminRequestsScreen()), - GoRoute(path: '/admin/quarantine', builder: (_, __) => const AdminQuarantineScreen()), - GoRoute(path: '/admin/users', builder: (_, __) => const AdminUsersScreen()), - ], - ), - ], - ); -} - -class _ShellWithPlayerBar extends ConsumerWidget { - const _ShellWithPlayerBar({required this.child}); - final Widget child; - @override - Widget build(BuildContext context, WidgetRef ref) { - // The banner sits above the routed child and uses SafeArea to clear - // the system status bar. MediaQuery.padding is screen-relative - // though, so the child's Scaffold/AppBar would still apply its own - // top status-bar inset on top of the banner — doubling the gap. - // When the banner is showing, strip that inset from the child so - // its AppBar lands directly under the banner. - final hasBanner = ref.watch(shouldShowUpdateBannerProvider) != null; - return Column( - children: [ - // VersionTooOldBanner above UpdateBanner: a server-rejects-you - // soft warning carries more user-relevant urgency than the APK - // download prompt below it. The two banners can coexist - // (server says you're too old AND a local APK is queued). - const VersionTooOldBanner(), - const UpdateBanner(), - Expanded( - child: hasBanner - ? MediaQuery.removePadding( - context: context, - removeTop: true, - child: child, - ) - : child, - ), - const PlayerBar(), - ], - ); - } -} - diff --git a/flutter_client/lib/shared/widgets/connection_error_banner.dart b/flutter_client/lib/shared/widgets/connection_error_banner.dart deleted file mode 100644 index 57e62961..00000000 --- a/flutter_client/lib/shared/widgets/connection_error_banner.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; - -import '../../theme/theme_extension.dart'; - -class ConnectionErrorBanner extends StatelessWidget { - const ConnectionErrorBanner({required this.onRetry, super.key}); - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Container( - color: fs.iron, - padding: const EdgeInsets.all(12), - child: Row(children: [ - Expanded( - child: Text( - "Couldn't reach the server.", - style: TextStyle(color: fs.parchment), - ), - ), - TextButton(onPressed: onRetry, child: const Text('Retry')), - TextButton( - onPressed: () => context.go('/server-url'), - child: const Text('Change URL'), - ), - ]), - ); - } -} diff --git a/flutter_client/lib/shared/widgets/lucide_heart.dart b/flutter_client/lib/shared/widgets/lucide_heart.dart deleted file mode 100644 index 9e103435..00000000 --- a/flutter_client/lib/shared/widgets/lucide_heart.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_svg/flutter_svg.dart'; - -/// The Lucide "heart" silhouette rendered as either an outline (stroke) -/// or a solid fill. Lucide ships only an outline heart, so the liked -/// state fills the same authoritative Lucide path (verified verbatim -/// from lucide-icons/lucide) — keeping both states visually Lucide -/// rather than introducing a Material filled heart. Used by LikeButton -/// (and, re-derived to a VectorDrawable, by the media notification). -class LucideHeart extends StatelessWidget { - const LucideHeart({ - required this.filled, - required this.color, - this.size = 22, - super.key, - }); - - final bool filled; - final Color color; - final double size; - - static const _path = - 'M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 ' - '22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5' - '-3-3.2-3-5.5'; - - @override - Widget build(BuildContext context) { - final svg = filled - ? '' - '' - : '' - ''; - return SvgPicture.string( - svg, - width: size, - height: size, - colorFilter: ColorFilter.mode(color, BlendMode.srcIn), - ); - } -} diff --git a/flutter_client/lib/shared/widgets/main_app_bar_actions.dart b/flutter_client/lib/shared/widgets/main_app_bar_actions.dart deleted file mode 100644 index 741cd4db..00000000 --- a/flutter_client/lib/shared/widgets/main_app_bar_actions.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../auth/auth_provider.dart'; -import '../../theme/theme_extension.dart'; - -/// Shared AppBar `actions` for top-level screens. Renders Home / Library / -/// Search as primary icons (suppressing the icon for [currentRoute]) plus -/// a kebab overflow with Playlists / Discover / Settings and (for admins) -/// Admin. -class MainAppBarActions extends ConsumerWidget { - const MainAppBarActions({super.key, required this.currentRoute}); - - final String currentRoute; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final user = ref.watch(authControllerProvider).value; - final isAdmin = user?.isAdmin ?? false; - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (currentRoute != '/home') - IconButton( - key: const Key('app_bar_home'), - icon: Icon(LucideIcons.house, color: fs.parchment), - tooltip: 'Home', - onPressed: () => context.go('/home'), - ), - if (currentRoute != '/library') - IconButton( - key: const Key('app_bar_library'), - icon: Icon(LucideIcons.library_big, color: fs.parchment), - tooltip: 'Library', - onPressed: () => context.push('/library'), - ), - if (currentRoute != '/search') - IconButton( - key: const Key('app_bar_search'), - icon: Icon(LucideIcons.search, color: fs.parchment), - tooltip: 'Search', - onPressed: () => context.push('/search'), - ), - PopupMenuButton( - key: const Key('app_bar_overflow'), - icon: Icon(LucideIcons.ellipsis_vertical, color: fs.parchment), - tooltip: 'More', - onSelected: (route) => context.push(route), - itemBuilder: (_) => [ - const PopupMenuItem(value: '/playlists', child: Text('Playlists')), - const PopupMenuItem(value: '/discover', child: Text('Discover')), - const PopupMenuItem(value: '/settings', child: Text('Settings')), - if (isAdmin) - const PopupMenuItem(value: '/admin', child: Text('Admin')), - ], - ), - ], - ); - } -} diff --git a/flutter_client/lib/shared/widgets/server_image.dart b/flutter_client/lib/shared/widgets/server_image.dart deleted file mode 100644 index 92b854e4..00000000 --- a/flutter_client/lib/shared/widgets/server_image.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart' - show HttpExceptionWithStatus; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../auth/auth_provider.dart'; - -/// CachedNetworkImage wrapper that resolves server-relative URLs (e.g. -/// `/api/albums//cover`) against the configured server base URL -/// from [serverUrlProvider]. Absolute URLs (with scheme) pass through -/// unchanged. Empty/null base or empty URL render the [fallback] -/// (defaults to a transparent SizedBox so callers can supply their own -/// surrounding placeholder). -/// -/// Mirrors the absolute-or-relative logic the audio handler uses for -/// stream URLs, so cover art and audio resolve URLs the same way. -/// -/// Uses CachedNetworkImage so cover bytes land on disk (path_provider -/// temp dir, keyed by URL) and survive scroll-off + app restart. The -/// previous Image.network behavior cached only in-memory, so the cold- -/// start home grid re-downloaded every cover. -class ServerImage extends ConsumerWidget { - const ServerImage({ - super.key, - required this.url, - this.fit, - this.fallback, - }); - - final String url; - final BoxFit? fit; - final Widget? fallback; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final empty = fallback ?? const SizedBox.shrink(); - if (url.isEmpty) return empty; - final base = ref.watch(serverUrlProvider).value; - final resolved = _resolve(base, url); - if (resolved == null) return empty; - // Cover endpoints are gated by RequireUser server-side. The image - // loader doesn't carry cookies/Bearer tokens automatically the way - // the browser's tag does, so we forward the session token as - // a header. Without this, the server returns 401 and the image - // fails. - // - // sessionTokenProvider is a FutureProvider — on first read after a - // hot restart its .value is null until the secure-storage read - // resolves. If we mounted the image with httpHeaders:null at that - // moment, the disk cache would lock in the 401 response and never - // retry. Hold the fallback until the token is available, then - // mount the image with the auth header attached. - final tokenAsync = ref.watch(sessionTokenProvider); - return tokenAsync.when( - loading: () => empty, - error: (_, __) => empty, - data: (token) { - final headers = (token != null && token.isNotEmpty) - ? {'Authorization': 'Bearer $token'} - : {}; - return CachedNetworkImage( - imageUrl: resolved, - httpHeaders: headers, - fit: fit, - // 120ms feels like cover bytes settling in on a cache miss - // (smoother than the abrupt zero-fade); on cache hits the - // image is decoded synchronously so the fade is imperceptible. - // The default 500ms is too long — looks like a regression on - // a populated grid. - fadeInDuration: const Duration(milliseconds: 120), - fadeOutDuration: Duration.zero, - // Keep failures local — a single 401/timeout shouldn't dump - // a stack trace or replace the parent Container's background. - errorWidget: (_, __, ___) => empty, - // Filter expected 404s out of dev console noise: playlist - // collages aren't built until the playlist has tracks and - // the build job runs (system playlists like For-You / - // Discover hit this on first-render). The errorWidget - // already renders the fallback for the user; this just - // keeps the dev console clean. Non-404 errors still - // surface so auth/connectivity issues remain visible. - errorListener: (err) { - if (err is HttpExceptionWithStatus && err.statusCode == 404) { - return; - } - debugPrint('ServerImage: $err'); - }, - ); - }, - ); - } - - static String? _resolve(String? baseUrl, String url) { - final parsed = Uri.tryParse(url); - if (parsed != null && parsed.hasScheme) return url; - if (baseUrl == null || baseUrl.isEmpty) return null; - final base = baseUrl.endsWith('/') - ? baseUrl.substring(0, baseUrl.length - 1) - : baseUrl; - final path = url.startsWith('/') ? url : '/$url'; - return '$base$path'; - } -} diff --git a/flutter_client/lib/shared/widgets/skeletons.dart b/flutter_client/lib/shared/widgets/skeletons.dart deleted file mode 100644 index edbbbbf8..00000000 --- a/flutter_client/lib/shared/widgets/skeletons.dart +++ /dev/null @@ -1,199 +0,0 @@ -// Skeleton placeholder widgets for the per-item rendering architecture -// (see docs/superpowers/specs/2026-05-13-per-item-rendering-design.md). -// -// Each skeleton matches the exact dimensions of its corresponding real -// card so the layout doesn't shift when content lands. A subtle -// shimmer sweep makes the page feel alive while individual tiles -// hydrate against drift / REST in the background. -// -// Self-contained shimmer (no `shimmer` package dep) so we can keep -// pubspec lean and tune the sweep colors against FabledSword tokens. - -import 'package:flutter/material.dart'; - -import '../../theme/theme_extension.dart'; - -/// Period of one full shimmer sweep across the skeleton. -const Duration _shimmerPeriod = Duration(milliseconds: 1200); - -/// Wraps a child with a slow-moving highlight band on top of the -/// skeleton's base color. Cheap — uses a single AnimationController -/// per surface and a LinearGradient shader. -class _Shimmer extends StatefulWidget { - const _Shimmer({required this.child}); - final Widget child; - - @override - State<_Shimmer> createState() => _ShimmerState(); -} - -class _ShimmerState extends State<_Shimmer> - with SingleTickerProviderStateMixin { - late final AnimationController _ctrl = AnimationController( - vsync: this, - duration: _shimmerPeriod, - )..repeat(); - - @override - void dispose() { - _ctrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return AnimatedBuilder( - animation: _ctrl, - builder: (context, child) { - return ShaderMask( - blendMode: BlendMode.srcATop, - shaderCallback: (rect) { - // Sweep starts off-screen left, ends off-screen right. - final dx = (_ctrl.value * 2 - 1) * rect.width; - return LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - fs.slate, - fs.iron, - fs.slate, - ], - stops: const [0.0, 0.5, 1.0], - transform: _SlideGradient(dx), - ).createShader(rect); - }, - child: child, - ); - }, - child: widget.child, - ); - } -} - -/// Helper for translating a gradient horizontally inside its rect. -class _SlideGradient extends GradientTransform { - const _SlideGradient(this.dx); - final double dx; - - @override - Matrix4 transform(Rect bounds, {TextDirection? textDirection}) { - return Matrix4.translationValues(dx, 0, 0); - } -} - -/// Skeleton matched to AlbumCard's 140px outer width / 124px cover / -/// title + artist text rows. Used in horizontal carousels where the -/// real card lives. -class SkeletonAlbumTile extends StatelessWidget { - const SkeletonAlbumTile({super.key, this.width = 140}); - final double width; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final coverSize = width - 16; - return _Shimmer( - child: SizedBox( - width: width, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Container( - width: coverSize, - height: coverSize, - color: fs.slate, - ), - ), - const SizedBox(height: 8), - Container(width: coverSize * 0.8, height: 14, color: fs.slate), - const SizedBox(height: 4), - Container(width: coverSize * 0.6, height: 12, color: fs.slate), - ], - ), - ), - ), - ); - } -} - -/// Skeleton matched to ArtistCard's 140px / 124px round-thumb shape. -class SkeletonArtistTile extends StatelessWidget { - const SkeletonArtistTile({super.key, this.width = 140}); - final double width; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - final coverSize = width - 16; - return _Shimmer( - child: SizedBox( - width: width, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: coverSize, - height: coverSize, - decoration: BoxDecoration( - color: fs.slate, - shape: BoxShape.circle, - ), - ), - const SizedBox(height: 8), - Container(width: coverSize * 0.7, height: 14, color: fs.slate), - ], - ), - ), - ), - ); - } -} - -/// Skeleton matched to TrackRow's vertical-list layout. 56dp cover + -/// title + artist line, similar to the real row. -class SkeletonTrackRow extends StatelessWidget { - const SkeletonTrackRow({super.key}); - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return _Shimmer( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: fs.slate, - borderRadius: BorderRadius.circular(6), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Container(width: 180, height: 14, color: fs.slate), - const SizedBox(height: 6), - Container(width: 120, height: 12, color: fs.slate), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/flutter_client/lib/shared/widgets/track_actions/add_to_playlist_sheet.dart b/flutter_client/lib/shared/widgets/track_actions/add_to_playlist_sheet.dart deleted file mode 100644 index d57c1a7c..00000000 --- a/flutter_client/lib/shared/widgets/track_actions/add_to_playlist_sheet.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../../playlists/playlists_provider.dart'; -import '../../../theme/theme_extension.dart'; - -/// Modal bottom sheet listing the caller's user-created playlists. -/// Returns the picked playlistId via Navigator.pop, or null on cancel. -class AddToPlaylistSheet extends ConsumerWidget { - const AddToPlaylistSheet({super.key}); - - static Future show(BuildContext context) { - return showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (_) => const AddToPlaylistSheet(), - ); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final lists = ref.watch(playlistsListProvider('user')); - return SafeArea( - child: Container( - color: fs.iron, - padding: const EdgeInsets.symmetric(vertical: 8), - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.6, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), - child: Text( - 'Add to playlist', - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 20, - ), - ), - ), - Flexible( - child: lists.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Padding( - padding: const EdgeInsets.all(16), - child: Text('$e', style: TextStyle(color: fs.error)), - ), - data: (data) { - final owned = data.owned - .where((p) => p.systemVariant == null) - .toList(); - if (owned.isEmpty) { - return Padding( - padding: const EdgeInsets.all(20), - child: Text( - "You haven't created any playlists yet.", - style: TextStyle(color: fs.ash), - ), - ); - } - return ListView.builder( - shrinkWrap: true, - itemCount: owned.length, - itemBuilder: (_, i) { - final p = owned[i]; - // Material(transparency) gives ListTile an ink target - // beneath the outer Container's color paint — required - // by the Flutter 3.44 ListTile/ColoredBox assertion. - return Material( - type: MaterialType.transparency, - child: ListTile( - key: Key('add_to_playlist_${p.id}'), - leading: Icon(LucideIcons.list_music, color: fs.parchment), - title: Text( - p.name, - style: TextStyle(color: fs.parchment), - ), - subtitle: Text( - '${p.trackCount} ${p.trackCount == 1 ? "track" : "tracks"}', - style: TextStyle(color: fs.ash, fontSize: 12), - ), - onTap: () => Navigator.pop(context, p.id), - ), - ); - }, - ); - }, - ), - ), - ], - ), - ), - ); - } -} diff --git a/flutter_client/lib/shared/widgets/track_actions/hide_track_sheet.dart b/flutter_client/lib/shared/widgets/track_actions/hide_track_sheet.dart deleted file mode 100644 index 2c3ee4d0..00000000 --- a/flutter_client/lib/shared/widgets/track_actions/hide_track_sheet.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../theme/theme_extension.dart'; - -/// Modal bottom sheet for picking a hide reason + optional notes. -/// Returns ({reason, notes}) on submit, null on cancel. -class HideTrackSheet extends StatefulWidget { - const HideTrackSheet({super.key}); - - static Future<({String reason, String notes})?> show(BuildContext context) { - return showModalBottomSheet<({String reason, String notes})>( - context: context, - isScrollControlled: true, - builder: (_) => const HideTrackSheet(), - ); - } - - @override - State createState() => _HideTrackSheetState(); -} - -class _HideTrackSheetState extends State { - String _reason = 'bad_rip'; - final _notesCtrl = TextEditingController(); - - // Wire values mirror the server vocabulary; display labels mirror the - // existing _QuarantineTile in library_screen.dart. - static const _options = [ - ('bad_rip', 'Bad rip'), - ('wrong_file', 'Wrong file'), - ('wrong_tags', 'Wrong tags'), - ('duplicate', 'Duplicate'), - ('other', 'Other'), - ]; - - @override - void dispose() { - _notesCtrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return SafeArea( - child: Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: Container( - color: fs.iron, - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Hide this track', - style: TextStyle( - color: fs.parchment, - fontFamily: 'Fraunces', - fontSize: 20, - ), - ), - const SizedBox(height: 12), - Text( - 'Pick a reason. Optional notes are visible to admins.', - style: TextStyle(color: fs.ash), - ), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final (value, label) in _options) - ChoiceChip( - key: Key('hide_reason_$value'), - label: Text(label), - selected: _reason == value, - onSelected: (_) => setState(() => _reason = value), - ), - ], - ), - const SizedBox(height: 12), - TextField( - key: const Key('hide_notes_input'), - controller: _notesCtrl, - decoration: const InputDecoration( - labelText: 'Notes (optional)', - ), - maxLines: 2, - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - const SizedBox(width: 8), - ElevatedButton( - key: const Key('hide_confirm'), - style: ElevatedButton.styleFrom( - backgroundColor: fs.oxblood, - foregroundColor: fs.parchment, - ), - onPressed: () => Navigator.pop( - context, - (reason: _reason, notes: _notesCtrl.text.trim()), - ), - child: const Text('Hide'), - ), - ], - ), - ], - ), - ), - ), - ); - } -} diff --git a/flutter_client/lib/shared/widgets/track_actions/track_actions_button.dart b/flutter_client/lib/shared/widgets/track_actions/track_actions_button.dart deleted file mode 100644 index e5c58772..00000000 --- a/flutter_client/lib/shared/widgets/track_actions/track_actions_button.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; - -import '../../../models/track.dart'; -import '../../../theme/theme_extension.dart'; -import 'track_actions_sheet.dart'; - -/// Small 3-dot trigger that opens TrackActionsSheet. Drop into any -/// track row / card to expose the canonical 7-action menu. -class TrackActionsButton extends StatelessWidget { - const TrackActionsButton({ - super.key, - required this.track, - this.hideQueueActions = false, - this.onNavigate, - }); - - final TrackRef track; - - /// Suppresses Play next / Add to queue. Set true on the Now Playing - /// screen where the menu's track IS the currently-playing one. - final bool hideQueueActions; - - /// Forwarded to TrackActionsSheet.onNavigate. The host receives the - /// destination path AFTER the sheet has popped and is responsible - /// for navigating to it (typically: pop self first, then push). - final Future Function(String path)? onNavigate; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return IconButton( - icon: Icon(LucideIcons.ellipsis_vertical, color: fs.ash, size: 18), - tooltip: 'Track actions', - onPressed: () => TrackActionsSheet.show( - context, - track, - hideQueueActions: hideQueueActions, - onNavigate: onNavigate, - ), - ); - } -} diff --git a/flutter_client/lib/shared/widgets/track_actions/track_actions_sheet.dart b/flutter_client/lib/shared/widgets/track_actions/track_actions_sheet.dart deleted file mode 100644 index bf29f1a4..00000000 --- a/flutter_client/lib/shared/widgets/track_actions/track_actions_sheet.dart +++ /dev/null @@ -1,304 +0,0 @@ -import 'package:drift/drift.dart' as drift; -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../../api/endpoints/likes.dart'; -import '../../../cache/audio_cache_manager.dart' show appDbProvider; -import '../../../cache/db.dart'; -import '../../../cache/mutation_queue.dart'; -import '../../../likes/likes_provider.dart'; -import '../../../models/track.dart'; -import '../../../player/player_provider.dart'; -import '../../../playlists/playlists_provider.dart'; -import '../../../quarantine/quarantine_provider.dart'; -import '../../../theme/theme_extension.dart'; -import 'add_to_playlist_sheet.dart'; -import 'hide_track_sheet.dart'; - -/// Modal bottom sheet that lists the 7 canonical track actions. -/// Pops first when an item is tapped (immediate visual feedback) and -/// then runs the action — for actions that open a sub-sheet, the -/// sub-sheet opens after the parent pops. -class TrackActionsSheet extends ConsumerWidget { - const TrackActionsSheet({ - super.key, - required this.track, - required this.hideQueueActions, - this.onNavigate, - }); - - final TrackRef track; - final bool hideQueueActions; - - /// Optional hook for "Go to album" / "Go to artist". Called with - /// the destination route path AFTER the sheet has popped. Lets - /// callers that live OUTSIDE the ShellRoute (e.g. the full player - /// at /now-playing) pop themselves first then push, so go_router - /// doesn't try to mount a second ShellRoute on top of the active - /// top-level route. When null, the sheet does its own - /// context.push, which is correct for surfaces already inside the - /// shell (mini player, track rows on detail screens, etc.). - final Future Function(String path)? onNavigate; - - static Future show( - BuildContext context, - TrackRef track, { - bool hideQueueActions = false, - Future Function(String path)? onNavigate, - }) { - return showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (_) => TrackActionsSheet( - track: track, - hideQueueActions: hideQueueActions, - onNavigate: onNavigate, - ), - ); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final fs = Theme.of(context).extension()!; - final liked = ref.watch(likedIdsProvider).value?.has(LikeKind.track, track.id) ?? false; - final hidden = ref.watch(myQuarantineProvider).value?.any((r) => r.trackId == track.id) ?? false; - return SafeArea( - child: Container( - color: fs.iron, - padding: const EdgeInsets.symmetric(vertical: 8), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (!hideQueueActions) ...[ - _MenuItem( - key: const Key('track_actions_play_next'), - icon: LucideIcons.list_video, - label: 'Play next', - onTap: () async { - Navigator.pop(context); - await ref.read(playerActionsProvider).playNext(track); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Added to queue (next)')), - ); - } - }, - ), - _MenuItem( - key: const Key('track_actions_enqueue'), - icon: LucideIcons.list_music, - label: 'Add to queue', - onTap: () async { - Navigator.pop(context); - await ref.read(playerActionsProvider).enqueue(track); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Added to queue')), - ); - } - }, - ), - const _Divider(), - ], - _MenuItem( - key: const Key('track_actions_like'), - icon: LucideIcons.heart, - label: liked ? 'Unlike' : 'Like', - onTap: () async { - Navigator.pop(context); - await ref.read(likesControllerProvider).toggle(LikeKind.track, track.id); - }, - ), - _MenuItem( - key: const Key('track_actions_add_to_playlist'), - icon: LucideIcons.list_plus, - label: 'Add to playlist…', - onTap: () async { - Navigator.pop(context); - final playlistId = await AddToPlaylistSheet.show(context); - if (playlistId == null || !context.mounted) return; - try { - await ref.read(addToPlaylistActionProvider).call(playlistId, track.id); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Added to playlist')), - ); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Couldn't add to playlist: $e")), - ); - } - } - }, - ), - _MenuItem( - key: const Key('track_actions_start_radio'), - icon: LucideIcons.radio, - label: 'Start radio', - onTap: () async { - Navigator.pop(context); - try { - await ref.read(playerActionsProvider).startRadio(track.id); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Couldn't start radio: $e")), - ); - } - } - }, - ), - const _Divider(), - _MenuItem( - key: const Key('track_actions_go_to_album'), - icon: LucideIcons.disc_3, - label: 'Go to album', - onTap: () { - Navigator.pop(context); - final path = '/albums/${track.albumId}'; - if (onNavigate != null) { - onNavigate!(path); - } else { - context.push(path); - } - }, - ), - _MenuItem( - key: const Key('track_actions_go_to_artist'), - icon: LucideIcons.user, - label: 'Go to artist', - onTap: () { - Navigator.pop(context); - final path = '/artists/${track.artistId}'; - if (onNavigate != null) { - onNavigate!(path); - } else { - context.push(path); - } - }, - ), - const _Divider(), - _MenuItem( - key: const Key('track_actions_hide'), - icon: hidden ? LucideIcons.eye : LucideIcons.eye_off, - label: hidden ? 'Unhide' : 'Hide', - onTap: () async { - Navigator.pop(context); - if (hidden) { - try { - await ref.read(myQuarantineProvider.notifier).unflag(track.id); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Couldn't unhide: $e")), - ); - } - } - return; - } - final result = await HideTrackSheet.show(context); - if (result == null || !context.mounted) return; - try { - await ref - .read(myQuarantineProvider.notifier) - .flag(track, result.reason, result.notes); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Couldn't hide: $e")), - ); - } - } - }, - ), - ], - ), - ), - ); - } -} - -class _MenuItem extends StatelessWidget { - const _MenuItem({ - super.key, - required this.icon, - required this.label, - required this.onTap, - }); - final IconData icon; - final String label; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - // Transparency-typed Material sits between the outer Container's color - // paint and the ListTile so ListTile can paint its own ink splashes. - // Flutter 3.44 promoted the "ListTile inside ColoredBox without Material" - // warning to a hard assertion. - return Material( - type: MaterialType.transparency, - child: ListTile( - leading: Icon(icon, color: fs.parchment), - title: Text(label, style: TextStyle(color: fs.parchment)), - onTap: onTap, - ), - ); - } -} - -class _Divider extends StatelessWidget { - const _Divider(); - @override - Widget build(BuildContext context) { - final fs = Theme.of(context).extension()!; - return Divider(height: 1, color: fs.slate); - } -} - -/// Convenience callable for "append a single track to a playlist." -/// Lives here (not in playlists_provider) because it's purely a -/// menu-flow concern — no other caller. -typedef AddToPlaylistAction = Future Function(String playlistId, String trackId); - -final addToPlaylistActionProvider = Provider((ref) { - return (playlistId, trackId) async { - final db = ref.read(appDbProvider); - // Optimistic drift write so the playlist detail screen shows the - // new track instantly. The position is best-effort — append after - // the current max for this playlist. The server's authoritative - // position lands on next playlistDetailProvider fetch (SWR), or - // when the queued mutation replays. - final maxPos = await (db.selectOnly(db.cachedPlaylistTracks) - ..addColumns([db.cachedPlaylistTracks.position.max()]) - ..where(db.cachedPlaylistTracks.playlistId.equals(playlistId))) - .getSingleOrNull(); - final nextPos = - ((maxPos?.read(db.cachedPlaylistTracks.position.max()) ?? -1) + 1); - await db.into(db.cachedPlaylistTracks).insertOnConflictUpdate( - CachedPlaylistTracksCompanion.insert( - playlistId: playlistId, - trackId: trackId, - position: drift.Value(nextPos), - ), - ); - - try { - final api = await ref.read(playlistsApiProvider.future); - await api.appendTracks(playlistId, [trackId]); - } catch (_) { - // REST failed — keep the optimistic drift row; queue the call. - await ref.read(mutationQueueProvider).enqueue( - MutationKinds.playlistAppend, - { - 'playlistId': playlistId, - 'trackIds': [trackId], - }, - ); - } - }; -}); diff --git a/flutter_client/lib/shared/widgets/version_gate.dart b/flutter_client/lib/shared/widgets/version_gate.dart deleted file mode 100644 index ab57acc6..00000000 --- a/flutter_client/lib/shared/widgets/version_gate.dart +++ /dev/null @@ -1,265 +0,0 @@ -import 'dart:async'; - -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:pub_semver/pub_semver.dart'; - -import '../../api/client.dart'; -import '../../api/endpoints/health.dart'; -import '../../auth/auth_provider.dart'; -import '../../theme/theme_extension.dart'; - -/// Result of the most recent /healthz version compatibility check. -/// -/// `skipped` means the server didn't emit a `min_client_version` field -/// (older servers, partial deploys). Treat as compatible. -enum VersionResult { ok, tooOld, skipped } - -VersionResult _resultFromString(String? s) { - switch (s) { - case 'ok': - return VersionResult.ok; - case 'tooOld': - return VersionResult.tooOld; - default: - return VersionResult.skipped; - } -} - -String _stringFromResult(VersionResult r) { - switch (r) { - case VersionResult.ok: - return 'ok'; - case VersionResult.tooOld: - return 'tooOld'; - case VersionResult.skipped: - return 'skipped'; - } -} - -/// Drives the version-compatibility check against /healthz. Non-blocking: -/// the controller hydrates from a 1h-throttled cache on boot, exposes -/// the current `VersionResult`, and refreshes itself in the background. -/// UI (VersionGate + _VersionTooOldBanner) reads this state to decide -/// whether to surface the "too old" banner — never blocks rendering. -/// -/// Cache keys live in flutter_secure_storage so the cadence survives -/// app restarts. 1h throttle bounds /healthz traffic; explicit -/// `recheck()` (from the banner's "Check now" button or app resume) -/// bypasses the throttle. -class VersionCheckController extends AsyncNotifier { - static const _kResult = 'version_check_result'; - static const _kAtMs = 'version_check_at_ms'; - // 1 minute. The /healthz response is sub-1KB and the call is - // non-blocking, so a tight cadence buys faster recovery from - // server-side min_client_version bumps without measurable cost. The - // gate also acts as a safety net against duplicate calls when the - // periodic timer and a resume event fire close together. - static const _staleMs = 60 * 1000; - - @override - Future build() async { - final storage = ref.read(secureStorageProvider); - final resultStr = await storage.read(key: _kResult); - final atStr = await storage.read(key: _kAtMs); - final cached = _resultFromString(resultStr); - final at = int.tryParse(atStr ?? '') ?? 0; - final nowMs = DateTime.now().millisecondsSinceEpoch; - - // Fire background recheck on cold-start when cache is missing or stale. - // Doesn't block: build returns the cached result immediately; the - // recheck updates state asynchronously when it lands. - if (nowMs - at > _staleMs) { - Future.microtask(_runCheck); - } - return cached; - } - - /// Force a fresh check, bypassing the 1h staleness gate. Used by the - /// "Check now" banner button and (indirectly) by the AppLifecycleState - /// resume observer in VersionGate. - Future recheck() async => _runCheck(); - - /// Recheck only if the cache is older than the 1h throttle. No-op - /// when fresh. Used on app resume to avoid hammering /healthz when - /// the user is briefly switching between apps. - Future recheckIfStale() async { - final storage = ref.read(secureStorageProvider); - final atStr = await storage.read(key: _kAtMs); - final at = int.tryParse(atStr ?? '') ?? 0; - if (DateTime.now().millisecondsSinceEpoch - at > _staleMs) { - await _runCheck(); - } - } - - Future _runCheck() async { - try { - final url = await ref.read(serverUrlProvider.future); - if (url == null || url.isEmpty) return; - // Bounded timeouts for a health probe — the default - // ApiClient.buildDio dio is tuned for actual data fetches - // (8s connect + 30s receive). /healthz should resolve in - // tens of milliseconds; failing fast unblocks slow networks. - final dio = ApiClient.buildDio( - baseUrl: url, - tokenResolver: () async => null, - ); - dio.options.connectTimeout = const Duration(seconds: 3); - dio.options.receiveTimeout = const Duration(seconds: 2); - final body = await HealthApi(dio).check(); - final min = body['min_client_version']; - VersionResult result; - if (min == null || min.isEmpty) { - result = VersionResult.skipped; - } else { - final info = await PackageInfo.fromPlatform(); - final mine = Version.parse(info.version); - final required = Version.parse(min); - result = mine < required ? VersionResult.tooOld : VersionResult.ok; - } - // Persist + flip state. Storage write before state assignment so - // a hot reload immediately after won't see a fresh state with a - // stale cache. - final storage = ref.read(secureStorageProvider); - await storage.write(key: _kResult, value: _stringFromResult(result)); - await storage.write( - key: _kAtMs, - value: DateTime.now().millisecondsSinceEpoch.toString(), - ); - state = AsyncData(result); - } on DioException catch (_) { - // Network error / timeout. Keep the cached state; don't bump the - // timestamp so the next staleness check still triggers a retry. - } catch (_) { - // Other errors (PackageInfo, parsing, etc.). Same handling — - // soft-fail so the UI never sees an error state from a defensive - // background check. - } - } -} - -final versionCheckProvider = - AsyncNotifierProvider( - VersionCheckController.new, -); - -/// Wraps the shell. Non-blocking: always renders the child. Activates -/// the version check controller on mount and reruns it on app resume -/// (gated by the controller's 1h staleness throttle). -class VersionGate extends ConsumerStatefulWidget { - const VersionGate({required this.child, super.key}); - final Widget child; - - @override - ConsumerState createState() => _VersionGateState(); -} - -class _VersionGateState extends ConsumerState - with WidgetsBindingObserver { - // Polling interval during active foreground use. Paired with the - // controller's 1m staleness gate so concurrent fires (timer + resume) - // dedupe to a single network call. Tight cadence is affordable — - // /healthz is sub-1KB and the call is non-blocking. - static const _pollInterval = Duration(minutes: 1); - Timer? _pollTimer; - - @override - void initState() { - super.initState(); - // Read once so the provider's build() runs; the controller - // hydrates from cache + fires a background check when stale. - ref.read(versionCheckProvider); - WidgetsBinding.instance.addObserver(this); - _startPolling(); - } - - @override - void dispose() { - _stopPolling(); - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - - void _startPolling() { - _pollTimer?.cancel(); - _pollTimer = Timer.periodic(_pollInterval, (_) { - // ignore: unawaited_futures - ref.read(versionCheckProvider.notifier).recheckIfStale(); - }); - } - - void _stopPolling() { - _pollTimer?.cancel(); - _pollTimer = null; - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - switch (state) { - case AppLifecycleState.resumed: - // Re-arm the timer + fire one immediate (staleness-gated) check - // so users who foreground after a long away period get a fresh - // result without waiting for the next tick. - // ignore: unawaited_futures - ref.read(versionCheckProvider.notifier).recheckIfStale(); - _startPolling(); - case AppLifecycleState.paused: - case AppLifecycleState.inactive: - case AppLifecycleState.hidden: - case AppLifecycleState.detached: - // Stop firing while backgrounded — no need to burn battery on - // health probes the user can't see the result of. - _stopPolling(); - } - } - - @override - Widget build(BuildContext context) => widget.child; -} - -/// Banner that surfaces when the server reports our client is too old. -/// Non-blocking: the rest of the app keeps working (offline-mode-style -/// — locally cached metadata + audio still play). Tap "Check now" to -/// force a re-check after installing a new build. -class VersionTooOldBanner extends ConsumerWidget { - const VersionTooOldBanner({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final asyncResult = ref.watch(versionCheckProvider); - final result = asyncResult.asData?.value; - if (result != VersionResult.tooOld) return const SizedBox.shrink(); - final fs = Theme.of(context).extension()!; - return SafeArea( - bottom: false, - child: Container( - color: fs.error.withValues(alpha: 0.15), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row(children: [ - Icon(LucideIcons.triangle_alert, color: fs.error, size: 18), - const SizedBox(width: 8), - Expanded( - child: Text( - "This app is older than the server requires. " - "You can keep using cached content; install an update when ready.", - style: TextStyle(color: fs.parchment, fontSize: 13), - ), - ), - TextButton( - onPressed: () => - ref.read(versionCheckProvider.notifier).recheck(), - style: TextButton.styleFrom( - foregroundColor: fs.parchment, - minimumSize: const Size(0, 36), - padding: const EdgeInsets.symmetric(horizontal: 12), - ), - child: const Text('Check now'), - ), - ]), - ), - ); - } -} diff --git a/flutter_client/lib/theme/theme_data.dart b/flutter_client/lib/theme/theme_data.dart deleted file mode 100644 index 506d5809..00000000 --- a/flutter_client/lib/theme/theme_data.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; - -import 'theme_extension.dart'; -import 'tokens.dart'; - -ThemeData buildDarkTheme() { - final fs = FabledSwordTheme.dark(); - final colorScheme = ColorScheme.dark( - surface: fs.iron, - onSurface: fs.parchment, - primary: fs.accent, - onPrimary: fs.parchment, - secondary: fs.moss, - error: fs.error, - ); - return ThemeData( - useMaterial3: true, - brightness: Brightness.dark, - colorScheme: colorScheme, - scaffoldBackgroundColor: fs.obsidian, - textTheme: GoogleFonts.interTextTheme().apply( - bodyColor: fs.parchment, - displayColor: fs.parchment, - ), - fontFamily: FabledSwordFlatTokens.fontBody, - extensions: >[fs], - ); -} - -ThemeData buildLightTheme() { - final fs = FabledSwordTheme.light(); - final colorScheme = ColorScheme.light( - surface: fs.iron, - onSurface: fs.parchment, // semantic — light's "parchment" is dark text - primary: fs.accent, - onPrimary: FabledSwordFlatTokens.onAction, - secondary: fs.moss, - error: fs.error, - ); - return ThemeData( - useMaterial3: true, - brightness: Brightness.light, - colorScheme: colorScheme, - scaffoldBackgroundColor: fs.obsidian, // semantic — light's obsidian is the bg - textTheme: GoogleFonts.interTextTheme().apply( - bodyColor: fs.parchment, - displayColor: fs.parchment, - ), - fontFamily: FabledSwordFlatTokens.fontBody, - extensions: >[fs], - ); -} - -/// Back-compat alias. Existing tests + code call buildThemeData(); it -/// returns the dark theme to preserve current behaviour. New code -/// should prefer buildDarkTheme()/buildLightTheme() explicitly. -ThemeData buildThemeData() => buildDarkTheme(); diff --git a/flutter_client/lib/theme/theme_extension.dart b/flutter_client/lib/theme/theme_extension.dart deleted file mode 100644 index 0f82e168..00000000 --- a/flutter_client/lib/theme/theme_extension.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'tokens.dart'; - -class FabledSwordTheme extends ThemeExtension { - const FabledSwordTheme({ - required this.accent, - required this.obsidian, - required this.iron, - required this.slate, - required this.pewter, - required this.parchment, - required this.vellum, - required this.ash, - required this.moss, - required this.bronze, - required this.oxblood, - required this.warning, - required this.error, - required this.info, - required this.display, - required this.body, - required this.mono, - }); - - final Color accent; - final Color obsidian, iron, slate, pewter; - final Color parchment, vellum, ash; - final Color moss, bronze, oxblood; - final Color warning, error, info; - final TextStyle display, body, mono; - - factory FabledSwordTheme.dark() => const FabledSwordTheme( - accent: FabledSwordFlatTokens.accent, - obsidian: FabledSwordDarkTokens.obsidian, - iron: FabledSwordDarkTokens.iron, - slate: FabledSwordDarkTokens.slate, - pewter: FabledSwordDarkTokens.pewter, - parchment: FabledSwordDarkTokens.parchment, - vellum: FabledSwordDarkTokens.vellum, - ash: FabledSwordDarkTokens.ash, - moss: FabledSwordFlatTokens.moss, - bronze: FabledSwordFlatTokens.bronze, - oxblood: FabledSwordFlatTokens.oxblood, - warning: FabledSwordFlatTokens.warning, - error: FabledSwordFlatTokens.error, - info: FabledSwordFlatTokens.info, - display: TextStyle(fontFamily: FabledSwordFlatTokens.fontDisplay), - body: TextStyle(fontFamily: FabledSwordFlatTokens.fontBody), - mono: TextStyle(fontFamily: FabledSwordFlatTokens.fontMono), - ); - - factory FabledSwordTheme.light() => const FabledSwordTheme( - accent: FabledSwordFlatTokens.accent, - obsidian: FabledSwordLightTokens.obsidian, - iron: FabledSwordLightTokens.iron, - slate: FabledSwordLightTokens.slate, - pewter: FabledSwordLightTokens.pewter, - parchment: FabledSwordLightTokens.parchment, - vellum: FabledSwordLightTokens.vellum, - ash: FabledSwordLightTokens.ash, - moss: FabledSwordFlatTokens.moss, - bronze: FabledSwordFlatTokens.bronze, - oxblood: FabledSwordFlatTokens.oxblood, - warning: FabledSwordFlatTokens.warning, - error: FabledSwordFlatTokens.error, - info: FabledSwordFlatTokens.info, - display: TextStyle(fontFamily: FabledSwordFlatTokens.fontDisplay), - body: TextStyle(fontFamily: FabledSwordFlatTokens.fontBody), - mono: TextStyle(fontFamily: FabledSwordFlatTokens.fontMono), - ); - - /// Back-compat alias for the original API. Returns the dark theme. - /// Existing call sites can keep using fromTokens() until they're - /// migrated to .dark() / .light() explicitly. - static FabledSwordTheme fromTokens() => FabledSwordTheme.dark(); - - @override - FabledSwordTheme copyWith({ - Color? accent, - }) => - FabledSwordTheme( - accent: accent ?? this.accent, - obsidian: obsidian, iron: iron, slate: slate, pewter: pewter, - parchment: parchment, vellum: vellum, ash: ash, - moss: moss, bronze: bronze, oxblood: oxblood, - warning: warning, error: error, info: info, - display: display, body: body, mono: mono, - ); - - @override - FabledSwordTheme lerp(ThemeExtension? other, double t) => this; -} diff --git a/flutter_client/lib/theme/theme_mode_provider.dart b/flutter_client/lib/theme/theme_mode_provider.dart deleted file mode 100644 index 58ae0a07..00000000 --- a/flutter_client/lib/theme/theme_mode_provider.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../auth/auth_provider.dart'; - -const _kThemeModeKey = 'theme_mode'; - -enum AppThemeMode { system, dark, light } - -extension AppThemeModeMaterial on AppThemeMode { - ThemeMode get materialMode => switch (this) { - AppThemeMode.system => ThemeMode.system, - AppThemeMode.dark => ThemeMode.dark, - AppThemeMode.light => ThemeMode.light, - }; -} - -class ThemeModeController extends AsyncNotifier { - @override - Future build() async { - final storage = ref.watch(secureStorageProvider); - final raw = await storage.read(key: _kThemeModeKey); - return switch (raw) { - 'dark' => AppThemeMode.dark, - 'light' => AppThemeMode.light, - _ => AppThemeMode.system, - }; - } - - Future set(AppThemeMode mode) async { - final storage = ref.read(secureStorageProvider); - await storage.write(key: _kThemeModeKey, value: mode.name); - state = AsyncData(mode); - } -} - -final themeModeProvider = - AsyncNotifierProvider( - ThemeModeController.new, -); diff --git a/flutter_client/lib/theme/tokens.dart b/flutter_client/lib/theme/tokens.dart deleted file mode 100644 index e603ad50..00000000 --- a/flutter_client/lib/theme/tokens.dart +++ /dev/null @@ -1,68 +0,0 @@ -// GENERATED — do not edit. Source: shared/fabledsword.tokens.json -// Run `dart run tool/gen_tokens.dart` to regenerate. -import 'package:flutter/material.dart'; - -class FabledSwordDarkTokens { - static const Color obsidian = Color(0xFF14171A); - static const Color iron = Color(0xFF1E2228); - static const Color slate = Color(0xFF2C313A); - static const Color pewter = Color(0xFF3F4651); - static const Color parchment = Color(0xFFE8E4D8); - static const Color vellum = Color(0xFFC2BFB4); - static const Color ash = Color(0xFF9C9A92); -} - -class FabledSwordLightTokens { - static const Color obsidian = Color(0xFFF8F5EE); - static const Color iron = Color(0xFFECE6D5); - static const Color slate = Color(0xFFDCD3BD); - static const Color pewter = Color(0xFFB8AE94); - static const Color parchment = Color(0xFF14171A); - static const Color vellum = Color(0xFF2C313A); - static const Color ash = Color(0xFF5C6068); -} - -class FabledSwordFlatTokens { - static const Color moss = Color(0xFF4A5D3F); - static const Color bronze = Color(0xFF8B7355); - static const Color oxblood = Color(0xFF6B2118); - static const Color warning = Color(0xFF8B6F1E); - static const Color error = Color(0xFFC04A1F); - static const Color info = Color(0xFF3D5A6E); - static const Color accent = Color(0xFF4A6B5C); - static const Color onAction = Color(0xFFE8E4D8); - static const double radiusSm = 4; - static const double radiusMd = 8; - static const double radiusLg = 12; - static const double radiusXl = 16; - static const String fontDisplay = "Fraunces"; - static const String fontBody = "Inter"; - static const String fontMono = "JetBrains Mono"; -} - -/// Back-compat alias — dark surface tokens + flat. Prefer the explicit -/// FabledSwordDarkTokens / FabledSwordLightTokens / FabledSwordFlatTokens. -class FabledSwordTokens { - static const Color obsidian = Color(0xFF14171A); - static const Color iron = Color(0xFF1E2228); - static const Color slate = Color(0xFF2C313A); - static const Color pewter = Color(0xFF3F4651); - static const Color parchment = Color(0xFFE8E4D8); - static const Color vellum = Color(0xFFC2BFB4); - static const Color ash = Color(0xFF9C9A92); - static const Color moss = Color(0xFF4A5D3F); - static const Color bronze = Color(0xFF8B7355); - static const Color oxblood = Color(0xFF6B2118); - static const Color warning = Color(0xFF8B6F1E); - static const Color error = Color(0xFFC04A1F); - static const Color info = Color(0xFF3D5A6E); - static const Color accent = Color(0xFF4A6B5C); - static const Color onAction = Color(0xFFE8E4D8); - static const double radiusSm = 4; - static const double radiusMd = 8; - static const double radiusLg = 12; - static const double radiusXl = 16; - static const String fontDisplay = "Fraunces"; - static const String fontBody = "Inter"; - static const String fontMono = "JetBrains Mono"; -} diff --git a/flutter_client/lib/update/client_update_provider.dart b/flutter_client/lib/update/client_update_provider.dart deleted file mode 100644 index 514cd2d8..00000000 --- a/flutter_client/lib/update/client_update_provider.dart +++ /dev/null @@ -1,143 +0,0 @@ -import 'dart:async'; - -import 'package:dio/dio.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:package_info_plus/package_info_plus.dart'; - -import '../library/library_providers.dart' show dioProvider; -import 'installer.dart'; -import 'update_info.dart'; - -/// Tracks the bundled-server APK version vs. the installed app version. -/// Polls /api/client/version on startup + every 24h. Returns null when -/// no update is available (or the channel is unreachable / disabled). -/// -/// Server returns 404 when no APK is bundled (dev environments, pre-CI -/// images) — we treat that as "no update channel" and stay silent. -class ClientUpdateController extends AsyncNotifier { - static const Duration _pollInterval = Duration(hours: 24); - - Timer? _pollTimer; - - @override - Future build() async { - ref.onDispose(() { - _pollTimer?.cancel(); - _pollTimer = null; - }); - _pollTimer ??= Timer.periodic(_pollInterval, (_) => ref.invalidateSelf()); - return _check(); - } - - Future _check() async { - final dio = await ref.read(dioProvider.future); - final Response> r; - try { - r = await dio.get>('/api/client/version'); - } on DioException catch (e) { - // 404 = no APK bundled; any other error = treat as silent. - if (e.response?.statusCode == 404) return null; - return null; - } - if (r.data == null) return null; - - final info = UpdateInfo.fromJson(r.data!); - final installed = (await PackageInfo.fromPlatform()).version; - if (!isVersionNewer(info.version, installed)) return null; - return info; - } -} - -/// True when `serverVersion` is strictly newer than `installedVersion`. -/// -/// Comparison strategy: split both strings on `.`, parse each component -/// as an int (non-numeric = 0), pad the shorter list with zeros, then -/// compare component-wise. This handles our date-style versions -/// (2026.05.10.1) which exceed the 3-part semver shape that -/// `pub_semver.Version.parse` accepts, and treats "2026.05.10" as -/// equal to "2026.05.10.0" rather than "different = newer". -/// -/// Falls back to pub_semver as a secondary attempt when the components -/// look semver-like (handles pre-release suffixes, build metadata, etc). -/// -/// Exposed for testing; the polling logic in ClientUpdateController -/// is the only production caller. -bool isVersionNewer(String serverVersion, String installedVersion) { - List parts(String v) => v - .replaceFirst(RegExp(r'^v'), '') - .split('.') - .map((p) => int.tryParse(p) ?? 0) - .toList(); - - final svr = parts(serverVersion); - final ins = parts(installedVersion); - - // Safety net for non-numeric versions (e.g. branch-name builds like - // "main" vs "dev"): if neither side parsed any non-zero component, - // fall back to string inequality so an operator on a dev build - // still gets the banner instead of silently matching everything. - final svrAllZero = svr.every((c) => c == 0); - final insAllZero = ins.every((c) => c == 0); - if (svrAllZero && insAllZero) { - return serverVersion.replaceFirst(RegExp(r'^v'), '') != - installedVersion.replaceFirst(RegExp(r'^v'), ''); - } - - final n = svr.length > ins.length ? svr.length : ins.length; - while (svr.length < n) { - svr.add(0); - } - while (ins.length < n) { - ins.add(0); - } - for (var i = 0; i < n; i++) { - if (svr[i] > ins[i]) return true; - if (svr[i] < ins[i]) return false; - } - return false; -} - -final clientUpdateProvider = - AsyncNotifierProvider( - ClientUpdateController.new); - -/// In-memory dismissed-versions set. Keyed by version string so a -/// later release's banner re-appears even if the operator dismissed -/// the previous one. Not persisted — restart re-shows the banner, -/// which is acceptable nudging for v1. -class _DismissedVersionsNotifier extends Notifier> { - @override - Set build() => {}; - - void add(String version) { - state = {...state, version}; - } -} - -final _dismissedVersionsProvider = - NotifierProvider<_DismissedVersionsNotifier, Set>( - _DismissedVersionsNotifier.new); - -/// True when the update banner should render: an UpdateInfo is -/// available AND the operator hasn't dismissed this specific version. -final shouldShowUpdateBannerProvider = Provider((ref) { - final info = ref.watch(clientUpdateProvider).value; - if (info == null) return null; - final dismissed = ref.watch(_dismissedVersionsProvider); - if (dismissed.contains(info.version)) return null; - return info; -}); - -/// Dismiss controller: marks the given version as dismissed so the -/// banner stops showing for this session. -final dismissUpdateProvider = Provider((ref) { - return (version) => - ref.read(_dismissedVersionsProvider.notifier).add(version); -}); - -/// Installer provider — depends on dio so the install download uses -/// the same authenticated client (though /api/client/apk is unauthed, -/// reusing dio keeps configuration consistent). -final updateInstallerProvider = FutureProvider((ref) async { - return UpdateInstaller(await ref.watch(dioProvider.future)); -}); diff --git a/flutter_client/lib/update/installer.dart b/flutter_client/lib/update/installer.dart deleted file mode 100644 index 94cca2ee..00000000 --- a/flutter_client/lib/update/installer.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/services.dart'; -import 'package:path_provider/path_provider.dart'; - -/// Bridges to MainActivity.kt's MethodChannel for the Android -/// PackageInstaller intent (#397). Web / iOS don't support self- -/// install; this class is Android-only at v1. -class UpdateInstaller { - UpdateInstaller(this._dio); - final Dio _dio; - - static const _channel = MethodChannel('com.fabledsword.minstrel/installer'); - static const _filename = 'minstrel-update.apk'; - - /// Streams the APK from `apkUrl` into the cache directory. `onProgress` - /// receives 0..1 fractions; emits 1.0 once when the download completes. - /// Returns the local path the install intent will read from. - Future download( - String apkUrl, { - void Function(double progress)? onProgress, - }) async { - final cacheDir = await getApplicationCacheDirectory(); - final path = '${cacheDir.path}/$_filename'; - await _dio.download( - apkUrl, - path, - onReceiveProgress: (received, total) { - if (total > 0 && onProgress != null) { - onProgress(received / total); - } - }, - ); - return path; - } - - /// Hands the downloaded APK to Android's PackageInstaller via a - /// FileProvider content:// URI. The system shows the install confirm - /// dialog; user must tap Install. App restarts on the new version. - /// - /// First-ever install attempt prompts the user to flip "Install - /// unknown apps" for Minstrel in Settings → Apps → Special access. - /// One-time grant; persists across updates. - Future install(String apkPath) async { - await _channel.invokeMethod('install', {'path': apkPath}); - } -} diff --git a/flutter_client/lib/update/update_banner.dart b/flutter_client/lib/update/update_banner.dart deleted file mode 100644 index 559777a9..00000000 --- a/flutter_client/lib/update/update_banner.dart +++ /dev/null @@ -1,146 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../theme/theme_extension.dart'; -import 'client_update_provider.dart'; -import 'update_info.dart'; - -/// Soft banner mounted at the top of the shell. Renders nothing when -/// no update is available or the user has dismissed this version's -/// banner. Tapping Install downloads the APK and fires the system -/// install intent (Android only). -class UpdateBanner extends ConsumerStatefulWidget { - const UpdateBanner({super.key}); - - @override - ConsumerState createState() => _UpdateBannerState(); -} - -enum _Stage { idle, downloading, error } - -class _UpdateBannerState extends ConsumerState { - _Stage _stage = _Stage.idle; - double _progress = 0; - String? _error; - - Future _onInstall(UpdateInfo info) async { - setState(() { - _stage = _Stage.downloading; - _progress = 0; - _error = null; - }); - try { - final installer = await ref.read(updateInstallerProvider.future); - final path = await installer.download( - info.apkUrl, - onProgress: (p) => setState(() => _progress = p), - ); - await installer.install(path); - // Stage stays 'downloading' — Android system install dialog has - // taken over. If user cancels, the banner is still here. - } catch (e) { - if (!mounted) return; - setState(() { - _stage = _Stage.error; - _error = '$e'; - }); - } - } - - void _onDismiss(UpdateInfo info) { - ref.read(dismissUpdateProvider)(info.version); - } - - @override - Widget build(BuildContext context) { - final info = ref.watch(shouldShowUpdateBannerProvider); - if (info == null) return const SizedBox.shrink(); - final fs = Theme.of(context).extension()!; - - return Material( - color: fs.iron, - child: SafeArea( - bottom: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 4, 4, 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(LucideIcons.download, color: fs.parchment, size: 16), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _stage == _Stage.error - ? 'Update failed' - : 'Update Minstrel · ${info.version} available', - style: TextStyle(color: fs.parchment, fontSize: 12), - overflow: TextOverflow.ellipsis, - ), - if (_stage == _Stage.downloading) ...[ - const SizedBox(height: 2), - LinearProgressIndicator( - value: _progress > 0 ? _progress : null, - minHeight: 2, - backgroundColor: fs.obsidian, - valueColor: AlwaysStoppedAnimation(fs.accent), - ), - ], - if (_stage == _Stage.error && _error != null) ...[ - const SizedBox(height: 1), - Text( - _error!, - style: TextStyle(color: fs.ash, fontSize: 11), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - if (_stage == _Stage.idle) - TextButton( - onPressed: () => _onInstall(info), - style: TextButton.styleFrom( - foregroundColor: fs.accent, - minimumSize: const Size(56, 28), - padding: const EdgeInsets.symmetric(horizontal: 8), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - ), - child: const Text('Install', style: TextStyle(fontSize: 13)), - ), - if (_stage == _Stage.error) - TextButton( - onPressed: () => _onInstall(info), - style: TextButton.styleFrom( - foregroundColor: fs.accent, - minimumSize: const Size(56, 28), - padding: const EdgeInsets.symmetric(horizontal: 8), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - ), - child: const Text('Retry', style: TextStyle(fontSize: 13)), - ), - SizedBox( - width: 32, - height: 32, - child: IconButton( - tooltip: 'Dismiss', - padding: EdgeInsets.zero, - iconSize: 16, - icon: Icon(LucideIcons.x, color: fs.ash), - onPressed: () => _onDismiss(info), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/flutter_client/lib/update/update_info.dart b/flutter_client/lib/update/update_info.dart deleted file mode 100644 index fdd8335a..00000000 --- a/flutter_client/lib/update/update_info.dart +++ /dev/null @@ -1,21 +0,0 @@ -// Wire shape returned by GET /api/client/version. `version` is the -// server-bundled APK version (raw — may have a "v" prefix from the -// git tag); `apkUrl` is server-relative (e.g. "/api/client/apk"). - -class UpdateInfo { - const UpdateInfo({ - required this.version, - required this.apkUrl, - required this.sizeBytes, - }); - - final String version; - final String apkUrl; - final int sizeBytes; - - factory UpdateInfo.fromJson(Map j) => UpdateInfo( - version: (j['version'] as String?) ?? '', - apkUrl: (j['apk_url'] as String?) ?? '/api/client/apk', - sizeBytes: (j['size_bytes'] as num?)?.toInt() ?? 0, - ); -} diff --git a/flutter_client/pubspec.lock b/flutter_client/pubspec.lock deleted file mode 100644 index ee7f11dc..00000000 --- a/flutter_client/pubspec.lock +++ /dev/null @@ -1,1274 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" - url: "https://pub.dev" - source: hosted - version: "93.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b - url: "https://pub.dev" - source: hosted - version: "10.0.1" - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" - async: - dependency: transitive - description: - name: async - sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 - url: "https://pub.dev" - source: hosted - version: "2.13.1" - audio_service: - dependency: "direct main" - description: - name: audio_service - sha256: cb122c7c2639d2a992421ef96b67948ad88c5221da3365ccef1031393a76e044 - url: "https://pub.dev" - source: hosted - version: "0.18.18" - audio_service_platform_interface: - dependency: transitive - description: - name: audio_service_platform_interface - sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777" - url: "https://pub.dev" - source: hosted - version: "0.1.3" - audio_service_web: - dependency: transitive - description: - name: audio_service_web - sha256: b8ea9243201ee53383157fbccf13d5d2a866b5dda922ec19d866d1d5d70424df - url: "https://pub.dev" - source: hosted - version: "0.1.4" - audio_session: - dependency: "direct main" - description: - name: audio_session - sha256: "7217b229db57cc4dc577a8abb56b7429a5a212b978517a5be578704bfe5e568b" - url: "https://pub.dev" - source: hosted - version: "0.2.3" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - build: - dependency: transitive - description: - name: build - sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 - url: "https://pub.dev" - source: hosted - version: "4.0.6" - build_config: - dependency: transitive - description: - name: build_config - sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" - url: "https://pub.dev" - source: hosted - version: "1.3.0" - build_daemon: - dependency: transitive - description: - name: build_daemon - sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 - url: "https://pub.dev" - source: hosted - version: "4.1.1" - build_runner: - dependency: "direct dev" - description: - name: build_runner - sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" - url: "https://pub.dev" - source: hosted - version: "2.15.0" - built_collection: - dependency: transitive - description: - name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" - url: "https://pub.dev" - source: hosted - version: "8.12.6" - cached_network_image: - dependency: "direct main" - description: - name: cached_network_image - sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" - url: "https://pub.dev" - source: hosted - version: "3.4.1" - cached_network_image_platform_interface: - dependency: transitive - description: - name: cached_network_image_platform_interface - sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" - url: "https://pub.dev" - source: hosted - version: "4.1.1" - cached_network_image_web: - dependency: transitive - description: - name: cached_network_image_web - sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" - url: "https://pub.dev" - source: hosted - version: "1.3.1" - characters: - dependency: transitive - description: - name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.dev" - source: hosted - version: "1.4.1" - charcode: - dependency: transitive - description: - name: charcode - sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a - url: "https://pub.dev" - source: hosted - version: "1.4.0" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" - url: "https://pub.dev" - source: hosted - version: "2.0.4" - cli_config: - dependency: transitive - description: - name: cli_config - sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec - url: "https://pub.dev" - source: hosted - version: "0.2.0" - cli_util: - dependency: transitive - description: - name: cli_util - sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c - url: "https://pub.dev" - source: hosted - version: "0.4.2" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - code_assets: - dependency: transitive - description: - name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - connectivity_plus: - dependency: "direct main" - description: - name: connectivity_plus - sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec - url: "https://pub.dev" - source: hosted - version: "6.1.5" - connectivity_plus_platform_interface: - dependency: transitive - description: - name: connectivity_plus_platform_interface - sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - coverage: - dependency: transitive - description: - name: coverage - sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" - url: "https://pub.dev" - source: hosted - version: "1.15.0" - crypto: - dependency: transitive - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" - url: "https://pub.dev" - source: hosted - version: "3.1.7" - dbus: - dependency: transitive - description: - name: dbus - sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 - url: "https://pub.dev" - source: hosted - version: "0.7.12" - dio: - dependency: "direct main" - description: - name: dio - sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c - url: "https://pub.dev" - source: hosted - version: "5.9.2" - dio_web_adapter: - dependency: transitive - description: - name: dio_web_adapter - sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - drift: - dependency: "direct main" - description: - name: drift - sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e" - url: "https://pub.dev" - source: hosted - version: "2.31.0" - drift_dev: - dependency: "direct dev" - description: - name: drift_dev - sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587" - url: "https://pub.dev" - source: hosted - version: "2.31.0" - drift_flutter: - dependency: "direct main" - description: - name: drift_flutter - sha256: c07120854742a0cae2f7501a0da02493addde550db6641d284983c08762e60a7 - url: "https://pub.dev" - source: hosted - version: "0.2.8" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" - source: hosted - version: "1.3.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" - source: hosted - version: "7.0.1" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" - source: hosted - version: "1.1.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_cache_manager: - dependency: "direct main" - description: - name: flutter_cache_manager - sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" - url: "https://pub.dev" - source: hosted - version: "3.4.1" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.dev" - source: hosted - version: "6.0.0" - flutter_lucide: - dependency: "direct main" - description: - name: flutter_lucide - sha256: d2866c9ba75b2300e73a888489f0d7ef1d225e1c352d7e7f5c7fdff7d04e294c - url: "https://pub.dev" - source: hosted - version: "1.11.0" - flutter_riverpod: - dependency: "direct main" - description: - name: flutter_riverpod - sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2" - url: "https://pub.dev" - source: hosted - version: "3.3.1" - flutter_secure_storage: - dependency: "direct main" - description: - name: flutter_secure_storage - sha256: "6848263f9744072d0977347c383fb8b57d9780319a6bf5238b5a2866a029de62" - url: "https://pub.dev" - source: hosted - version: "10.2.0" - flutter_secure_storage_darwin: - dependency: transitive - description: - name: flutter_secure_storage_darwin - sha256: "3af15a3cb2bf5b8b776832bd01776f8018766aece55623176e28b406481fb320" - url: "https://pub.dev" - source: hosted - version: "0.3.0" - flutter_secure_storage_linux: - dependency: transitive - description: - name: flutter_secure_storage_linux - sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - flutter_secure_storage_platform_interface: - dependency: transitive - description: - name: flutter_secure_storage_platform_interface - sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" - url: "https://pub.dev" - source: hosted - version: "2.0.1" - flutter_secure_storage_web: - dependency: transitive - description: - name: flutter_secure_storage_web - sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - flutter_secure_storage_windows: - dependency: transitive - description: - name: flutter_secure_storage_windows - sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" - url: "https://pub.dev" - source: hosted - version: "4.1.0" - flutter_svg: - dependency: "direct main" - description: - name: flutter_svg - sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9" - url: "https://pub.dev" - source: hosted - version: "2.2.4" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_timezone: - dependency: "direct main" - description: - name: flutter_timezone - sha256: "13b2109ad75651faced4831bf262e32559e44aa549426eab8a597610d385d934" - url: "https://pub.dev" - source: hosted - version: "4.1.1" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.dev" - source: hosted - version: "4.0.0" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" - source: hosted - version: "2.1.3" - go_router: - dependency: "direct main" - description: - name: go_router - sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f" - url: "https://pub.dev" - source: hosted - version: "17.2.3" - google_fonts: - dependency: "direct main" - description: - name: google_fonts - sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d" - url: "https://pub.dev" - source: hosted - version: "8.1.0" - graphs: - dependency: transitive - description: - name: graphs - sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - hooks: - dependency: transitive - description: - name: hooks - sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" - url: "https://pub.dev" - source: hosted - version: "1.0.3" - http: - dependency: transitive - description: - name: http - sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.dev" - source: hosted - version: "1.6.0" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.dev" - source: hosted - version: "3.2.2" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - io: - dependency: transitive - description: - name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.dev" - source: hosted - version: "1.0.5" - jni: - dependency: transitive - description: - name: jni - sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f - url: "https://pub.dev" - source: hosted - version: "1.0.0" - jni_flutter: - dependency: transitive - description: - name: jni_flutter - sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 - url: "https://pub.dev" - source: hosted - version: "4.11.0" - just_audio: - dependency: "direct main" - description: - name: just_audio - sha256: "9694e4734f515f2a052493d1d7e0d6de219ee0427c7c29492e246ff32a219908" - url: "https://pub.dev" - source: hosted - version: "0.10.5" - just_audio_platform_interface: - dependency: transitive - description: - name: just_audio_platform_interface - sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a" - url: "https://pub.dev" - source: hosted - version: "4.6.0" - just_audio_web: - dependency: transitive - description: - name: just_audio_web - sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" - url: "https://pub.dev" - source: hosted - version: "0.4.16" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.dev" - source: hosted - version: "11.0.2" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.dev" - source: hosted - version: "3.0.10" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - lints: - dependency: transitive - description: - name: lints - sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" - url: "https://pub.dev" - source: hosted - version: "6.1.0" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 - url: "https://pub.dev" - source: hosted - version: "0.12.19" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.dev" - source: hosted - version: "0.13.0" - meta: - dependency: transitive - description: - name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" - url: "https://pub.dev" - source: hosted - version: "1.17.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - mocktail: - dependency: "direct dev" - description: - name: mocktail - sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa" - url: "https://pub.dev" - source: hosted - version: "1.0.5" - native_toolchain_c: - dependency: transitive - description: - name: native_toolchain_c - sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" - url: "https://pub.dev" - source: hosted - version: "0.17.6" - nm: - dependency: transitive - description: - name: nm - sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" - url: "https://pub.dev" - source: hosted - version: "0.5.0" - node_preamble: - dependency: transitive - description: - name: node_preamble - sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" - url: "https://pub.dev" - source: hosted - version: "2.0.2" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" - url: "https://pub.dev" - source: hosted - version: "9.3.0" - octo_image: - dependency: transitive - description: - name: octo_image - sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - package_config: - dependency: transitive - description: - name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.dev" - source: hosted - version: "2.2.0" - package_info_plus: - dependency: "direct main" - description: - name: package_info_plus - sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" - url: "https://pub.dev" - source: hosted - version: "8.3.1" - package_info_plus_platform_interface: - dependency: transitive - description: - name: package_info_plus_platform_interface - sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" - url: "https://pub.dev" - source: hosted - version: "3.2.1" - palette_generator: - dependency: "direct main" - description: - name: palette_generator - sha256: "4420f7ccc3f0a4a906144e73f8b6267cd940b64f57a7262e95cb8cec3a8ae0ed" - url: "https://pub.dev" - source: hosted - version: "0.3.3+7" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - path_parsing: - dependency: transitive - description: - name: path_parsing - sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - path_provider: - dependency: "direct main" - description: - name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" - url: "https://pub.dev" - source: hosted - version: "2.1.5" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" - url: "https://pub.dev" - source: hosted - version: "2.3.1" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" - url: "https://pub.dev" - source: hosted - version: "2.6.0" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.dev" - source: hosted - version: "2.2.1" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - permission_handler: - dependency: "direct main" - description: - name: permission_handler - sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 - url: "https://pub.dev" - source: hosted - version: "12.0.1" - permission_handler_android: - dependency: transitive - description: - name: permission_handler_android - sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" - url: "https://pub.dev" - source: hosted - version: "13.0.1" - permission_handler_apple: - dependency: transitive - description: - name: permission_handler_apple - sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 - url: "https://pub.dev" - source: hosted - version: "9.4.7" - permission_handler_html: - dependency: transitive - description: - name: permission_handler_html - sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" - url: "https://pub.dev" - source: hosted - version: "0.1.3+5" - permission_handler_platform_interface: - dependency: transitive - description: - name: permission_handler_platform_interface - sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 - url: "https://pub.dev" - source: hosted - version: "4.3.0" - permission_handler_windows: - dependency: transitive - description: - name: permission_handler_windows - sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" - url: "https://pub.dev" - source: hosted - version: "0.2.1" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" - url: "https://pub.dev" - source: hosted - version: "7.0.2" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - pool: - dependency: transitive - description: - name: pool - sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" - url: "https://pub.dev" - source: hosted - version: "1.5.2" - pub_semver: - dependency: "direct main" - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - recase: - dependency: transitive - description: - name: recase - sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 - url: "https://pub.dev" - source: hosted - version: "4.1.0" - record_use: - dependency: transitive - description: - name: record_use - sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" - url: "https://pub.dev" - source: hosted - version: "0.6.0" - riverpod: - dependency: transitive - description: - name: riverpod - sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83" - url: "https://pub.dev" - source: hosted - version: "3.2.1" - rxdart: - dependency: transitive - description: - name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" - url: "https://pub.dev" - source: hosted - version: "0.28.0" - shelf: - dependency: transitive - description: - name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.dev" - source: hosted - version: "1.4.2" - shelf_packages_handler: - dependency: transitive - description: - name: shelf_packages_handler - sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - shelf_static: - dependency: transitive - description: - name: shelf_static - sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 - url: "https://pub.dev" - source: hosted - version: "1.1.3" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" - url: "https://pub.dev" - source: hosted - version: "3.0.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 - url: "https://pub.dev" - source: hosted - version: "4.2.3" - source_map_stack_trace: - dependency: transitive - description: - name: source_map_stack_trace - sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b - url: "https://pub.dev" - source: hosted - version: "2.1.2" - source_maps: - dependency: transitive - description: - name: source_maps - sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" - url: "https://pub.dev" - source: hosted - version: "0.10.13" - source_span: - dependency: transitive - description: - name: source_span - sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.dev" - source: hosted - version: "1.10.2" - sqflite: - dependency: transitive - description: - name: sqflite - sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a" - url: "https://pub.dev" - source: hosted - version: "2.4.2+1" - sqflite_android: - dependency: transitive - description: - name: sqflite_android - sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" - url: "https://pub.dev" - source: hosted - version: "2.4.2+3" - sqflite_common: - dependency: transitive - description: - name: sqflite_common - sha256: "5e8377564d95166761a968ed96104e0569b6b6cc611faac92a36ab8a169112c3" - url: "https://pub.dev" - source: hosted - version: "2.5.6+1" - sqflite_darwin: - dependency: transitive - description: - name: sqflite_darwin - sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" - url: "https://pub.dev" - source: hosted - version: "2.4.2" - sqflite_platform_interface: - dependency: transitive - description: - name: sqflite_platform_interface - sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - sqlite3: - dependency: transitive - description: - name: sqlite3 - sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" - url: "https://pub.dev" - source: hosted - version: "2.9.4" - sqlite3_flutter_libs: - dependency: "direct main" - description: - name: sqlite3_flutter_libs - sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad - url: "https://pub.dev" - source: hosted - version: "0.5.42" - sqlparser: - dependency: transitive - description: - name: sqlparser - sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19" - url: "https://pub.dev" - source: hosted - version: "0.43.1" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - state_notifier: - dependency: transitive - description: - name: state_notifier - sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb - url: "https://pub.dev" - source: hosted - version: "1.0.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - synchronized: - dependency: transitive - description: - name: synchronized - sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" - url: "https://pub.dev" - source: hosted - version: "3.4.0+1" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test: - dependency: transitive - description: - name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" - url: "https://pub.dev" - source: hosted - version: "1.30.0" - test_api: - dependency: transitive - description: - name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" - url: "https://pub.dev" - source: hosted - version: "0.7.10" - test_core: - dependency: transitive - description: - name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" - url: "https://pub.dev" - source: hosted - version: "0.6.16" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - uuid: - dependency: transitive - description: - name: uuid - sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" - url: "https://pub.dev" - source: hosted - version: "4.5.3" - vector_graphics: - dependency: transitive - description: - name: vector_graphics - sha256: "6409a25046024f0f8c5d8a59fec314081e81f9d436b66ca4015a8b49772bf445" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - vector_graphics_codec: - dependency: transitive - description: - name: vector_graphics_codec - sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" - url: "https://pub.dev" - source: hosted - version: "1.1.13" - vector_graphics_compiler: - dependency: transitive - description: - name: vector_graphics_compiler - sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.dev" - source: hosted - version: "2.2.0" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" - url: "https://pub.dev" - source: hosted - version: "15.2.0" - watcher: - dependency: transitive - description: - name: watcher - sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" - url: "https://pub.dev" - source: hosted - version: "1.2.1" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" - source: hosted - version: "3.0.3" - webkit_inspection_protocol: - dependency: transitive - description: - name: webkit_inspection_protocol - sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" - url: "https://pub.dev" - source: hosted - version: "1.2.1" - win32: - dependency: transitive - description: - name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e - url: "https://pub.dev" - source: hosted - version: "5.15.0" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" - xml: - dependency: transitive - description: - name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" - url: "https://pub.dev" - source: hosted - version: "6.6.1" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" - source: hosted - version: "3.1.3" -sdks: - dart: ">=3.11.0 <4.0.0" - flutter: ">=3.38.4" diff --git a/flutter_client/pubspec.yaml b/flutter_client/pubspec.yaml deleted file mode 100644 index 770406d4..00000000 --- a/flutter_client/pubspec.yaml +++ /dev/null @@ -1,70 +0,0 @@ -name: minstrel -description: Minstrel mobile client -publish_to: 'none' -version: 2026.5.21+14 - -environment: - sdk: '>=3.5.0 <4.0.0' - flutter: '>=3.24.0' - -dependencies: - flutter: - sdk: flutter - flutter_riverpod: ^3.3.1 - dio: ^5.7.0 - just_audio: ^0.10.5 - audio_service: ^0.18.15 - # Audio focus + interruptions + becoming-noisy. just_audio auto-manages - # session activation once configured; the app must still handle - # interruption/becoming-noisy itself (just_audio does not). Same author - # as just_audio/audio_service so versions track together. - audio_session: ^0.2.3 - flutter_secure_storage: ^10.2.0 - go_router: ^17.2.3 - flutter_svg: ^2.0.16 - # Lucide icon set (design system mandates Lucide, not Material). - # Icons exposed as LucideIcons. IconData usable in Icon(). - flutter_lucide: ^1.11.0 - # Runtime POST_NOTIFICATIONS request (Android 13+ denies-by-default - # until asked; the media notification is suppressed without it). - permission_handler: ^12.0.1 - google_fonts: ^8.1.0 - # 10.x conflicts with flutter_secure_storage 10.x on win32. Hold at 8.3.1 - # until either lib bumps win32 to 6.x. - package_info_plus: ^8.3.1 - pub_semver: ^2.1.4 - path_provider: ^2.1.5 - drift: ^2.18.0 - drift_flutter: ^0.2.0 - sqlite3_flutter_libs: ^0.5.24 - connectivity_plus: ^6.0.5 - flutter_timezone: ^4.1.1 - palette_generator: ^0.3.3 - # Disk-persistent image cache. Image.network only caches in memory, so - # cover art repainted after a scroll-off or app restart re-fetches from - # the server. cached_network_image stores the bytes under - # path_provider's temp dir keyed by URL, surviving both. Used directly - # by ServerImage (auth-aware path) and as CachedNetworkImageProvider - # for the mini bar (which composes its own Image widget). - cached_network_image: ^3.4.1 - # flutter_cache_manager is the disk-cache layer cached_network_image - # sits on top of. SyncController uses DefaultCacheManager directly to - # pre-warm covers during metadata sync, so a cold-start home grid - # paints from disk on the very first scroll rather than firing a - # network round-trip per tile. - flutter_cache_manager: ^3.4.1 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^6.0.0 - mocktail: ^1.0.5 - drift_dev: ^2.18.0 - build_runner: ^2.4.13 - -flutter: - uses-material-design: true - assets: - - assets/svg/ - - assets/error-copy.json - - shared/fabledsword.tokens.json diff --git a/flutter_client/test/admin/admin_landing_screen_test.dart b/flutter_client/test/admin/admin_landing_screen_test.dart deleted file mode 100644 index 95eca570..00000000 --- a/flutter_client/test/admin/admin_landing_screen_test.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/admin/admin_landing_screen.dart'; -import 'package:minstrel/admin/admin_providers.dart'; -import 'package:minstrel/auth/auth_provider.dart'; -import 'package:minstrel/models/user.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -class _StubAuth extends AuthController { - @override - Future build() async => - const User(id: 'u1', username: 'admin', isAdmin: true); -} - -void main() { - testWidgets('renders three section cards with counts from provider', - (t) async { - await t.pumpWidget( - ProviderScope( - overrides: [ - authControllerProvider.overrideWith(() => _StubAuth()), - adminCountsProvider.overrideWith( - (_) async => const AdminCounts( - requests: 3, - quarantine: 1, - users: 5, - ), - ), - ], - child: MaterialApp( - theme: buildThemeData(), - home: const AdminLandingScreen(), - ), - ), - ); - await t.pumpAndSettle(); - expect(find.byKey(const Key('admin_card_requests')), findsOneWidget); - expect(find.byKey(const Key('admin_card_quarantine')), findsOneWidget); - expect(find.byKey(const Key('admin_card_users')), findsOneWidget); - expect(find.text('3'), findsOneWidget); - expect(find.text('1'), findsOneWidget); - expect(find.text('5'), findsOneWidget); - }); -} diff --git a/flutter_client/test/admin/admin_quarantine_screen_test.dart b/flutter_client/test/admin/admin_quarantine_screen_test.dart deleted file mode 100644 index 2ec85b9e..00000000 --- a/flutter_client/test/admin/admin_quarantine_screen_test.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/admin/admin_providers.dart'; -import 'package:minstrel/admin/admin_quarantine_screen.dart'; -import 'package:minstrel/auth/auth_provider.dart'; -import 'package:minstrel/models/admin_quarantine_item.dart'; -import 'package:minstrel/models/user.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -class _StubAuth extends AuthController { - @override - Future build() async => - const User(id: 'u1', username: 'admin', isAdmin: true); -} - -class _StubQuarantine extends AdminQuarantineController { - _StubQuarantine(this._initial); - final List _initial; - @override - Future> build() async => _initial; -} - -const _item = AdminQuarantineItem( - trackId: 't1', - trackTitle: 'Bad Track', - artistName: 'Some Artist', - albumTitle: 'Some Album', - albumId: 'al1', - lidarrAlbumMbid: null, - reportCount: 2, - latestAt: '2026-05-08T00:00:00Z', - reasonCounts: {'wrong_tags': 1, 'bad_rip': 1}, - reports: [ - AdminQuarantineReport( - userId: 'u2', - username: 'alice', - reason: 'wrong_tags', - notes: null, - createdAt: '2026-05-08T00:00:00Z', - ), - AdminQuarantineReport( - userId: 'u3', - username: 'bob', - reason: 'bad_rip', - notes: 'cracks at 2:13', - createdAt: '2026-05-08T00:01:00Z', - ), - ], -); - -Widget _harness(List rows) => ProviderScope( - overrides: [ - authControllerProvider.overrideWith(() => _StubAuth()), - adminQuarantineProvider.overrideWith(() => _StubQuarantine(rows)), - ], - child: MaterialApp( - theme: buildThemeData(), - home: const AdminQuarantineScreen(), - ), - ); - -void main() { - testWidgets('empty state', (t) async { - await t.pumpWidget(_harness(const [])); - await t.pumpAndSettle(); - expect(find.text('No quarantined tracks.'), findsOneWidget); - }); - - testWidgets('row renders aggregate summary + 3-dot menu has three actions', - (t) async { - await t.pumpWidget(_harness(const [_item])); - await t.pumpAndSettle(); - expect(find.byKey(const Key('admin_quarantine_tile_t1')), findsOneWidget); - expect(find.text('Bad Track'), findsOneWidget); - expect(find.textContaining('2 reports'), findsOneWidget); - await t.tap(find.byKey(const Key('admin_quarantine_menu_t1'))); - await t.pumpAndSettle(); - expect(find.text('Resolve'), findsOneWidget); - expect(find.text('Delete file'), findsOneWidget); - expect(find.text('Delete via Lidarr'), findsOneWidget); - }); - - testWidgets('expanding tile reveals per-user reports', (t) async { - await t.pumpWidget(_harness(const [_item])); - await t.pumpAndSettle(); - await t.tap(find.byKey(const Key('admin_quarantine_tile_t1'))); - await t.pumpAndSettle(); - expect(find.textContaining('alice — wrong_tags'), findsOneWidget); - expect(find.textContaining('bob — bad_rip'), findsOneWidget); - expect(find.text('cracks at 2:13'), findsOneWidget); - }); -} diff --git a/flutter_client/test/admin/admin_requests_screen_test.dart b/flutter_client/test/admin/admin_requests_screen_test.dart deleted file mode 100644 index b57031e8..00000000 --- a/flutter_client/test/admin/admin_requests_screen_test.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/admin/admin_providers.dart'; -import 'package:minstrel/admin/admin_requests_screen.dart'; -import 'package:minstrel/auth/auth_provider.dart'; -import 'package:minstrel/models/admin_request.dart'; -import 'package:minstrel/models/admin_user.dart'; -import 'package:minstrel/models/user.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -class _StubAuth extends AuthController { - @override - Future build() async => - const User(id: 'u1', username: 'admin', isAdmin: true); -} - -class _StubRequests extends AdminRequestsController { - _StubRequests(this._initial); - final List _initial; - @override - Future> build() async => _initial; -} - -class _StubUsers extends AdminUsersController { - _StubUsers(this._initial); - final List _initial; - @override - Future> build() async => _initial; -} - -const _row = AdminRequest( - id: 'r1', - userId: 'user-uuid-1', - status: 'pending', - kind: 'album', - artistName: 'Some Artist', - albumTitle: 'Test Album', - trackTitle: null, - requestedAt: '2026-05-08T00:00:00Z', - decidedAt: null, - notes: null, - importedAlbumCount: 0, - importedTrackCount: 0, -); - -const _alice = AdminUser( - id: 'user-uuid-1', - username: 'alice', - displayName: null, - isAdmin: false, - autoApproveRequests: false, - createdAt: '2026-05-01T00:00:00Z', -); - -Widget _harness({ - required List requests, - required List users, -}) => - ProviderScope( - overrides: [ - authControllerProvider.overrideWith(() => _StubAuth()), - adminRequestsProvider.overrideWith(() => _StubRequests(requests)), - adminUsersProvider.overrideWith(() => _StubUsers(users)), - ], - child: MaterialApp( - theme: buildThemeData(), - home: const AdminRequestsScreen(), - ), - ); - -void main() { - testWidgets('renders empty state when no requests', (t) async { - await t.pumpWidget(_harness(requests: const [], users: const [])); - await t.pumpAndSettle(); - expect(find.text('No pending requests.'), findsOneWidget); - }); - - testWidgets('renders row with display name + joined requester username', - (t) async { - await t.pumpWidget(_harness(requests: const [_row], users: const [_alice])); - await t.pumpAndSettle(); - expect(find.byKey(const Key('admin_request_row_r1')), findsOneWidget); - expect(find.text('Test Album'), findsOneWidget); - expect(find.textContaining('requested by alice'), findsOneWidget); - }); - - testWidgets('falls back to uuid prefix when username unknown', (t) async { - await t.pumpWidget(_harness(requests: const [_row], users: const [])); - await t.pumpAndSettle(); - expect(find.textContaining('requested by user-uui'), findsOneWidget); - }); -} diff --git a/flutter_client/test/admin/admin_users_screen_test.dart b/flutter_client/test/admin/admin_users_screen_test.dart deleted file mode 100644 index ff296b05..00000000 --- a/flutter_client/test/admin/admin_users_screen_test.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/admin/admin_providers.dart'; -import 'package:minstrel/admin/admin_users_screen.dart'; -import 'package:minstrel/auth/auth_provider.dart'; -import 'package:minstrel/models/admin_user.dart'; -import 'package:minstrel/models/invite.dart'; -import 'package:minstrel/models/user.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -class _StubAuth extends AuthController { - @override - Future build() async => - const User(id: 'u1', username: 'admin', isAdmin: true); -} - -class _StubUsers extends AdminUsersController { - _StubUsers(this._initial); - final List _initial; - @override - Future> build() async => _initial; -} - -class _StubInvites extends AdminInvitesController { - _StubInvites(this._initial); - final List _initial; - @override - Future> build() async => _initial; -} - -const _userRow = AdminUser( - id: 'u2', - username: 'alice', - displayName: null, - isAdmin: false, - autoApproveRequests: true, - createdAt: '2026-05-01T00:00:00Z', -); - -const _inviteRow = Invite( - token: 'INV-ABC123', - invitedBy: 'u1', - note: 'for alice', - createdAt: '2026-05-08T00:00:00Z', - expiresAt: '2026-05-09T00:00:00Z', -); - -Widget _harness({ - required List users, - required List invites, -}) => - ProviderScope( - overrides: [ - authControllerProvider.overrideWith(() => _StubAuth()), - adminUsersProvider.overrideWith(() => _StubUsers(users)), - adminInvitesProvider.overrideWith(() => _StubInvites(invites)), - ], - child: MaterialApp( - theme: buildThemeData(), - home: const AdminUsersScreen(), - ), - ); - -void main() { - testWidgets('renders user rows with badges', (t) async { - await t.pumpWidget(_harness(users: const [_userRow], invites: const [])); - await t.pumpAndSettle(); - expect(find.byKey(const Key('admin_user_row_u2')), findsOneWidget); - expect(find.text('alice'), findsOneWidget); - expect(find.text('auto-approve'), findsOneWidget); - }); - - testWidgets('renders invite rows + Generate button', (t) async { - await t.pumpWidget(_harness(users: const [], invites: const [_inviteRow])); - await t.pumpAndSettle(); - expect(find.byKey(const Key('invite_row_INV-ABC123')), findsOneWidget); - expect(find.byKey(const Key('invite_generate_button')), findsOneWidget); - expect(find.textContaining('for alice'), findsOneWidget); - }); -} diff --git a/flutter_client/test/api/client_test.dart b/flutter_client/test/api/client_test.dart deleted file mode 100644 index 6e738df4..00000000 --- a/flutter_client/test/api/client_test.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/api/client.dart'; - -void main() { - test('client attaches Bearer token from token resolver', () async { - final d = ApiClient.buildDio( - baseUrl: 'http://example/', - tokenResolver: () async => 'tok-123', - ); - Map? captured; - d.interceptors.add( - InterceptorsWrapper(onRequest: (opts, h) { - captured = Map.from(opts.headers); - h.reject(DioException(requestOptions: opts, type: DioExceptionType.cancel)); - }), - ); - try { - await d.get('/whatever'); - } catch (_) {} - expect(captured?['Authorization'], 'Bearer tok-123'); - }); - - test('client omits Authorization when resolver returns null', () async { - final d = ApiClient.buildDio( - baseUrl: 'http://example/', - tokenResolver: () async => null, - ); - Map? captured; - d.interceptors.add( - InterceptorsWrapper(onRequest: (opts, h) { - captured = Map.from(opts.headers); - h.reject(DioException(requestOptions: opts, type: DioExceptionType.cancel)); - }), - ); - try { - await d.get('/whatever'); - } catch (_) {} - expect(captured?.containsKey('Authorization'), isFalse); - }); -} diff --git a/flutter_client/test/api/endpoints/library_test.dart b/flutter_client/test/api/endpoints/library_test.dart deleted file mode 100644 index e1d36831..00000000 --- a/flutter_client/test/api/endpoints/library_test.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/api/endpoints/library.dart'; - -/// Stub adapter that returns a fixed JSON response for any request. The -/// body argument can be either a Map or a List (the artist-tracks endpoint -/// emits a top-level JSON array). Per-test wiring keeps the routing trivial. -class _StubAdapter implements HttpClientAdapter { - _StubAdapter(this._body); - final Object _body; - - @override - Future fetch( - RequestOptions options, - Stream? requestStream, - Future? cancelFuture, - ) async { - return ResponseBody.fromString( - jsonEncode(_body), - 200, - headers: const { - Headers.contentTypeHeader: ['application/json'], - }, - ); - } - - @override - void close({bool force = false}) {} -} - -Dio _dioWith(Object body) { - final d = Dio(BaseOptions(baseUrl: 'http://example.test')); - d.httpClientAdapter = _StubAdapter(body); - return d; -} - -void main() { - group('LibraryApi', () { - test('getAlbum parses album fields and tracks list', () async { - final api = LibraryApi(_dioWith({ - 'id': 'al-1', - 'title': 'Geogaddi', - 'sort_title': 'geogaddi', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - 'year': 2002, - 'track_count': 1, - 'duration_sec': 312, - 'cover_url': '/api/albums/al-1/cover', - 'tracks': [ - { - 'id': 't-1', - 'title': 'Music Is Math', - 'album_id': 'al-1', - 'album_title': 'Geogaddi', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - 'duration_sec': 312, - 'track_number': 4, - 'stream_url': '/api/tracks/t-1/stream', - }, - ], - })); - final r = await api.getAlbum('al-1'); - expect(r.album.id, 'al-1'); - expect(r.album.title, 'Geogaddi'); - expect(r.album.artistName, 'Boards of Canada'); - expect(r.album.year, 2002); - expect(r.tracks, hasLength(1)); - expect(r.tracks.single.title, 'Music Is Math'); - expect(r.tracks.single.durationSec, 312); - expect(r.tracks.single.trackNumber, 4); - }); - - test('getArtist parses ArtistDetail body as ArtistRef', () async { - final api = LibraryApi(_dioWith({ - 'id': 'art-1', - 'name': 'Boards of Canada', - 'sort_name': 'boards of canada', - 'album_count': 5, - 'cover_url': '/api/albums/al-1/cover', - 'albums': const [], - })); - final a = await api.getArtist('art-1'); - expect(a.id, 'art-1'); - expect(a.name, 'Boards of Canada'); - expect(a.albumCount, 5); - }); - - test('getArtistAlbums extracts the albums array', () async { - final api = LibraryApi(_dioWith({ - 'id': 'art-1', - 'name': 'Boards of Canada', - 'albums': [ - { - 'id': 'al-1', - 'title': 'Geogaddi', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - }, - { - 'id': 'al-2', - 'title': 'The Campfire Headphase', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - }, - ], - })); - final albums = await api.getArtistAlbums('art-1'); - expect(albums, hasLength(2)); - expect(albums.first.id, 'al-1'); - expect(albums.last.title, 'The Campfire Headphase'); - }); - - test('getArtistTracks parses a top-level JSON array', () async { - // Server emits a bare []TrackRef, NOT {"tracks": [...]}. - final api = LibraryApi(_dioWith(>[ - { - 'id': 't-1', - 'title': 'Music Is Math', - 'album_id': 'al-1', - 'album_title': 'Geogaddi', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - 'duration_sec': 312, - }, - { - 'id': 't-2', - 'title': 'Dawn Chorus', - 'album_id': 'al-1', - 'album_title': 'Geogaddi', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - 'duration_sec': 168, - }, - ])); - final tracks = await api.getArtistTracks('art-1'); - expect(tracks, hasLength(2)); - expect(tracks.first.id, 't-1'); - expect(tracks.last.durationSec, 168); - }); - - test('getHome parses HomePayload sections', () async { - final api = LibraryApi(_dioWith({ - 'recently_added_albums': [ - { - 'id': 'al-1', - 'title': 'Geogaddi', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - }, - ], - 'rediscover_albums': const [], - 'rediscover_artists': const [], - 'most_played_tracks': const [], - 'last_played_artists': [ - {'id': 'art-1', 'name': 'Boards of Canada'}, - ], - })); - final home = await api.getHome(); - expect(home.recentlyAddedAlbums.single.title, 'Geogaddi'); - expect(home.lastPlayedArtists.single.name, 'Boards of Canada'); - expect(home.rediscoverAlbums, isEmpty); - }); - }); -} diff --git a/flutter_client/test/api/errors_test.dart b/flutter_client/test/api/errors_test.dart deleted file mode 100644 index 605802d8..00000000 --- a/flutter_client/test/api/errors_test.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/api/errors.dart'; - -void main() { - RequestOptions opts() => RequestOptions(path: '/x'); - - group('ApiError.fromDio', () { - test('flat envelope: {error: "code"}', () { - final d = DioException( - requestOptions: opts(), - response: Response( - requestOptions: opts(), - statusCode: 503, - data: {'error': 'lidarr_unreachable'}, - ), - ); - final e = ApiError.fromDio(d); - expect(e.code, 'lidarr_unreachable'); - expect(e.status, 503); - }); - - test('nested envelope: {error: {code, message}}', () { - final d = DioException( - requestOptions: opts(), - response: Response( - requestOptions: opts(), - statusCode: 400, - data: {'error': {'code': 'bad_request', 'message': 'invalid JSON body'}}, - ), - ); - final e = ApiError.fromDio(d); - expect(e.code, 'bad_request'); - expect(e.message, 'invalid JSON body'); - }); - - test('connection refused: code is connection_refused', () { - final d = DioException( - requestOptions: opts(), - type: DioExceptionType.connectionError, - error: 'Connection refused', - ); - final e = ApiError.fromDio(d); - expect(e.code, 'connection_refused'); - }); - - test('401 with no envelope falls back to unauthenticated', () { - final d = DioException( - requestOptions: opts(), - response: Response(requestOptions: opts(), statusCode: 401), - ); - final e = ApiError.fromDio(d); - expect(e.code, 'unauthenticated'); - }); - }); -} diff --git a/flutter_client/test/auth/auth_provider_test.dart b/flutter_client/test/auth/auth_provider_test.dart deleted file mode 100644 index 1d899441..00000000 --- a/flutter_client/test/auth/auth_provider_test.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mocktail/mocktail.dart'; - -import 'package:minstrel/auth/auth_provider.dart'; - -class _MockStorage extends Mock implements FlutterSecureStorage {} - -void main() { - late _MockStorage storage; - - setUpAll(() { - registerFallbackValue(''); - }); - - setUp(() { - storage = _MockStorage(); - // Default reads return null; default writes/deletes succeed. - when(() => storage.read(key: any(named: 'key'))).thenAnswer((_) async => null); - when(() => storage.write(key: any(named: 'key'), value: any(named: 'value'))).thenAnswer((_) async {}); - when(() => storage.delete(key: any(named: 'key'))).thenAnswer((_) async {}); - }); - - test('saves server URL via setServerUrl', () async { - final container = ProviderContainer(overrides: [ - secureStorageProvider.overrideWithValue(storage), - ]); - addTearDown(container.dispose); - - await container.read(authControllerProvider.notifier).setServerUrl('http://localhost:8080'); - - verify(() => storage.write(key: 'server_url', value: 'http://localhost:8080')).called(1); - }); - - test('setSession then clearSession transitions auth state to null', () async { - final container = ProviderContainer(overrides: [ - secureStorageProvider.overrideWithValue(storage), - ]); - addTearDown(container.dispose); - - final ctrl = container.read(authControllerProvider.notifier); - await ctrl.setSession(token: 't', userJson: '{"id":"u","username":"u","is_admin":false}'); - expect((await container.read(authControllerProvider.future))?.username, 'u'); - - // clearSession must trigger storage deletes for both keys. - await ctrl.clearSession(); - verify(() => storage.delete(key: 'session_token')).called(1); - verify(() => storage.delete(key: 'current_user')).called(1); - expect(await container.read(authControllerProvider.future), isNull); - }); -} diff --git a/flutter_client/test/auth/login_screen_test.dart b/flutter_client/test/auth/login_screen_test.dart deleted file mode 100644 index e3d8c7f3..00000000 --- a/flutter_client/test/auth/login_screen_test.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/auth/login_screen.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -void main() { - testWidgets('login screen renders both fields and the submit button', (tester) async { - await tester.pumpWidget(ProviderScope( - child: MaterialApp( - theme: buildThemeData(), - home: const LoginScreen(), - ), - )); - expect(find.byType(TextField), findsNWidgets(2)); - expect(find.text('Sign in'), findsNWidgets(2)); // header + button - }); -} diff --git a/flutter_client/test/cache/adapters_test.dart b/flutter_client/test/cache/adapters_test.dart deleted file mode 100644 index a17f7354..00000000 --- a/flutter_client/test/cache/adapters_test.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/cache/adapters.dart'; -import 'package:minstrel/models/album.dart'; -import 'package:minstrel/models/artist.dart'; -import 'package:minstrel/models/playlist.dart'; -import 'package:minstrel/models/track.dart'; - -void main() { - test('ArtistRef.toDrift preserves id + name + sortName', () { - const ref = ArtistRef(id: 'a1', name: 'Boards of Canada', sortName: 'Boards of Canada'); - final companion = ref.toDrift(); - expect(companion.id.value, 'a1'); - expect(companion.name.value, 'Boards of Canada'); - expect(companion.sortName.value, 'Boards of Canada'); - }); - - test('ArtistRef.toDrift falls back to name when sortName is empty', () { - const ref = ArtistRef(id: 'a1', name: 'The Album Leaf'); - final companion = ref.toDrift(); - expect(companion.sortName.value, 'The Album Leaf'); - }); - - test('AlbumRef.toDrift preserves id + title + artistId', () { - const ref = AlbumRef(id: 'al1', title: 'Geogaddi', artistId: 'ar1'); - final companion = ref.toDrift(); - expect(companion.id.value, 'al1'); - expect(companion.title.value, 'Geogaddi'); - expect(companion.artistId.value, 'ar1'); - }); - - test('TrackRef.toDrift converts seconds to ms + preserves track/disc', () { - const ref = TrackRef( - id: 't1', - title: 'Roygbiv', - albumId: 'al1', - artistId: 'ar1', - durationSec: 137, - trackNumber: 4, - discNumber: 1, - ); - final companion = ref.toDrift(); - expect(companion.id.value, 't1'); - expect(companion.durationMs.value, 137 * 1000); - expect(companion.trackNumber.value, 4); - expect(companion.discNumber.value, 1); - }); - - test('Playlist.toDrift preserves id + userId + name', () { - const p = Playlist( - id: 'p1', - userId: 'u1', - name: 'My Mix', - description: 'a great mix', - isPublic: true, - systemVariant: null, - trackCount: 12, - coverUrl: '', - ownerUsername: 'alice', - createdAt: '', - updatedAt: '', - ); - final companion = p.toDrift(); - expect(companion.id.value, 'p1'); - expect(companion.userId.value, 'u1'); - expect(companion.name.value, 'My Mix'); - expect(companion.isPublic.value, true); - expect(companion.trackCount.value, 12); - }); -} diff --git a/flutter_client/test/cache/audio_cache_manager_test.dart b/flutter_client/test/cache/audio_cache_manager_test.dart deleted file mode 100644 index 09a7ace5..00000000 --- a/flutter_client/test/cache/audio_cache_manager_test.dart +++ /dev/null @@ -1,159 +0,0 @@ -import 'dart:io'; - -import 'package:dio/dio.dart'; -import 'package:drift/drift.dart' show Value; -import 'package:drift/native.dart' show NativeDatabase; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/cache/audio_cache_manager.dart'; -import 'package:minstrel/cache/db.dart'; - -AppDb _testDb() => AppDb(NativeDatabase.memory()); - -void main() { - test('isCached returns false when no row exists', () async { - final db = _testDb(); - addTearDown(db.close); - final mgr = AudioCacheManager( - db: db, - dioFactory: () async => Dio(), - cacheDirFactory: () async => Directory.systemTemp.createTempSync(), - ); - expect(await mgr.isCached('nonexistent'), false); - expect(await mgr.pathFor('nonexistent'), null); - }); - - test('bucketUsage sums drift sizeBytes across rows', () async { - final db = _testDb(); - addTearDown(db.close); - final tmp = Directory.systemTemp.createTempSync(); - final mgr = AudioCacheManager( - db: db, - dioFactory: () async => Dio(), - cacheDirFactory: () async => tmp, - ); - // Each row needs a distinct `path` because path is not the primary - // key, but mapping by trackId only works if rows are distinct. - await db.batch((b) { - b.insertAll(db.audioCacheIndex, [ - AudioCacheIndexCompanion.insert( - trackId: 'a', - path: 'p_a', - sizeBytes: 100, - source: CacheSource.manual), - AudioCacheIndexCompanion.insert( - trackId: 'b', - path: 'p_b', - sizeBytes: 250, - source: CacheSource.incidental), - ]); - }); - // usageBytes() is a directory walk (authoritative on-disk total, - // catches orphan partials); for a drift-sizeBytes sum, bucketUsage - // is the correct API. With empty liked set, both rows go to rolling. - final usage = await mgr.bucketUsage(const {}); - expect(usage.liked + usage.rolling, 350); - }); - - test('rolling cap evicts non-liked LRU; liked protected', () async { - final db = _testDb(); - addTearDown(db.close); - final tmp = Directory.systemTemp.createTempSync(); - final mgr = AudioCacheManager( - db: db, - dioFactory: () async => Dio(), - cacheDirFactory: () async => tmp, - ); - Future mk(String id) async { - final f = File('${tmp.path}/audio_cache/$id.mp3'); - await f.create(recursive: true); - await f.writeAsBytes(List.filled(100, 0)); - } - await mk('old'); - await mk('new'); - await mk('lik'); - await db.batch((b) { - b.insertAll(db.audioCacheIndex, [ - // 'old' has the older lastPlayedAt → evicted first. - AudioCacheIndexCompanion.insert( - trackId: 'old', - path: '${tmp.path}/audio_cache/old.mp3', - sizeBytes: 100, - source: CacheSource.incidental, - lastPlayedAt: Value(DateTime(2020))), - AudioCacheIndexCompanion.insert( - trackId: 'new', - path: '${tmp.path}/audio_cache/new.mp3', - sizeBytes: 100, - source: CacheSource.incidental, - lastPlayedAt: Value(DateTime(2024))), - AudioCacheIndexCompanion.insert( - trackId: 'lik', - path: '${tmp.path}/audio_cache/lik.mp3', - sizeBytes: 100, - source: CacheSource.incidental, - lastPlayedAt: Value(DateTime(2019))), - ]); - }); - final liked = {'lik'}; - final u = await mgr.bucketUsage(liked); - expect(u.liked, 100); - expect(u.rolling, 200); - // Rolling cap fits one 100-byte file; Liked cap huge. - await mgr.evictBuckets( - likedCap: 1 << 30, rollingCap: 100, liked: liked); - expect(await mgr.isCached('old'), false); // oldest rolling, evicted - expect(await mgr.isCached('new'), true); // newer rolling, kept - expect(await mgr.isCached('lik'), true); // liked, protected - }); - - test('clearAll removes everything including manual', () async { - final db = _testDb(); - addTearDown(db.close); - final tmp = Directory.systemTemp.createTempSync(); - final mgr = AudioCacheManager( - db: db, - dioFactory: () async => Dio(), - cacheDirFactory: () async => tmp, - ); - final f = File('${tmp.path}/audio_cache/man.mp3'); - await f.create(recursive: true); - await f.writeAsBytes(List.filled(50, 0)); - await db.into(db.audioCacheIndex).insertOnConflictUpdate( - AudioCacheIndexCompanion.insert( - trackId: 'man', - path: f.path, - sizeBytes: 50, - source: CacheSource.manual), - ); - expect(await mgr.usageBytes(), 50); - await mgr.clearAll(); - expect(await mgr.usageBytes(), 0); - expect(await mgr.isCached('man'), false); - }); - - test('unpin removes index row + deletes file', () async { - final db = _testDb(); - addTearDown(db.close); - final tmp = Directory.systemTemp.createTempSync(); - final mgr = AudioCacheManager( - db: db, - dioFactory: () async => Dio(), - cacheDirFactory: () async => tmp, - ); - final f = File('${tmp.path}/audio_cache/x.mp3'); - await f.create(recursive: true); - await f.writeAsBytes(List.filled(10, 0)); - await db.into(db.audioCacheIndex).insertOnConflictUpdate( - AudioCacheIndexCompanion.insert( - trackId: 'x', - path: f.path, - sizeBytes: 10, - source: CacheSource.autoPrefetch), - ); - expect(await mgr.isCached('x'), true); - await mgr.unpin('x'); - expect(await mgr.isCached('x'), false); - expect(f.existsSync(), false); - }); -} diff --git a/flutter_client/test/cache/cache_first_test.dart b/flutter_client/test/cache/cache_first_test.dart deleted file mode 100644 index ff286873..00000000 --- a/flutter_client/test/cache/cache_first_test.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:minstrel/cache/cache_first.dart'; - -void main() { - test('non-empty drift emission passes through toResult', () async { - final controller = StreamController>(); - final results = cacheFirst( - driftStream: controller.stream, - fetchAndPopulate: () async => fail('should not fetch when rows present'), - toResult: (rows) => rows.fold(0, (a, b) => a + b), - isOnline: () async => true, - ); - - final firstFuture = results.first; - controller.add([1, 2, 3]); - expect(await firstFuture, 6); - await controller.close(); - }); - - test('empty drift emission + online triggers fetchAndPopulate', () async { - final controller = StreamController>(); - var fetchCalled = 0; - final results = cacheFirst( - driftStream: controller.stream, - fetchAndPopulate: () async { - fetchCalled++; - controller.add([42]); // simulate populate causing re-emission - }, - toResult: (rows) => rows.fold(0, (a, b) => a + b), - isOnline: () async => true, - ); - - // Post-coldFetchAttempted guard, cacheFirst yields the current - // (still-empty) rows after the fetch returns, so the stream - // never hangs when populate is a no-op for this filter. The - // simulated drift re-emit then yields the populated rows. - // Capture the Future *before* feeding the controller so the - // listener is subscribed when the first add() lands. - final emissionsFuture = results.take(2).toList(); - controller.add([]); - final emissions = await emissionsFuture; - expect(emissions, [0, 42]); - expect(fetchCalled, 1); - await controller.close(); - }); - - test('empty drift + offline yields empty result without fetch', () async { - final controller = StreamController>(); - var fetchCalled = 0; - final results = cacheFirst( - driftStream: controller.stream, - fetchAndPopulate: () async { - fetchCalled++; - }, - toResult: (rows) => rows.fold(0, (a, b) => a + b), - isOnline: () async => false, - ); - - final firstFuture = results.first; - controller.add([]); - expect(await firstFuture, 0); - expect(fetchCalled, 0); - await controller.close(); - }); - - test('REST failure falls through to empty result', () async { - final controller = StreamController>(); - final results = cacheFirst( - driftStream: controller.stream, - fetchAndPopulate: () async => throw Exception('REST 500'), - toResult: (rows) => rows.fold(0, (a, b) => a + b), - isOnline: () async => true, - ); - - final firstFuture = results.first; - controller.add([]); - expect(await firstFuture, 0); - await controller.close(); - }); -} diff --git a/flutter_client/test/cache/cache_settings_provider_test.dart b/flutter_client/test/cache/cache_settings_provider_test.dart deleted file mode 100644 index 284800e4..00000000 --- a/flutter_client/test/cache/cache_settings_provider_test.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; - -import 'package:minstrel/auth/auth_provider.dart'; -import 'package:minstrel/cache/cache_settings_provider.dart'; - -class _MockStorage extends Mock implements FlutterSecureStorage {} - -void main() { - setUpAll(() { - registerFallbackValue(''); - }); - - test('returns defaults when storage is empty', () async { - final storage = _MockStorage(); - when(() => storage.read(key: any(named: 'key'))) - .thenAnswer((_) async => null); - when(() => storage.write( - key: any(named: 'key'), value: any(named: 'value'))) - .thenAnswer((_) async {}); - - final container = ProviderContainer(overrides: [ - secureStorageProvider.overrideWithValue(storage), - ]); - addTearDown(container.dispose); - - final s = await container.read(cacheSettingsProvider.future); - expect(s.likedCapBytes, CacheSettings.defaults.likedCapBytes); - expect(s.rollingCapBytes, CacheSettings.defaults.rollingCapBytes); - expect(s.likedCapBytes, 5 * 1024 * 1024 * 1024); - expect(s.prefetchWindow, 5); - expect(s.cacheLikedTracks, true); - }); - - test('setPrefetchWindow clamps to 1..10', () async { - final storage = _MockStorage(); - when(() => storage.read(key: any(named: 'key'))) - .thenAnswer((_) async => null); - when(() => storage.write( - key: any(named: 'key'), value: any(named: 'value'))) - .thenAnswer((_) async {}); - - final container = ProviderContainer(overrides: [ - secureStorageProvider.overrideWithValue(storage), - ]); - addTearDown(container.dispose); - - await container.read(cacheSettingsProvider.future); - final ctrl = container.read(cacheSettingsProvider.notifier); - await ctrl.setPrefetchWindow(99); - expect(container.read(cacheSettingsProvider).value!.prefetchWindow, 10); - await ctrl.setPrefetchWindow(0); - expect(container.read(cacheSettingsProvider).value!.prefetchWindow, 1); - }); - - test('setCacheLikedTracks toggles persisted', () async { - final storage = _MockStorage(); - when(() => storage.read(key: any(named: 'key'))) - .thenAnswer((_) async => null); - when(() => storage.write( - key: any(named: 'key'), value: any(named: 'value'))) - .thenAnswer((_) async {}); - - final container = ProviderContainer(overrides: [ - secureStorageProvider.overrideWithValue(storage), - ]); - addTearDown(container.dispose); - - await container.read(cacheSettingsProvider.future); - await container.read(cacheSettingsProvider.notifier).setCacheLikedTracks(false); - expect( - container.read(cacheSettingsProvider).value!.cacheLikedTracks, false); - verify(() => storage.write(key: 'cache_liked_tracks', value: 'false')) - .called(1); - }); -} diff --git a/flutter_client/test/cache/connectivity_provider_test.dart b/flutter_client/test/cache/connectivity_provider_test.dart deleted file mode 100644 index 170549e0..00000000 --- a/flutter_client/test/cache/connectivity_provider_test.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/cache/connectivity_provider.dart'; - -void main() { - // The provider relies on connectivity_plus, which uses a platform - // channel — accessing it from a unit test triggers - // "Binding has not yet been initialized" because there's no platform - // implementation available. Real coverage lives in on-device passes. - // Smoke-test the import only. - test('connectivityProvider import smoke', () { - expect(connectivityProvider, isNotNull); - }); -} diff --git a/flutter_client/test/cache/prefetcher_test.dart b/flutter_client/test/cache/prefetcher_test.dart deleted file mode 100644 index f2e8b54f..00000000 --- a/flutter_client/test/cache/prefetcher_test.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -void main() { - // The prefetcher's behavior is integration-y: it depends on the audio - // handler's mediaItem + queue streams + the operator's cache settings. - // Mocking those in a unit test would amount to mocking everything. - // Real coverage lives in on-device verification + the audio cache - // manager unit tests (which exercise the underlying pin/evict logic). - test('prefetcher import smoke', () { - expect(1 + 1, 2); - }); -} diff --git a/flutter_client/test/cache/sync_controller_test.dart b/flutter_client/test/cache/sync_controller_test.dart deleted file mode 100644 index 977fd41e..00000000 --- a/flutter_client/test/cache/sync_controller_test.dart +++ /dev/null @@ -1,142 +0,0 @@ -import 'dart:convert'; - -import 'package:dio/dio.dart'; -import 'package:drift/native.dart' show NativeDatabase; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/cache/audio_cache_manager.dart' show appDbProvider; -import 'package:minstrel/cache/db.dart'; -import 'package:minstrel/cache/sync_controller.dart'; -import 'package:minstrel/library/library_providers.dart' show dioProvider; - -/// Builds a Dio whose adapter resolves every request to the supplied -/// status code + body. Avoids touching the network in tests. -/// -/// Body is normalized through a JSON round-trip so Map/List literals -/// surface as `Map` / `List` (matching how -/// real Dio responses parse), not the `Map` shape -/// Dart's literal inference would otherwise pick. sync_controller's -/// `as Map` casts are invariant on generics and -/// would throw TypeError on the literal shape. -Dio _stubDio({required int status, dynamic body}) { - final dio = Dio(); - final normalizedBody = - (body is Map || body is List) ? jsonDecode(jsonEncode(body)) : body; - dio.interceptors.add(InterceptorsWrapper(onRequest: (req, h) { - h.resolve(Response( - requestOptions: req, - statusCode: status, - data: normalizedBody, - )); - })); - return dio; -} - -ProviderContainer _container({required AppDb db, required Dio dio}) { - return ProviderContainer(overrides: [ - appDbProvider.overrideWithValue(db), - dioProvider.overrideWith((ref) async => dio), - ]); -} - -void main() { - test('204 advances lastSyncAt without changing cursor', () async { - final db = AppDb(NativeDatabase.memory()); - addTearDown(db.close); - - final container = _container(db: db, dio: _stubDio(status: 204)); - addTearDown(container.dispose); - - final result = await container.read(syncControllerProvider.notifier).sync(); - expect(result?.upserts, 0); - expect(result?.deletes, 0); - final meta = await db.select(db.syncMetadata).getSingleOrNull(); - expect(meta?.lastSyncAt, isNotNull); - }); - - test('200 with artist upsert writes drift row + advances cursor', () async { - final db = AppDb(NativeDatabase.memory()); - addTearDown(db.close); - - final container = _container( - db: db, - dio: _stubDio(status: 200, body: { - 'cursor': 7, - 'upserts': { - 'artist': [ - {'id': 'a1', 'name': 'A', 'sort_name': 'A'}, - ], - }, - 'deletes': {}, - }), - ); - addTearDown(container.dispose); - - final result = await container.read(syncControllerProvider.notifier).sync(); - expect(result?.upserts, 1); - expect(result?.cursor, 7); - final artist = await (db.select(db.cachedArtists) - ..where((t) => t.id.equals('a1'))) - .getSingleOrNull(); - expect(artist?.name, 'A'); - final meta = await db.select(db.syncMetadata).getSingleOrNull(); - expect(meta?.cursor, 7); - }); - - test('200 with track delete removes the row', () async { - final db = AppDb(NativeDatabase.memory()); - addTearDown(db.close); - - // Seed an existing cached track - await db.into(db.cachedTracks).insertOnConflictUpdate( - CachedTracksCompanion.insert( - id: 't1', albumId: 'al1', artistId: 'ar1', title: 'song'), - ); - - final container = _container( - db: db, - dio: _stubDio(status: 200, body: { - 'cursor': 3, - 'upserts': {}, - 'deletes': { - 'track': ['t1'], - }, - }), - ); - addTearDown(container.dispose); - - final result = await container.read(syncControllerProvider.notifier).sync(); - expect(result?.deletes, 1); - final track = await (db.select(db.cachedTracks) - ..where((t) => t.id.equals('t1'))) - .getSingleOrNull(); - expect(track, isNull); - }); - - test('like_track upsert + delete round-trip', () async { - final db = AppDb(NativeDatabase.memory()); - addTearDown(db.close); - - final container = _container( - db: db, - dio: _stubDio(status: 200, body: { - 'cursor': 1, - 'upserts': { - 'like_track': [ - {'user_id': 'u1', 'track_id': 't1'}, - ], - }, - 'deletes': {}, - }), - ); - addTearDown(container.dispose); - - await container.read(syncControllerProvider.notifier).sync(); - final liked = await db.select(db.cachedLikes).get(); - expect(liked.length, 1); - expect(liked.first.userId, 'u1'); - expect(liked.first.entityType, 'track'); - expect(liked.first.entityId, 't1'); - }); -} diff --git a/flutter_client/test/library/album_detail_screen_test.dart b/flutter_client/test/library/album_detail_screen_test.dart deleted file mode 100644 index ee1e0293..00000000 --- a/flutter_client/test/library/album_detail_screen_test.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/library/album_detail_screen.dart'; -import 'package:minstrel/library/library_providers.dart'; -import 'package:minstrel/models/album.dart'; -import 'package:minstrel/models/track.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -void main() { - testWidgets('renders album header + track list', (tester) async { - await tester.pumpWidget(ProviderScope( - overrides: [ - albumProvider('al-1').overrideWith((ref) => Stream.value(( - album: const AlbumRef(id: 'al-1', title: 'Drukqs', artistId: 'a-1', artistName: 'Aphex Twin'), - tracks: const [ - TrackRef(id: 't-1', title: 'Avril 14th', albumId: 'al-1', artistId: 'a-1', durationSec: 121, trackNumber: 4), - ], - ))), - ], - child: MaterialApp(theme: buildThemeData(), home: const AlbumDetailScreen(id: 'al-1')), - )); - await tester.pumpAndSettle(); - expect(find.text('Drukqs'), findsOneWidget); - expect(find.text('Avril 14th'), findsOneWidget); - }); -} diff --git a/flutter_client/test/library/artist_detail_screen_test.dart b/flutter_client/test/library/artist_detail_screen_test.dart deleted file mode 100644 index 07ae1037..00000000 --- a/flutter_client/test/library/artist_detail_screen_test.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/library/artist_detail_screen.dart'; -import 'package:minstrel/library/library_providers.dart'; -import 'package:minstrel/models/album.dart'; -import 'package:minstrel/models/artist.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -void main() { - testWidgets('renders artist name and Albums header', (tester) async { - await tester.pumpWidget(ProviderScope( - overrides: [ - artistProvider('a-1').overrideWith((ref) => Stream.value(const ArtistRef(id: 'a-1', name: 'Aphex Twin'))), - artistAlbumsProvider('a-1').overrideWith((ref) => Stream.value(const [])), - ], - child: MaterialApp(theme: buildThemeData(), home: const ArtistDetailScreen(id: 'a-1')), - )); - await tester.pumpAndSettle(); - expect(find.text('Aphex Twin'), findsOneWidget); - expect(find.text('Albums'), findsOneWidget); - }); -} diff --git a/flutter_client/test/library/home_screen_test.dart b/flutter_client/test/library/home_screen_test.dart deleted file mode 100644 index da1c1a58..00000000 --- a/flutter_client/test/library/home_screen_test.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/api/endpoints/playlists.dart'; -import 'package:minstrel/library/home_screen.dart'; -import 'package:minstrel/library/library_providers.dart'; -import 'package:minstrel/models/home_index.dart'; -import 'package:minstrel/models/playlist.dart'; -import 'package:minstrel/models/system_playlists_status.dart'; -import 'package:minstrel/playlists/playlists_provider.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -// All tests pump an empty HomeIndex unless they care about populated -// section IDs — per-tile hydration is intentionally not exercised here. -// What this suite verifies is the screen's section-level shape: -// placeholders / empty-state copy / section presence given the index. -const _emptyIndex = HomeIndex.empty; - -void main() { - testWidgets('renders 4 placeholder cards in Playlists row when no system playlists exist', - (tester) async { - await tester.pumpWidget(ProviderScope( - overrides: [ - homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)), - playlistsListProvider('all').overrideWith( - (ref) => Stream.value(PlaylistsList.empty()), - ), - systemPlaylistsStatusProvider.overrideWith( - (ref) => Stream.value(SystemPlaylistsStatus.empty()), - ), - ], - child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()), - )); - await tester.pumpAndSettle(); - // For-You placeholder + Discover placeholder + 3 Songs-like placeholders. - expect(find.text('For You'), findsOneWidget); - expect(find.text('Discover'), findsOneWidget); - expect(find.text('Songs like…'), findsNWidgets(3)); - }); - - testWidgets('renders empty-state copy for each section when the index is empty', - (tester) async { - await tester.pumpWidget(ProviderScope( - overrides: [ - homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)), - playlistsListProvider('all').overrideWith( - (ref) => Stream.value(PlaylistsList.empty()), - ), - systemPlaylistsStatusProvider.overrideWith( - (ref) => Stream.value(SystemPlaylistsStatus.empty()), - ), - ], - child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()), - )); - await tester.pumpAndSettle(); - expect( - find.text("Nothing added yet. Scan a folder via the server's config."), - findsOneWidget, - ); - expect( - find.text('No forgotten favourites yet. Like some albums or artists to fill this in.'), - findsOneWidget, - ); - expect(find.text('No plays to draw from. Listen to something.'), - findsOneWidget); - expect(find.text('No recent plays.'), findsOneWidget); - }); - - testWidgets('renders For-You card when system playlist exists', - (tester) async { - const forYou = Playlist( - id: 'fy', - userId: 'u1', - name: 'For You', - description: '', - isPublic: false, - systemVariant: 'for_you', - trackCount: 75, - coverUrl: '', - ownerUsername: 'alice', - createdAt: '2026-05-01T00:00:00Z', - updatedAt: '2026-05-01T00:00:00Z', - ); - await tester.pumpWidget(ProviderScope( - overrides: [ - homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)), - playlistsListProvider('all').overrideWith( - (ref) => Stream.value( - const PlaylistsList(owned: [forYou], public: [])), - ), - systemPlaylistsStatusProvider.overrideWith( - (ref) => Stream.value(SystemPlaylistsStatus.empty()), - ), - ], - child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()), - )); - await tester.pumpAndSettle(); - // The real card carries the playlist name + the "for you" badge. - expect(find.text('For You'), findsOneWidget); - expect(find.text('for you'), findsOneWidget); - }); - - // Section-with-populated-IDs coverage lives in the drift-tagged - // integration suite (Slice F follow-up) because per-tile providers - // require a real (in-memory) drift DB. The flutter-ci runner skips - // drift tests pending libsqlite3. -} diff --git a/flutter_client/test/library/widgets/compact_track_card_test.dart b/flutter_client/test/library/widgets/compact_track_card_test.dart deleted file mode 100644 index d8e789ea..00000000 --- a/flutter_client/test/library/widgets/compact_track_card_test.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/library/widgets/compact_track_card.dart'; -import 'package:minstrel/models/track.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -const _track = TrackRef( - id: 't1', - title: 'Roygbiv', - albumId: 'a1', - albumTitle: 'Geogaddi', - artistId: 'ar1', - artistName: 'Boards of Canada', - durationSec: 137, - trackNumber: 4, - streamUrl: '', -); - -void main() { - testWidgets('renders title and artist', (tester) async { - await tester.pumpWidget(ProviderScope( - child: MaterialApp( - theme: buildThemeData(), - home: const Scaffold( - body: CompactTrackCard( - track: _track, - sectionTracks: [_track], - index: 0, - ), - ), - ), - )); - expect(find.text('Roygbiv'), findsOneWidget); - expect(find.text('Boards of Canada'), findsOneWidget); - }); -} diff --git a/flutter_client/test/library/widgets_smoke_test.dart b/flutter_client/test/library/widgets_smoke_test.dart deleted file mode 100644 index 56e39a8b..00000000 --- a/flutter_client/test/library/widgets_smoke_test.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:drift/native.dart' show NativeDatabase; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/cache/audio_cache_manager.dart' show appDbProvider; -import 'package:minstrel/cache/db.dart'; -import 'package:minstrel/library/widgets/album_card.dart'; -import 'package:minstrel/library/widgets/track_row.dart'; -import 'package:minstrel/models/album.dart'; -import 'package:minstrel/models/track.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -void main() { - testWidgets('AlbumCard renders title and artist', (tester) async { - await tester.pumpWidget(MaterialApp( - theme: buildThemeData(), - home: Scaffold( - body: AlbumCard( - album: const AlbumRef( - id: 'a', - title: 'Geogaddi', - artistId: 'x', - artistName: 'Boards of Canada', - ), - onTap: () {}, - ), - ), - )); - expect(find.text('Geogaddi'), findsOneWidget); - expect(find.text('Boards of Canada'), findsOneWidget); - }); - - testWidgets('TrackRow shows mm:ss duration', (tester) async { - // TrackRow contains CachedIndicator (ConsumerWidget) which reaches - // audioCacheManagerProvider → appDbProvider. Override appDbProvider - // with an explicit NativeDatabase.memory() executor — drift_flutter's - // default `driftDatabase()` schedules a deferred-init Timer that - // outlives the test widget tree and trips - // _verifyInvariants("A Timer is still pending after dispose"). - await tester.pumpWidget(ProviderScope( - overrides: [ - appDbProvider.overrideWith((ref) { - final db = AppDb(NativeDatabase.memory()); - ref.onDispose(db.close); - return db; - }), - ], - child: MaterialApp( - theme: buildThemeData(), - home: Scaffold( - body: TrackRow( - track: const TrackRef( - id: 't', - title: 'Roygbiv', - albumId: 'a', - artistId: 'x', - durationSec: 137, - trackNumber: 4, - ), - onTap: () {}, - ), - ), - ), - )); - expect(find.text('02:17'), findsOneWidget); - }); -} diff --git a/flutter_client/test/models/models_test.dart b/flutter_client/test/models/models_test.dart deleted file mode 100644 index d1082091..00000000 --- a/flutter_client/test/models/models_test.dart +++ /dev/null @@ -1,168 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:minstrel/models/album.dart'; -import 'package:minstrel/models/artist.dart'; -import 'package:minstrel/models/home_data.dart'; -import 'package:minstrel/models/track.dart'; -import 'package:minstrel/models/user.dart'; - -void main() { - group('User.fromJson', () { - test('parses canonical UserView shape', () { - final u = User.fromJson({ - 'id': 'usr-1', - 'username': 'alice', - 'is_admin': true, - }); - expect(u.id, 'usr-1'); - expect(u.username, 'alice'); - expect(u.isAdmin, isTrue); - }); - - test('defaults is_admin to false when missing', () { - final u = User.fromJson({'id': 'usr-2', 'username': 'bob'}); - expect(u.isAdmin, isFalse); - }); - }); - - group('ArtistRef.fromJson', () { - test('parses full payload using server cover_url field', () { - final a = ArtistRef.fromJson({ - 'id': 'art-1', - 'name': 'Boards of Canada', - 'sort_name': 'Boards of Canada', - 'album_count': 7, - 'cover_url': '/api/albums/al-1/cover', - }); - expect(a.name, 'Boards of Canada'); - expect(a.sortName, 'Boards of Canada'); - expect(a.albumCount, 7); - expect(a.coverUrl, '/api/albums/al-1/cover'); - }); - - test('tolerates missing optional fields', () { - final a = ArtistRef.fromJson({'id': 'art-2', 'name': 'Aphex Twin'}); - expect(a.sortName, ''); - expect(a.albumCount, 0); - expect(a.coverUrl, ''); - }); - }); - - group('AlbumRef.fromJson', () { - test('parses full payload with year and duration_sec', () { - final a = AlbumRef.fromJson({ - 'id': 'al-1', - 'title': 'Geogaddi', - 'sort_title': 'Geogaddi', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - 'year': 2002, - 'track_count': 23, - 'duration_sec': 4020, - 'cover_url': '/api/albums/al-1/cover', - }); - expect(a.title, 'Geogaddi'); - expect(a.year, 2002); - expect(a.trackCount, 23); - expect(a.durationSec, 4020); - expect(a.coverUrl, '/api/albums/al-1/cover'); - }); - - test('leaves year null when absent', () { - final a = AlbumRef.fromJson({ - 'id': 'al-2', - 'title': 'Untitled', - 'artist_id': 'art-1', - }); - expect(a.year, isNull); - expect(a.trackCount, 0); - expect(a.durationSec, 0); - }); - }); - - group('TrackRef.fromJson', () { - test('parses full payload including stream_url', () { - final t = TrackRef.fromJson({ - 'id': 'tr-1', - 'title': 'Music Is Math', - 'album_id': 'al-1', - 'album_title': 'Geogaddi', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - 'track_number': 5, - 'disc_number': 1, - 'duration_sec': 379, - 'stream_url': '/api/tracks/tr-1/stream', - }); - expect(t.title, 'Music Is Math'); - expect(t.trackNumber, 5); - expect(t.discNumber, 1); - expect(t.durationSec, 379); - expect(t.streamUrl, '/api/tracks/tr-1/stream'); - }); - - test('leaves track_number and disc_number null when omitted', () { - final t = TrackRef.fromJson({ - 'id': 'tr-2', - 'title': 'Hidden', - 'album_id': 'al-1', - 'artist_id': 'art-1', - }); - expect(t.trackNumber, isNull); - expect(t.discNumber, isNull); - }); - }); - - group('HomeData.fromJson', () { - test('handles missing sections as empty lists', () { - final h = HomeData.fromJson({}); - expect(h.recentlyAddedAlbums, isEmpty); - expect(h.rediscoverAlbums, isEmpty); - expect(h.rediscoverArtists, isEmpty); - expect(h.mostPlayedTracks, isEmpty); - expect(h.lastPlayedArtists, isEmpty); - }); - - test('parses populated payload across all five sections', () { - final h = HomeData.fromJson({ - 'recently_added_albums': [ - { - 'id': 'al-1', - 'title': 'Geogaddi', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - 'cover_url': '/api/albums/al-1/cover', - } - ], - 'rediscover_albums': [ - { - 'id': 'al-2', - 'title': 'Music Has the Right to Children', - 'artist_id': 'art-1', - 'artist_name': 'Boards of Canada', - } - ], - 'rediscover_artists': [ - {'id': 'art-2', 'name': 'Aphex Twin'} - ], - 'most_played_tracks': [ - { - 'id': 'tr-1', - 'title': 'Roygbiv', - 'album_id': 'al-2', - 'artist_id': 'art-1', - 'duration_sec': 150, - } - ], - 'last_played_artists': [ - {'id': 'art-3', 'name': 'Tycho'} - ], - }); - expect(h.recentlyAddedAlbums.single.title, 'Geogaddi'); - expect(h.recentlyAddedAlbums.single.coverUrl, '/api/albums/al-1/cover'); - expect(h.rediscoverAlbums.single.id, 'al-2'); - expect(h.rediscoverArtists.single.name, 'Aphex Twin'); - expect(h.mostPlayedTracks.single.durationSec, 150); - expect(h.lastPlayedArtists.single.name, 'Tycho'); - }); - }); -} diff --git a/flutter_client/test/player/album_cover_cache_test.dart b/flutter_client/test/player/album_cover_cache_test.dart deleted file mode 100644 index 79940f89..00000000 --- a/flutter_client/test/player/album_cover_cache_test.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/player/album_cover_cache.dart'; - -class _RecordingAdapter implements HttpClientAdapter { - _RecordingAdapter(this.body); - final List body; - int callCount = 0; - - @override - Future fetch( - RequestOptions options, - Stream? requestStream, - Future? cancelFuture, - ) async { - callCount++; - return ResponseBody.fromBytes(body, 200, headers: { - Headers.contentTypeHeader: ['image/jpeg'], - }); - } - - @override - void close({bool force = false}) {} -} - -class _FailingAdapter implements HttpClientAdapter { - @override - Future fetch( - RequestOptions options, - Stream? requestStream, - Future? cancelFuture, - ) async { - throw DioException( - requestOptions: options, - type: DioExceptionType.connectionError, - message: 'simulated network failure', - ); - } - - @override - void close({bool force = false}) {} -} - -Future _tmpDirFactory() async => - Directory.systemTemp.createTempSync('cover_cache_test_'); - -void main() { - test('cache miss writes file and returns path', () async { - final adapter = _RecordingAdapter([1, 2, 3, 4]); - final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter; - final cache = AlbumCoverCache( - dioFactory: () async => dio, - cacheDirFactory: _tmpDirFactory, - ); - final path = await cache.getOrFetch('alb-1'); - expect(path, isNotNull); - expect(File(path!).readAsBytesSync(), [1, 2, 3, 4]); - expect(adapter.callCount, 1); - }); - - test('cache hit returns same path without re-fetching', () async { - final adapter = _RecordingAdapter([9, 9, 9]); - final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter; - final tmp = await _tmpDirFactory(); - final cache = AlbumCoverCache( - dioFactory: () async => dio, - cacheDirFactory: () async => tmp, - ); - final p1 = await cache.getOrFetch('alb-2'); - final p2 = await cache.getOrFetch('alb-2'); - expect(p1, p2); - expect(adapter.callCount, 1); - }); - - test('concurrent calls for same albumId dedupe', () async { - final adapter = _RecordingAdapter([1, 2, 3]); - final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter; - final cache = AlbumCoverCache( - dioFactory: () async => dio, - cacheDirFactory: _tmpDirFactory, - ); - final results = await Future.wait([ - cache.getOrFetch('alb-3'), - cache.getOrFetch('alb-3'), - cache.getOrFetch('alb-3'), - ]); - expect(results[0], isNotNull); - expect(results[1], results[0]); - expect(results[2], results[0]); - expect(adapter.callCount, 1); - }); - - test('failure returns null without writing file', () async { - final adapter = _FailingAdapter(); - final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter; - final tmp = await _tmpDirFactory(); - final cache = AlbumCoverCache( - dioFactory: () async => dio, - cacheDirFactory: () async => tmp, - ); - final path = await cache.getOrFetch('alb-4'); - expect(path, isNull); - expect(File('${tmp.path}/album_covers/alb-4.jpg').existsSync(), isFalse); - }); - - test('empty albumId returns null without dio call', () async { - final adapter = _RecordingAdapter([1, 2]); - final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter; - final cache = AlbumCoverCache( - dioFactory: () async => dio, - cacheDirFactory: _tmpDirFactory, - ); - final path = await cache.getOrFetch(''); - expect(path, isNull); - expect(adapter.callCount, 0); - }); -} diff --git a/flutter_client/test/player/player_provider_test.dart b/flutter_client/test/player/player_provider_test.dart deleted file mode 100644 index 5ba00e0d..00000000 --- a/flutter_client/test/player/player_provider_test.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/player/audio_handler.dart'; -import 'package:minstrel/player/player_provider.dart'; - -void main() { - // MinstrelAudioHandler() instantiates just_audio's AudioPlayer, which calls - // setMethodCallHandler on a platform channel during construction. Without an - // initialized binding the call asserts. We're not exercising real audio in - // the test — this just gives us a working binary messenger. - TestWidgetsFlutterBinding.ensureInitialized(); - - test('audioHandlerProvider must be overridden — bare read throws', () { - final container = ProviderContainer(); - addTearDown(container.dispose); - // Riverpod 3 wraps the underlying error in a ProviderException. The - // intent of the test is "bare read fails," so accept the wrapper as - // long as the inner error is the UnimplementedError we threw. - expect( - () => container.read(audioHandlerProvider), - throwsA(predicate((e) { - if (e is UnimplementedError) return true; - // ProviderException is private to riverpod; match by name + by - // walking its toString for the inner UnimplementedError text. - return e.runtimeType.toString().contains('ProviderException') && - e.toString().contains('UnimplementedError'); - })), - ); - }); - - test('overridden audioHandlerProvider exposes playbackState stream', () { - final handler = MinstrelAudioHandler(); - final container = ProviderContainer(overrides: [ - audioHandlerProvider.overrideWithValue(handler), - ]); - addTearDown(container.dispose); - expect(container.read(audioHandlerProvider), same(handler)); - final sub = container.listen(playbackStateProvider, (_, __) {}); - sub.close(); - }); -} diff --git a/flutter_client/test/playlists/widgets/playlist_card_test.dart b/flutter_client/test/playlists/widgets/playlist_card_test.dart deleted file mode 100644 index 186041d1..00000000 --- a/flutter_client/test/playlists/widgets/playlist_card_test.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_lucide/flutter_lucide.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/models/playlist.dart'; -import 'package:minstrel/playlists/widgets/playlist_card.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -const _userPlaylist = Playlist( - id: 'p1', - userId: 'u1', - name: 'Road trip', - description: '', - isPublic: false, - systemVariant: null, - trackCount: 12, - coverUrl: '', - ownerUsername: 'alice', - createdAt: '2026-05-01T00:00:00Z', - updatedAt: '2026-05-01T00:00:00Z', -); - -const _forYou = Playlist( - id: 'p2', - userId: 'u1', - name: 'For You', - description: '', - isPublic: false, - systemVariant: 'for_you', - trackCount: 75, - coverUrl: '', - ownerUsername: 'alice', - createdAt: '2026-05-01T00:00:00Z', - updatedAt: '2026-05-01T00:00:00Z', -); - -void main() { - testWidgets('renders name and no badge for user playlist', (tester) async { - await tester.pumpWidget(ProviderScope( - child: MaterialApp( - theme: buildThemeData(), - home: const Scaffold( - body: PlaylistCard(playlist: _userPlaylist), - ), - ))); - expect(find.text('Road trip'), findsOneWidget); - expect(find.text('for you'), findsNothing); - }); - - testWidgets('renders system badge for system playlist', (tester) async { - await tester.pumpWidget(ProviderScope( - child: MaterialApp( - theme: buildThemeData(), - home: const Scaffold( - body: PlaylistCard(playlist: _forYou), - ), - ))); - expect(find.text('For You'), findsOneWidget); - expect(find.text('for you'), findsOneWidget); - }); - - testWidgets('shows refresh kebab on system playlists', (tester) async { - await tester.pumpWidget(ProviderScope( - child: MaterialApp( - theme: buildThemeData(), - home: const Scaffold( - body: PlaylistCard(playlist: _forYou), - ), - ))); - expect(find.byIcon(LucideIcons.ellipsis_vertical), findsOneWidget); - }); - - testWidgets('no refresh kebab on user playlists', (tester) async { - await tester.pumpWidget(ProviderScope( - child: MaterialApp( - theme: buildThemeData(), - home: const Scaffold( - body: PlaylistCard(playlist: _userPlaylist), - ), - ))); - expect(find.byIcon(LucideIcons.ellipsis_vertical), findsNothing); - }); -} diff --git a/flutter_client/test/playlists/widgets/playlist_placeholder_card_test.dart b/flutter_client/test/playlists/widgets/playlist_placeholder_card_test.dart deleted file mode 100644 index e7c43eba..00000000 --- a/flutter_client/test/playlists/widgets/playlist_placeholder_card_test.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/playlists/widgets/playlist_placeholder_card.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -void main() { - testWidgets('building variant renders spinner + label', (tester) async { - await tester.pumpWidget(MaterialApp( - theme: buildThemeData(), - home: const Scaffold( - body: PlaylistPlaceholderCard(label: 'For You', variant: 'building'), - ), - )); - expect(find.text('For You'), findsOneWidget); - expect(find.text('Building…'), findsOneWidget); - expect(find.byType(CircularProgressIndicator), findsOneWidget); - }); - - testWidgets('failed variant shows warning + copy', (tester) async { - await tester.pumpWidget(MaterialApp( - theme: buildThemeData(), - home: const Scaffold( - body: PlaylistPlaceholderCard(label: 'Songs like…', variant: 'failed'), - ), - )); - expect(find.text("Couldn't generate"), findsOneWidget); - }); - - testWidgets('seed-needed variant copy', (tester) async { - await tester.pumpWidget(MaterialApp( - theme: buildThemeData(), - home: const Scaffold( - body: PlaylistPlaceholderCard(label: 'Songs like…', variant: 'seed-needed'), - ), - )); - expect(find.text('Like more music'), findsOneWidget); - }); - - testWidgets('pending variant copy', (tester) async { - await tester.pumpWidget(MaterialApp( - theme: buildThemeData(), - home: const Scaffold( - body: PlaylistPlaceholderCard(label: 'For You', variant: 'pending'), - ), - )); - expect(find.text('Coming soon'), findsOneWidget); - }); -} diff --git a/flutter_client/test/quarantine/quarantine_provider_test.dart b/flutter_client/test/quarantine/quarantine_provider_test.dart deleted file mode 100644 index 6acf119d..00000000 --- a/flutter_client/test/quarantine/quarantine_provider_test.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'dart:async'; - -import 'package:drift/native.dart' show NativeDatabase; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/api/endpoints/quarantine.dart'; -import 'package:minstrel/cache/audio_cache_manager.dart' show appDbProvider; -import 'package:minstrel/cache/connectivity_provider.dart'; -import 'package:minstrel/cache/db.dart'; -import 'package:minstrel/models/track.dart'; -import 'package:minstrel/quarantine/quarantine_provider.dart'; - -class _StubQuarantineApi implements QuarantineApi { - _StubQuarantineApi({this.shouldThrow = false}); - bool shouldThrow; - int flagCalls = 0; - int unflagCalls = 0; - - @override - Future flag(String trackId, String reason, {String notes = ''}) async { - flagCalls++; - if (shouldThrow) throw Exception('simulated server failure'); - } - - @override - Future unflag(String trackId) async { - unflagCalls++; - if (shouldThrow) throw Exception('simulated server failure'); - } -} - -const _track = TrackRef( - id: 't1', - title: 'Roygbiv', - albumId: 'a1', - albumTitle: 'Geogaddi', - artistId: 'ar1', - artistName: 'Boards of Canada', - durationSec: 137, - trackNumber: 4, - streamUrl: '', -); - -ProviderContainer _container({required AppDb db, required QuarantineApi api}) { - return ProviderContainer(overrides: [ - appDbProvider.overrideWithValue(db), - quarantineApiProvider.overrideWith((ref) async => api), - // _refreshFromServer() in build() reads connectivityProvider; in tests - // without this override it's a StreamProvider that never emits, so - // tearDown trips Riverpod's "disposed during loading" — visible on - // the throwing-API variant where the catch path's await mutationQueue - // .enqueue advances _refreshFromServer's chain enough to surface it. - // Use a never-closing async* generator instead of Stream.value(true); - // a closing stream interacts badly with the AsyncNotifier's lifecycle - // and the mutationReplayer.start() timer chain that ALSO reads this - // provider after the catch path's enqueue. - connectivityProvider.overrideWith((ref) async* { - yield true; - await Completer().future; // hold open until tearDown - }), - ]); -} - -Future _seed(AppDb db) async { - await db.into(db.cachedQuarantineMine).insert( - CachedQuarantineMineCompanion.insert( - trackId: 't1', - reason: 'bad_rip', - createdAt: '2026-05-01T00:00:00Z', - trackTitle: 'Roygbiv', - albumId: 'a1', - albumTitle: 'Geogaddi', - artistId: 'ar1', - artistName: 'Boards of Canada', - ), - ); -} - -void main() { - test('flag inserts a drift row and calls the server', - () async { - final db = AppDb(NativeDatabase.memory()); - addTearDown(db.close); - final api = _StubQuarantineApi(); - final container = _container(db: db, api: api); - addTearDown(container.dispose); - - await container.read(myQuarantineProvider.future); - await container - .read(myQuarantineProvider.notifier) - .flag(_track, 'bad_rip', ''); - - expect(api.flagCalls, 1); - final rows = await db.select(db.cachedQuarantineMine).get(); - expect(rows, hasLength(1)); - expect(rows.first.trackId, 't1'); - expect(rows.first.reason, 'bad_rip'); - }); - - test('flag keeps drift optimistic + queues mutation on server failure', () async { - final db = AppDb(NativeDatabase.memory()); - addTearDown(db.close); - final api = _StubQuarantineApi(shouldThrow: true); - final container = _container(db: db, api: api); - addTearDown(container.dispose); - - await container.read(myQuarantineProvider.future); - // No longer rethrows — flag swallows the REST failure and queues - // for replay so the user's intent persists offline. - await container - .read(myQuarantineProvider.notifier) - .flag(_track, 'bad_rip', ''); - final rows = await db.select(db.cachedQuarantineMine).get(); - expect(rows, hasLength(1), - reason: 'optimistic drift row should persist across REST failure'); - final mutations = await db.select(db.cachedMutations).get(); - expect(mutations, hasLength(1), - reason: 'failed flag should have been enqueued for replay'); - expect(mutations.first.kind, 'quarantine.flag'); - }, skip: 'Pending Fable #476 — StreamProvider lifecycle in async catch path; see task body for full diagnostic'); - - test('unflag deletes the drift row and calls the server', - () async { - final db = AppDb(NativeDatabase.memory()); - addTearDown(db.close); - await _seed(db); - final api = _StubQuarantineApi(); - final container = _container(db: db, api: api); - addTearDown(container.dispose); - - await container.read(myQuarantineProvider.future); - await container.read(myQuarantineProvider.notifier).unflag('t1'); - - expect(api.unflagCalls, 1); - final rows = await db.select(db.cachedQuarantineMine).get(); - expect(rows, isEmpty); - }); - - test('isHidden reflects drift state', () async { - final db = AppDb(NativeDatabase.memory()); - addTearDown(db.close); - await _seed(db); - final api = _StubQuarantineApi(); - final container = _container(db: db, api: api); - addTearDown(container.dispose); - - await container.read(myQuarantineProvider.future); - final ctrl = container.read(myQuarantineProvider.notifier); - expect(ctrl.isHidden('t1'), isTrue); - expect(ctrl.isHidden('t2'), isFalse); - }); -} diff --git a/flutter_client/test/requests/requests_screen_test.dart b/flutter_client/test/requests/requests_screen_test.dart deleted file mode 100644 index fcd63c8e..00000000 --- a/flutter_client/test/requests/requests_screen_test.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/auth/auth_provider.dart'; -import 'package:minstrel/models/admin_request.dart'; -import 'package:minstrel/models/user.dart'; -import 'package:minstrel/requests/requests_provider.dart'; -import 'package:minstrel/requests/requests_screen.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -class _StubAuth extends AuthController { - @override - Future build() async => - const User(id: 'u1', username: 'alice', isAdmin: false); -} - -class _StubRequests extends MyRequestsController { - _StubRequests(this._initial); - final List _initial; - @override - Future> build() async => _initial; -} - -const _pendingAlbum = AdminRequest( - id: 'r1', - userId: 'u1', - status: 'pending', - kind: 'album', - artistName: 'Aphex Twin', - albumTitle: 'Drukqs', - trackTitle: null, - requestedAt: '2026-05-08T00:00:00Z', - decidedAt: null, - notes: null, - importedAlbumCount: 0, - importedTrackCount: 0, -); - -const _completedTrackWithMatch = AdminRequest( - id: 'r2', - userId: 'u1', - status: 'completed', - kind: 'track', - artistName: 'Boards of Canada', - albumTitle: 'Geogaddi', - trackTitle: 'Roygbiv', - requestedAt: '2026-05-01T00:00:00Z', - decidedAt: '2026-05-02T00:00:00Z', - notes: null, - importedAlbumCount: 1, - importedTrackCount: 1, - matchedTrackId: 't1', - matchedAlbumId: 'a1', -); - -const _rejectedWithNotes = AdminRequest( - id: 'r3', - userId: 'u1', - status: 'rejected', - kind: 'artist', - artistName: 'Some Artist', - albumTitle: null, - trackTitle: null, - requestedAt: '2026-05-01T00:00:00Z', - decidedAt: '2026-05-02T00:00:00Z', - notes: 'Not in MusicBrainz', - importedAlbumCount: 0, - importedTrackCount: 0, -); - -Widget _harness(List requests) => ProviderScope( - overrides: [ - authControllerProvider.overrideWith(() => _StubAuth()), - myRequestsProvider.overrideWith(() => _StubRequests(requests)), - ], - child: MaterialApp( - theme: buildThemeData(), - home: const RequestsScreen(), - ), - ); - -void main() { - testWidgets('renders empty state when no requests', (t) async { - await t.pumpWidget(_harness(const [])); - await t.pumpAndSettle(); - expect(find.text('Nothing requested yet.'), findsOneWidget); - }); - - testWidgets('renders pending row with Cancel CTA', (t) async { - await t.pumpWidget(_harness(const [_pendingAlbum])); - await t.pumpAndSettle(); - expect(find.byKey(const Key('request_row_r1')), findsOneWidget); - expect(find.text('Drukqs'), findsOneWidget); - expect(find.text('Cancel'), findsOneWidget); - }); - - testWidgets('renders completed row with Listen CTA', (t) async { - await t.pumpWidget(_harness(const [_completedTrackWithMatch])); - await t.pumpAndSettle(); - expect(find.text('Roygbiv'), findsOneWidget); - expect(find.text('Listen'), findsOneWidget); - // Ingest progress copy - expect(find.text('Track ingested'), findsOneWidget); - }); - - testWidgets('renders rejected row with notes; no CTA', (t) async { - await t.pumpWidget(_harness(const [_rejectedWithNotes])); - await t.pumpAndSettle(); - expect(find.text('Some Artist'), findsOneWidget); - expect(find.text('Not in MusicBrainz'), findsOneWidget); - expect(find.text('Cancel'), findsNothing); - expect(find.text('Listen'), findsNothing); - }); - - testWidgets('Cancel button opens confirm dialog', (t) async { - await t.pumpWidget(_harness(const [_pendingAlbum])); - await t.pumpAndSettle(); - await t.tap(find.text('Cancel')); - await t.pumpAndSettle(); - expect(find.text('Cancel request?'), findsOneWidget); - expect(find.text('Cancel "Drukqs"?'), findsOneWidget); - // Dismiss with Keep - await t.tap(find.text('Keep')); - await t.pumpAndSettle(); - expect(find.text('Cancel request?'), findsNothing); - }); -} diff --git a/flutter_client/test/settings/appearance_section_test.dart b/flutter_client/test/settings/appearance_section_test.dart deleted file mode 100644 index 81f6a183..00000000 --- a/flutter_client/test/settings/appearance_section_test.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; - -import 'package:minstrel/auth/auth_provider.dart'; -import 'package:minstrel/settings/settings_screen.dart'; -import 'package:minstrel/theme/theme_data.dart'; -import 'package:minstrel/theme/theme_mode_provider.dart'; - -class _MockStorage extends Mock implements FlutterSecureStorage {} - -class _StubTheme extends ThemeModeController { - _StubTheme(this._initial); - final AppThemeMode _initial; - @override - Future build() async => _initial; -} - -void main() { - late _MockStorage storage; - - setUpAll(() { - registerFallbackValue(''); - }); - - setUp(() { - storage = _MockStorage(); - when(() => storage.read(key: any(named: 'key'))).thenAnswer((_) async => null); - when(() => storage.write(key: any(named: 'key'), value: any(named: 'value'))) - .thenAnswer((_) async {}); - }); - - testWidgets('renders three appearance radios + reflects the current mode', (t) async { - await t.pumpWidget(ProviderScope( - overrides: [ - secureStorageProvider.overrideWithValue(storage), - themeModeProvider.overrideWith(() => _StubTheme(AppThemeMode.dark)), - ], - child: MaterialApp( - theme: buildDarkTheme(), - home: const SettingsScreen(), - ), - )); - await t.pumpAndSettle(); - - expect(find.byKey(const Key('appearance_system')), findsOneWidget); - expect(find.byKey(const Key('appearance_light')), findsOneWidget); - expect(find.byKey(const Key('appearance_dark')), findsOneWidget); - - // The RadioGroup ancestor owns groupValue post-Flutter-3.32; the - // selected mode is read off it rather than off individual tiles. - final group = t.widget>( - find.byType(RadioGroup), - ); - expect(group.groupValue, AppThemeMode.dark); - final darkRadio = t.widget>( - find.byKey(const Key('appearance_dark')), - ); - expect(darkRadio.value, AppThemeMode.dark); - }); - - testWidgets('tapping the light radio writes "light" to storage', (t) async { - await t.pumpWidget(ProviderScope( - overrides: [ - secureStorageProvider.overrideWithValue(storage), - themeModeProvider.overrideWith(() => _StubTheme(AppThemeMode.system)), - ], - child: MaterialApp( - theme: buildDarkTheme(), - home: const SettingsScreen(), - ), - )); - await t.pumpAndSettle(); - - await t.tap(find.byKey(const Key('appearance_light'))); - await t.pumpAndSettle(); - - verify(() => storage.write(key: 'theme_mode', value: 'light')).called(1); - }); -} diff --git a/flutter_client/test/settings/storage_section_test.dart b/flutter_client/test/settings/storage_section_test.dart deleted file mode 100644 index 1722b722..00000000 --- a/flutter_client/test/settings/storage_section_test.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; - -import 'package:minstrel/auth/auth_provider.dart'; -import 'package:minstrel/settings/storage_section.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -class _MockStorage extends Mock implements FlutterSecureStorage {} - -void main() { - setUpAll(() { - registerFallbackValue(''); - }); - - testWidgets('renders Storage heading + all controls', (t) async { - final storage = _MockStorage(); - when(() => storage.read(key: any(named: 'key'))) - .thenAnswer((_) async => null); - when(() => storage.write( - key: any(named: 'key'), value: any(named: 'value'))) - .thenAnswer((_) async {}); - - await t.pumpWidget(ProviderScope( - overrides: [secureStorageProvider.overrideWithValue(storage)], - child: MaterialApp( - theme: buildDarkTheme(), - home: const Scaffold(body: StorageSection()), - ), - )); - await t.pumpAndSettle(); - - expect(find.text('Storage'), findsOneWidget); - expect(find.text('Liked cache limit'), findsOneWidget); - expect(find.text('Recently-played cache limit'), findsOneWidget); - expect(find.text('Pre-fetch ahead'), findsOneWidget); - expect(find.byKey(const Key('clear_cache_button')), findsOneWidget); - expect(find.byKey(const Key('sync_now_button')), findsOneWidget); - expect(find.byKey(const Key('cache_liked_toggle')), findsOneWidget); - expect(find.byKey(const Key('liked_cap_selector')), findsOneWidget); - expect(find.byKey(const Key('rolling_cap_selector')), findsOneWidget); - expect(find.byKey(const Key('prefetch_selector')), findsOneWidget); - }); - - testWidgets('Clear cache button shows confirm dialog', (t) async { - final storage = _MockStorage(); - when(() => storage.read(key: any(named: 'key'))) - .thenAnswer((_) async => null); - when(() => storage.write( - key: any(named: 'key'), value: any(named: 'value'))) - .thenAnswer((_) async {}); - - await t.pumpWidget(ProviderScope( - overrides: [secureStorageProvider.overrideWithValue(storage)], - child: MaterialApp( - theme: buildDarkTheme(), - home: const Scaffold(body: StorageSection()), - ), - )); - await t.pumpAndSettle(); - - await t.tap(find.byKey(const Key('clear_cache_button'))); - await t.pumpAndSettle(); - - expect(find.text('Clear cache?'), findsOneWidget); - // Cancel returns to the original state. - await t.tap(find.text('Cancel')); - await t.pumpAndSettle(); - expect(find.text('Clear cache?'), findsNothing); - }); -} diff --git a/flutter_client/test/shared/delayed_loading_test.dart b/flutter_client/test/shared/delayed_loading_test.dart deleted file mode 100644 index 2273633d..00000000 --- a/flutter_client/test/shared/delayed_loading_test.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/shared/delayed_loading.dart'; - -void main() { - testWidgets('renders nothing for the first 100ms of loading', (tester) async { - await tester.pumpWidget(const MaterialApp( - home: DelayedLoading( - isLoading: true, - delay: Duration(milliseconds: 200), - whileDelayed: Text('LOADING'), - whenReady: Text('READY'), - ), - )); - await tester.pump(const Duration(milliseconds: 100)); - expect(find.text('LOADING'), findsNothing); - expect(find.text('READY'), findsNothing); - }); - - testWidgets('renders whileDelayed after delay elapses', (tester) async { - await tester.pumpWidget(const MaterialApp( - home: DelayedLoading( - isLoading: true, - delay: Duration(milliseconds: 200), - whileDelayed: Text('LOADING'), - whenReady: Text('READY'), - ), - )); - await tester.pump(const Duration(milliseconds: 250)); - expect(find.text('LOADING'), findsOneWidget); - }); - - testWidgets('renders whenReady when not loading', (tester) async { - await tester.pumpWidget(const MaterialApp( - home: DelayedLoading( - isLoading: false, - whileDelayed: Text('LOADING'), - whenReady: Text('READY'), - ), - )); - expect(find.text('READY'), findsOneWidget); - }); - - testWidgets('resets timer when isLoading flips false then true', (tester) async { - await tester.pumpWidget(const MaterialApp( - home: DelayedLoading( - isLoading: true, - delay: Duration(milliseconds: 200), - whileDelayed: Text('LOADING'), - whenReady: Text('READY'), - ), - )); - await tester.pump(const Duration(milliseconds: 150)); - // Switch to not-loading, then back to loading. - await tester.pumpWidget(const MaterialApp( - home: DelayedLoading( - isLoading: false, - whileDelayed: Text('LOADING'), - whenReady: Text('READY'), - ), - )); - await tester.pumpWidget(const MaterialApp( - home: DelayedLoading( - isLoading: true, - delay: Duration(milliseconds: 200), - whileDelayed: Text('LOADING'), - whenReady: Text('READY'), - ), - )); - // Original 150ms shouldn't carry over — at 100ms past restart - // we should still see nothing. - await tester.pump(const Duration(milliseconds: 100)); - expect(find.text('LOADING'), findsNothing); - }); -} diff --git a/flutter_client/test/shared/main_app_bar_actions_test.dart b/flutter_client/test/shared/main_app_bar_actions_test.dart deleted file mode 100644 index 495369ae..00000000 --- a/flutter_client/test/shared/main_app_bar_actions_test.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/auth/auth_provider.dart'; -import 'package:minstrel/models/user.dart'; -import 'package:minstrel/shared/widgets/main_app_bar_actions.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -class _StubAuth extends AuthController { - _StubAuth(this._user); - final User? _user; - @override - Future build() async => _user; -} - -Widget _harness({required User? user, required String currentRoute}) { - return ProviderScope( - overrides: [ - authControllerProvider.overrideWith(() => _StubAuth(user)), - ], - child: MaterialApp( - theme: buildThemeData(), - home: Scaffold( - appBar: AppBar( - actions: [MainAppBarActions(currentRoute: currentRoute)], - ), - ), - ), - ); -} - -void main() { - const adminUser = User(id: 'u1', username: 'admin', isAdmin: true); - const regularUser = User(id: 'u2', username: 'alice', isAdmin: false); - - testWidgets('renders Library + Search primary icons + kebab on /home', - (t) async { - await t.pumpWidget(_harness(user: regularUser, currentRoute: '/home')); - await t.pumpAndSettle(); - expect(find.byKey(const Key('app_bar_library')), findsOneWidget); - expect(find.byKey(const Key('app_bar_search')), findsOneWidget); - expect(find.byKey(const Key('app_bar_home')), findsNothing); - expect(find.byKey(const Key('app_bar_overflow')), findsOneWidget); - }); - - testWidgets('suppresses Library icon when currentRoute is /library', - (t) async { - await t.pumpWidget(_harness(user: regularUser, currentRoute: '/library')); - await t.pumpAndSettle(); - expect(find.byKey(const Key('app_bar_library')), findsNothing); - expect(find.byKey(const Key('app_bar_home')), findsOneWidget); - }); - - testWidgets('overflow includes Admin only for admin users', (t) async { - await t.pumpWidget(_harness(user: adminUser, currentRoute: '/home')); - await t.pumpAndSettle(); - await t.tap(find.byKey(const Key('app_bar_overflow'))); - await t.pumpAndSettle(); - expect(find.text('Admin'), findsOneWidget); - }); - - testWidgets('overflow omits Admin for non-admin users', (t) async { - await t.pumpWidget(_harness(user: regularUser, currentRoute: '/home')); - await t.pumpAndSettle(); - await t.tap(find.byKey(const Key('app_bar_overflow'))); - await t.pumpAndSettle(); - expect(find.text('Admin'), findsNothing); - }); -} diff --git a/flutter_client/test/shared/widgets/track_actions/add_to_playlist_sheet_test.dart b/flutter_client/test/shared/widgets/track_actions/add_to_playlist_sheet_test.dart deleted file mode 100644 index 9c5bb10e..00000000 --- a/flutter_client/test/shared/widgets/track_actions/add_to_playlist_sheet_test.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/api/endpoints/playlists.dart'; -import 'package:minstrel/models/playlist.dart'; -import 'package:minstrel/playlists/playlists_provider.dart'; -import 'package:minstrel/shared/widgets/track_actions/add_to_playlist_sheet.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -const _userPlaylist = Playlist( - id: 'p1', - userId: 'u1', - name: 'Road trip', - description: '', - isPublic: false, - systemVariant: null, - trackCount: 12, - coverUrl: '', - ownerUsername: 'alice', - createdAt: '2026-05-01T00:00:00Z', - updatedAt: '2026-05-01T00:00:00Z', -); - -const _systemPlaylist = Playlist( - id: 'p2', - userId: 'u1', - name: 'For You', - description: '', - isPublic: false, - systemVariant: 'for_you', - trackCount: 75, - coverUrl: '', - ownerUsername: 'alice', - createdAt: '2026-05-01T00:00:00Z', - updatedAt: '2026-05-01T00:00:00Z', -); - -Widget _harness(PlaylistsList lists) { - return ProviderScope( - overrides: [ - playlistsListProvider('user').overrideWith((ref) => Stream.value(lists)), - ], - child: MaterialApp( - theme: buildThemeData(), - home: Builder(builder: (ctx) { - return Scaffold( - body: Center( - child: ElevatedButton( - onPressed: () => AddToPlaylistSheet.show(ctx), - child: const Text('open'), - ), - ), - ); - }), - ), - ); -} - -void main() { - testWidgets('renders user playlists, hides system ones', (t) async { - await t.pumpWidget(_harness( - const PlaylistsList(owned: [_userPlaylist, _systemPlaylist], public: []), - )); - await t.tap(find.text('open')); - await t.pumpAndSettle(); - expect(find.text('Road trip'), findsOneWidget); - expect(find.text('For You'), findsNothing); - }); - - testWidgets('empty state when no user playlists', (t) async { - await t.pumpWidget(_harness( - const PlaylistsList(owned: [_systemPlaylist], public: []), - )); - await t.tap(find.text('open')); - await t.pumpAndSettle(); - expect( - find.text("You haven't created any playlists yet."), - findsOneWidget, - ); - }); - - testWidgets('tapping a row pops with playlist id', (t) async { - String? picked; - await t.pumpWidget(ProviderScope( - overrides: [ - playlistsListProvider('user').overrideWith( - (ref) => Stream.value( - const PlaylistsList(owned: [_userPlaylist], public: [])), - ), - ], - child: MaterialApp( - theme: buildThemeData(), - home: Builder(builder: (ctx) { - return Scaffold( - body: Center( - child: ElevatedButton( - onPressed: () async { - picked = await AddToPlaylistSheet.show(ctx); - }, - child: const Text('open'), - ), - ), - ); - }), - ), - )); - await t.tap(find.text('open')); - await t.pumpAndSettle(); - await t.tap(find.byKey(const Key('add_to_playlist_p1'))); - await t.pumpAndSettle(); - expect(picked, 'p1'); - }); -} diff --git a/flutter_client/test/shared/widgets/track_actions/hide_track_sheet_test.dart b/flutter_client/test/shared/widgets/track_actions/hide_track_sheet_test.dart deleted file mode 100644 index 4cca8654..00000000 --- a/flutter_client/test/shared/widgets/track_actions/hide_track_sheet_test.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/shared/widgets/track_actions/hide_track_sheet.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -void main() { - Future<({String reason, String notes})?> openAndPick( - WidgetTester t, { - required String chipKey, - String notes = '', - }) async { - Future<({String reason, String notes})?>? future; - await t.pumpWidget(MaterialApp( - theme: buildThemeData(), - home: Builder(builder: (ctx) { - return Scaffold( - body: Center( - child: ElevatedButton( - onPressed: () { - future = HideTrackSheet.show(ctx); - }, - child: const Text('open'), - ), - ), - ); - }), - )); - await t.tap(find.text('open')); - await t.pumpAndSettle(); - if (chipKey != 'hide_reason_bad_rip') { - await t.tap(find.byKey(Key(chipKey))); - await t.pumpAndSettle(); - } - if (notes.isNotEmpty) { - await t.enterText(find.byKey(const Key('hide_notes_input')), notes); - } - await t.tap(find.byKey(const Key('hide_confirm'))); - await t.pumpAndSettle(); - return future; - } - - testWidgets('default reason is bad_rip', (t) async { - final result = await openAndPick(t, chipKey: 'hide_reason_bad_rip'); - expect(result, isNotNull); - expect(result!.reason, 'bad_rip'); - expect(result.notes, ''); - }); - - testWidgets('selecting wrong_tags returns wrong_tags', (t) async { - final result = await openAndPick(t, chipKey: 'hide_reason_wrong_tags'); - expect(result?.reason, 'wrong_tags'); - }); - - testWidgets('notes get trimmed and returned', (t) async { - final result = await openAndPick( - t, - chipKey: 'hide_reason_other', - notes: ' cracks at 2:13 ', - ); - expect(result?.reason, 'other'); - expect(result?.notes, 'cracks at 2:13'); - }); - - testWidgets('cancel returns null', (t) async { - Future<({String reason, String notes})?>? future; - await t.pumpWidget(MaterialApp( - theme: buildThemeData(), - home: Builder(builder: (ctx) { - return Scaffold( - body: Center( - child: ElevatedButton( - onPressed: () { future = HideTrackSheet.show(ctx); }, - child: const Text('open'), - ), - ), - ); - }), - )); - await t.tap(find.text('open')); - await t.pumpAndSettle(); - await t.tap(find.text('Cancel')); - await t.pumpAndSettle(); - expect(await future, isNull); - }); -} diff --git a/flutter_client/test/shared/widgets/track_actions/track_actions_sheet_test.dart b/flutter_client/test/shared/widgets/track_actions/track_actions_sheet_test.dart deleted file mode 100644 index 9ab666e5..00000000 --- a/flutter_client/test/shared/widgets/track_actions/track_actions_sheet_test.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/likes/likes_provider.dart'; -import 'package:minstrel/models/quarantine_mine.dart'; -import 'package:minstrel/models/track.dart'; -import 'package:minstrel/quarantine/quarantine_provider.dart'; -import 'package:minstrel/shared/widgets/track_actions/track_actions_sheet.dart'; -import 'package:minstrel/theme/theme_data.dart'; - -const _track = TrackRef( - id: 't1', - title: 'Roygbiv', - albumId: 'a1', - albumTitle: 'Geogaddi', - artistId: 'ar1', - artistName: 'Boards of Canada', - durationSec: 137, - trackNumber: 4, - streamUrl: '', -); - -class _StubQuarantine extends MyQuarantineController { - @override - Future> build() async => const []; -} - -Widget _harness({bool hideQueueActions = false, Set? likedTracks}) { - return ProviderScope( - overrides: [ - likedIdsProvider.overrideWith((ref) => Stream.value(LikedIds( - artists: const {}, - albums: const {}, - tracks: likedTracks ?? const {}, - ))), - myQuarantineProvider.overrideWith(() => _StubQuarantine()), - ], - child: MaterialApp( - theme: buildThemeData(), - home: Builder(builder: (ctx) { - return Scaffold( - body: Center( - child: ElevatedButton( - onPressed: () => TrackActionsSheet.show( - ctx, - _track, - hideQueueActions: hideQueueActions, - ), - child: const Text('open'), - ), - ), - ); - }), - ), - ); -} - -void main() { - testWidgets('renders all 7 items by default', (t) async { - await t.pumpWidget(_harness()); - await t.tap(find.text('open')); - await t.pumpAndSettle(); - expect(find.byKey(const Key('track_actions_play_next')), findsOneWidget); - expect(find.byKey(const Key('track_actions_enqueue')), findsOneWidget); - expect(find.byKey(const Key('track_actions_like')), findsOneWidget); - expect(find.byKey(const Key('track_actions_add_to_playlist')), findsOneWidget); - expect(find.byKey(const Key('track_actions_go_to_album')), findsOneWidget); - expect(find.byKey(const Key('track_actions_go_to_artist')), findsOneWidget); - expect(find.byKey(const Key('track_actions_hide')), findsOneWidget); - }); - - testWidgets('hideQueueActions suppresses Play next + Add to queue', (t) async { - await t.pumpWidget(_harness(hideQueueActions: true)); - await t.tap(find.text('open')); - await t.pumpAndSettle(); - expect(find.byKey(const Key('track_actions_play_next')), findsNothing); - expect(find.byKey(const Key('track_actions_enqueue')), findsNothing); - expect(find.byKey(const Key('track_actions_like')), findsOneWidget); - }); - - testWidgets('like label flips to Unlike when track is liked', (t) async { - await t.pumpWidget(_harness(likedTracks: const {'t1'})); - await t.tap(find.text('open')); - await t.pumpAndSettle(); - expect(find.text('Unlike'), findsOneWidget); - expect(find.text('Like'), findsNothing); - }); -} diff --git a/flutter_client/test/smoke_test.dart b/flutter_client/test/smoke_test.dart deleted file mode 100644 index 2c66efcc..00000000 --- a/flutter_client/test/smoke_test.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; - -import 'package:minstrel/app.dart'; -import 'package:minstrel/auth/auth_provider.dart'; - -class _NullStorage extends Mock implements FlutterSecureStorage {} - -void main() { - setUpAll(() { - registerFallbackValue(''); - }); - - testWidgets('cold launch lands on server-url screen when no URL stored', (tester) async { - // Override secureStorageProvider so all reads return null deterministically. - // Without this the real flutter_secure_storage MethodChannel hangs in - // widget tests (no platform channel), so the GoRouter redirect never - // resolves and ServerUrlScreen never renders. - final storage = _NullStorage(); - when(() => storage.read(key: any(named: 'key'))).thenAnswer((_) async => null); - - await tester.pumpWidget(ProviderScope( - overrides: [ - secureStorageProvider.overrideWithValue(storage), - ], - child: const MinstrelApp(), - )); - await tester.pumpAndSettle(); - expect(find.text('Connect to your Minstrel'), findsOneWidget); - }); -} diff --git a/flutter_client/test/theme/theme_extension_test.dart b/flutter_client/test/theme/theme_extension_test.dart deleted file mode 100644 index 873d8f73..00000000 --- a/flutter_client/test/theme/theme_extension_test.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:minstrel/theme/theme_extension.dart'; -import 'package:minstrel/theme/tokens.dart'; - -void main() { - test('FabledSwordTheme.dark uses dark surface tokens', () { - final fs = FabledSwordTheme.dark(); - expect(fs.obsidian, FabledSwordDarkTokens.obsidian); - expect(fs.iron, FabledSwordDarkTokens.iron); - expect(fs.parchment, FabledSwordDarkTokens.parchment); - }); - - test('FabledSwordTheme.light uses light surface tokens', () { - final fs = FabledSwordTheme.light(); - expect(fs.obsidian, FabledSwordLightTokens.obsidian); - expect(fs.iron, FabledSwordLightTokens.iron); - expect(fs.parchment, FabledSwordLightTokens.parchment); - }); - - test('flat tokens (accent, moss, etc.) are shared between factories', () { - final dark = FabledSwordTheme.dark(); - final light = FabledSwordTheme.light(); - expect(dark.accent, light.accent); - expect(dark.moss, light.moss); - expect(dark.bronze, light.bronze); - expect(dark.oxblood, light.oxblood); - expect(dark.warning, light.warning); - expect(dark.error, light.error); - expect(dark.info, light.info); - }); - - test('fromTokens() back-compat alias returns the dark theme', () { - final alias = FabledSwordTheme.fromTokens(); - final dark = FabledSwordTheme.dark(); - expect(alias.obsidian, dark.obsidian); - expect(alias.parchment, dark.parchment); - }); -} diff --git a/flutter_client/test/theme/theme_mode_provider_test.dart b/flutter_client/test/theme/theme_mode_provider_test.dart deleted file mode 100644 index 05b63da1..00000000 --- a/flutter_client/test/theme/theme_mode_provider_test.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; - -import 'package:minstrel/auth/auth_provider.dart'; -import 'package:minstrel/theme/theme_mode_provider.dart'; - -class _MockStorage extends Mock implements FlutterSecureStorage {} - -void main() { - late _MockStorage storage; - - setUpAll(() { - registerFallbackValue(''); - }); - - setUp(() { - storage = _MockStorage(); - when(() => storage.write(key: any(named: 'key'), value: any(named: 'value'))) - .thenAnswer((_) async {}); - }); - - test('defaults to system when nothing stored', () async { - when(() => storage.read(key: 'theme_mode')).thenAnswer((_) async => null); - final container = ProviderContainer(overrides: [ - secureStorageProvider.overrideWithValue(storage), - ]); - addTearDown(container.dispose); - - final mode = await container.read(themeModeProvider.future); - expect(mode, AppThemeMode.system); - }); - - test('reads stored "dark" → AppThemeMode.dark', () async { - when(() => storage.read(key: 'theme_mode')).thenAnswer((_) async => 'dark'); - final container = ProviderContainer(overrides: [ - secureStorageProvider.overrideWithValue(storage), - ]); - addTearDown(container.dispose); - - final mode = await container.read(themeModeProvider.future); - expect(mode, AppThemeMode.dark); - }); - - test('reads stored "light" → AppThemeMode.light', () async { - when(() => storage.read(key: 'theme_mode')).thenAnswer((_) async => 'light'); - final container = ProviderContainer(overrides: [ - secureStorageProvider.overrideWithValue(storage), - ]); - addTearDown(container.dispose); - - final mode = await container.read(themeModeProvider.future); - expect(mode, AppThemeMode.light); - }); - - test('set(.light) writes "light" to storage and updates state', () async { - when(() => storage.read(key: 'theme_mode')).thenAnswer((_) async => null); - final container = ProviderContainer(overrides: [ - secureStorageProvider.overrideWithValue(storage), - ]); - addTearDown(container.dispose); - - await container.read(themeModeProvider.future); - await container.read(themeModeProvider.notifier).set(AppThemeMode.light); - - verify(() => storage.write(key: 'theme_mode', value: 'light')).called(1); - expect(container.read(themeModeProvider).value, AppThemeMode.light); - }); - - test('materialMode extension maps each enum to ThemeMode', () { - expect(AppThemeMode.system.materialMode.name, 'system'); - expect(AppThemeMode.dark.materialMode.name, 'dark'); - expect(AppThemeMode.light.materialMode.name, 'light'); - }); -} diff --git a/flutter_client/test/update/client_update_provider_test.dart b/flutter_client/test/update/client_update_provider_test.dart deleted file mode 100644 index 9433c915..00000000 --- a/flutter_client/test/update/client_update_provider_test.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:minstrel/update/client_update_provider.dart'; - -void main() { - group('isVersionNewer (semver path)', () { - test('strictly newer', () { - expect(isVersionNewer('0.1.1', '0.1.0'), isTrue); - expect(isVersionNewer('0.2.0', '0.1.99'), isTrue); - expect(isVersionNewer('1.0.0', '0.99.99'), isTrue); - }); - - test('equal returns false', () { - expect(isVersionNewer('0.1.0', '0.1.0'), isFalse); - }); - - test('older returns false', () { - expect(isVersionNewer('0.1.0', '0.1.1'), isFalse); - expect(isVersionNewer('0.1.0', '0.2.0'), isFalse); - }); - - test('strips leading v on either side', () { - expect(isVersionNewer('v0.1.1', '0.1.0'), isTrue); - expect(isVersionNewer('0.1.1', 'v0.1.0'), isTrue); - expect(isVersionNewer('v0.1.0', 'v0.1.0'), isFalse); - }); - - test('zero-pads shorter version when comparing', () { - // "1.2" treated as "1.2.0" — shorter side gets zero-padded so - // comparison is component-wise. - expect(isVersionNewer('1.2.1', '1.2'), isTrue); - expect(isVersionNewer('1.2', '1.2.1'), isFalse); - expect(isVersionNewer('1.2', '1.2.0'), isFalse); - }); - }); - - group('isVersionNewer (date-style versions)', () { - // Date tags are 3- or 4-part: 2026.05.10 or 2026.05.10.1. The - // numeric comparator parses each component as int (so "05" == 5) - // and zero-pads the shorter side. - test('newer date → newer', () { - expect(isVersionNewer('2026.05.10', '2026.05.09'), isTrue); - }); - test('older date → not newer', () { - expect(isVersionNewer('2026.05.09', '2026.05.10'), isFalse); - }); - test('equal date → not newer', () { - expect(isVersionNewer('2026.05.10', '2026.05.10'), isFalse); - }); - test('leading zeros do not affect ordering', () { - // "2026.5.11" and "2026.05.11" parse to the same components. - // 12 > 5 numerically so month rollover stays correct. - expect(isVersionNewer('2026.5.11', '2026.05.11'), isFalse); - expect(isVersionNewer('2026.12.1', '2026.5.31'), isTrue); - }); - test('4-part build suffix breaks the tie', () { - // 2026.05.10 vs 2026.05.10.1 — first three equal, server has a - // 4th component (1), client zero-pads → server is newer. - expect(isVersionNewer('2026.05.10.1', '2026.05.10'), isTrue); - expect(isVersionNewer('2026.05.10', '2026.05.10.0'), isFalse); - }); - }); - - group('isVersionNewer (truly non-semver fallback)', () { - // Branch-name-style strings (no version structure) hit the - // try/catch fallback. Any string difference reads as "newer" so - // operators see _something_ rather than silently miss updates. - test('different unparseable strings → newer', () { - expect(isVersionNewer('main', 'dev'), isTrue); - expect(isVersionNewer('dev', 'main'), isTrue); - }); - test('equal unparseable strings → not newer', () { - expect(isVersionNewer('main', 'main'), isFalse); - }); - }); -} diff --git a/flutter_client/tool/gen_tokens.dart b/flutter_client/tool/gen_tokens.dart deleted file mode 100644 index 7540da63..00000000 --- a/flutter_client/tool/gen_tokens.dart +++ /dev/null @@ -1,97 +0,0 @@ -// Reads shared/fabledsword.tokens.json and emits lib/theme/tokens.dart. -// Run via `dart run tool/gen_tokens.dart`. The generated file is -// committed (CI validates it matches the JSON to catch drift). -// -// Emits three flat classes mirroring the source JSON: -// - FabledSwordDarkTokens (surface colors for dark mode) -// - FabledSwordLightTokens (surface colors for light mode) -// - FabledSwordFlatTokens (colors that don't change between modes, -// plus radii + font families) -// -// Plus a back-compat FabledSwordTokens alias that re-exports the dark -// surface colors + flat — kept so any code that still reads -// FabledSwordTokens.obsidian (etc.) continues to work during the -// migration. Safe to delete once theme_extension.dart switches over. -import 'dart:convert'; -import 'dart:io'; - -void main() { - final jsonFile = File('shared/fabledsword.tokens.json'); - final tokens = jsonDecode(jsonFile.readAsStringSync()) as Map; - - final colorsRoot = (tokens['colors'] as Map).cast(); - final dark = (colorsRoot['dark'] as Map).cast(); - final light = (colorsRoot['light'] as Map).cast(); - final flat = (colorsRoot['flat'] as Map).cast(); - final radii = (tokens['radii'] as Map).cast(); - final fonts = (tokens['fonts'] as Map).cast(); - - final out = StringBuffer() - ..writeln('// GENERATED — do not edit. Source: shared/fabledsword.tokens.json') - ..writeln('// Run `dart run tool/gen_tokens.dart` to regenerate.') - ..writeln("import 'package:flutter/material.dart';") - ..writeln(); - - void writeColorClass(String className, Map colors) { - out.writeln('class $className {'); - colors.forEach((name, hex) { - final value = hex.replaceFirst('#', '0xFF'); - out.writeln(' static const Color ${_camel(name)} = Color($value);'); - }); - out.writeln('}'); - out.writeln(); - } - - writeColorClass('FabledSwordDarkTokens', dark); - writeColorClass('FabledSwordLightTokens', light); - - out.writeln('class FabledSwordFlatTokens {'); - flat.forEach((name, hex) { - final value = hex.replaceFirst('#', '0xFF'); - out.writeln(' static const Color ${_camel(name)} = Color($value);'); - }); - radii.forEach((name, px) { - final v = px.replaceAll('px', ''); - out.writeln(' static const double radius${_pascal(name)} = $v;'); - }); - fonts.forEach((name, family) { - out.writeln(' static const String font${_pascal(name)} = ${jsonEncode(family)};'); - }); - out.writeln('}'); - out.writeln(); - - // Back-compat alias: dark surface colors + flat. Lets existing - // call sites that read FabledSwordTokens.obsidian etc. keep working - // until they're migrated to the explicit dark/light/flat classes. - out.writeln('/// Back-compat alias — dark surface tokens + flat. Prefer the explicit'); - out.writeln('/// FabledSwordDarkTokens / FabledSwordLightTokens / FabledSwordFlatTokens.'); - out.writeln('class FabledSwordTokens {'); - dark.forEach((name, hex) { - final value = hex.replaceFirst('#', '0xFF'); - out.writeln(' static const Color ${_camel(name)} = Color($value);'); - }); - flat.forEach((name, hex) { - final value = hex.replaceFirst('#', '0xFF'); - out.writeln(' static const Color ${_camel(name)} = Color($value);'); - }); - radii.forEach((name, px) { - final v = px.replaceAll('px', ''); - out.writeln(' static const double radius${_pascal(name)} = $v;'); - }); - fonts.forEach((name, family) { - out.writeln(' static const String font${_pascal(name)} = ${jsonEncode(family)};'); - }); - out.writeln('}'); - - File('lib/theme/tokens.dart').writeAsStringSync(out.toString()); - stdout.writeln('wrote lib/theme/tokens.dart'); -} - -// Hyphens become camelCase boundaries: on-action → onAction. -String _camel(String s) { - final parts = s.split('-'); - return parts.first + - parts.skip(1).map((p) => p[0].toUpperCase() + p.substring(1)).join(); -} - -String _pascal(String s) => s[0].toUpperCase() + s.substring(1); diff --git a/flutter_client/tool/sync_shared.sh b/flutter_client/tool/sync_shared.sh deleted file mode 100755 index d1931ac0..00000000 --- a/flutter_client/tool/sync_shared.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash -# Copies shared assets from web/ into flutter_client/. Run before -# `flutter build` and as part of CI. Idempotent. -set -euo pipefail - -cd "$(dirname "$0")/.." - -mkdir -p shared assets/svg -cp ../web/src/lib/styles/tokens.json shared/fabledsword.tokens.json -cp ../web/src/lib/styles/error-copy.json assets/error-copy.json -cp ../web/static/placeholders/album-fallback.svg assets/svg/album-fallback.svg - -echo "shared assets synced from web/" diff --git a/internal/api/admin_library_missing.go b/internal/api/admin_library_missing.go new file mode 100644 index 00000000..abbafb0b --- /dev/null +++ b/internal/api/admin_library_missing.go @@ -0,0 +1,250 @@ +package api + +import ( + "net/http" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition" +) + +// missingTrackView is one track whose file the scan could not find. +// +// The text fields come from the tracks row, not from the filesystem, which is +// the point: the recording is still a known thing with a title, an artist and +// a play history — only its bytes are absent. LastPlayedAt is nullable because +// plenty of missing files were never played, and that is exactly the signal an +// operator wants when deciding whether to bother re-acquiring one. +type missingTrackView struct { + TrackID string `json:"track_id"` + Title string `json:"title"` + ArtistID string `json:"artist_id"` + ArtistName string `json:"artist_name"` + AlbumID string `json:"album_id"` + AlbumTitle string `json:"album_title"` + FilePath string `json:"file_path"` + DurationSec int32 `json:"duration_sec"` + MissingSince string `json:"missing_since"` + LastPlayedAt *string `json:"last_played_at"` +} + +// reacquisitionStateView is what the sweeper has done about one album +// (milestone #290), attached to the group so the operator can tell "nothing +// has happened yet" from "asked twice, still nothing" without cross-checking +// the Requests queue. +// +// NextAttemptAt is computed rather than stored: the schedule is a function of +// the attempt count and the current settings, so persisting it would go stale +// the moment an operator edited the backoff. +type reacquisitionStateView struct { + Attempts int `json:"attempts"` + LastAttemptAt *string `json:"last_attempt_at"` + NextAttemptAt *string `json:"next_attempt_at"` + GaveUpAt *string `json:"gave_up_at"` +} + +// missingGroupView is a directory's worth of missing tracks. +// +// Grouping is the whole ergonomic argument for this surface. The case that +// produced #2523 was three reorganised albums showing up as ~40 individually +// missing files; presented flat that reads as forty problems, presented by +// folder it reads as three. MissingSince is the EARLIEST mark in the group, +// so a directory sorts and reads by when it first went away. +type missingGroupView struct { + Directory string `json:"directory"` + MissingSince string `json:"missing_since"` + Tracks []missingTrackView `json:"tracks"` + // Nil when nothing has been attempted for this group's album — the + // common case for a folder that just went missing, and distinct from + // an attempts:0 record, which cannot occur. + Reacquisition *reacquisitionStateView `json:"reacquisition"` +} + +// adminMissingResponse is the paged envelope. Total counts TRACKS, not +// groups — it is what the nav badge shows, and "12 files missing" is the +// honest number even when they happen to sit in two folders. +type adminMissingResponse struct { + Total int64 `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Groups []missingGroupView `json:"groups"` +} + +// handleListMissingTracks implements GET /api/admin/library/missing. +// +// Read-only by design. Nothing on this surface deletes a track: a missing file +// keeps its row, its history and its likes because it may come back, and if it +// comes back renamed the scanner adopts it (#2528). The surface exists so an +// operator can SEE what the library has lost and act on it deliberately. +func (h *handlers) handleListMissingTracks(w http.ResponseWriter, r *http.Request) { + limit, offset, err := parsePaging(r.URL.Query()) + if err != nil { + writeAdminJSONErr(w, http.StatusBadRequest, "invalid_paging") + return + } + + q := dbq.New(h.pool) + total, err := q.CountMissingTracks(r.Context()) + if err != nil { + h.logger.Error("admin: count missing tracks", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") + return + } + rows, err := q.ListMissingTracks(r.Context(), dbq.ListMissingTracksParams{ + PageLimit: int32(limit), + PageOffset: int32(offset), + }) + if err != nil { + h.logger.Error("admin: list missing tracks", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") + return + } + + groups := groupMissingByDirectory(rows) + h.attachReacquisitionState(r, q, rows, groups) + + out := adminMissingResponse{ + Total: total, + Limit: limit, + Offset: offset, + Groups: groups, + } + writeJSON(w, http.StatusOK, out) +} + +// groupMissingByDirectory folds the ordered rows into per-directory groups. +// +// It relies on ListMissingTracks ordering by directory, so a simple run-length +// fold is enough and no map is needed — which also preserves the query's +// ordering in the response instead of Go's random map iteration. A page +// boundary can split one directory across two pages; that is accepted rather +// than paging by group, because the alternative costs a second query to find +// the page's directories and this surface's realistic N is small. +func groupMissingByDirectory(rows []dbq.ListMissingTracksRow) []missingGroupView { + groups := make([]missingGroupView, 0, 8) + for _, row := range rows { + t := missingTrackView{ + TrackID: uuidToString(row.ID), + Title: row.Title, + ArtistID: uuidToString(row.ArtistID), + ArtistName: row.ArtistName, + AlbumID: uuidToString(row.AlbumID), + AlbumTitle: row.AlbumTitle, + FilePath: row.FilePath, + DurationSec: row.DurationMs / 1000, + MissingSince: formatTimestamp(row.MissingSince), + } + if row.LastPlayedAt.Valid { + s := formatTimestamp(row.LastPlayedAt) + t.LastPlayedAt = &s + } + + if n := len(groups); n > 0 && groups[n-1].Directory == row.Directory { + groups[n-1].Tracks = append(groups[n-1].Tracks, t) + continue + } + groups = append(groups, missingGroupView{ + Directory: row.Directory, + // First row of a run carries the group's timestamp. Rows are + // ordered within a directory by disc/track, not by mark time, so + // this is "the mark on the first track" rather than the minimum — + // they are the same value in the case that matters (a whole folder + // vanishing at once) and close enough otherwise. + MissingSince: t.MissingSince, + Tracks: []missingTrackView{t}, + }) + } + return groups +} + +// attachReacquisitionState decorates each group with what the sweeper has +// done about its album (milestone #290). +// +// Best-effort: this is context on a list whose primary job is showing what is +// missing, so a failure here leaves the groups bare rather than failing the +// page. One batched query for the whole page, not one per group. +// +// A group is keyed by directory while re-acquisition is keyed by album, and +// those line up in practice (an album's files live in one folder) but are not +// guaranteed to — a directory holding two albums takes the first album's +// state, which is the same album its first track belongs to. +func (h *handlers) attachReacquisitionState( + r *http.Request, + q *dbq.Queries, + rows []dbq.ListMissingTracksRow, + groups []missingGroupView, +) { + if len(groups) == 0 { + return + } + // Directory -> the album its first row belongs to, matching the order + // groupMissingByDirectory folded them in. + dirAlbum := make(map[string]pgtype.UUID, len(groups)) + ids := make([]pgtype.UUID, 0, len(groups)) + for _, row := range rows { + if _, seen := dirAlbum[row.Directory]; seen { + continue + } + dirAlbum[row.Directory] = row.AlbumID + ids = append(ids, row.AlbumID) + } + + states, err := q.GetReacquisitionForAlbums(r.Context(), ids) + if err != nil { + h.logger.Warn("admin missing: reacquisition state lookup failed", "err", err) + return + } + byAlbum := make(map[string]dbq.MissingReacquisition, len(states)) + for _, s := range states { + byAlbum[uuidToString(s.AlbumID)] = s + } + + // The settings service is optional in contexts that only wire routing + // (tests), and the backoff projection is decoration on a list whose real + // job is elsewhere — so fall back to the shipped defaults rather than + // making this a nil-pointer waiting to happen (rule #48). + cfg := reacquisition.Defaults + if h.reacqSettings != nil { + cfg = h.reacqSettings.Get() + } + for i := range groups { + albumID, ok := dirAlbum[groups[i].Directory] + if !ok { + continue + } + state, ok := byAlbum[uuidToString(albumID)] + if !ok { + continue + } + groups[i].Reacquisition = buildReacquisitionState(state, cfg.Backoff(state.Attempts)) + } +} + +// buildReacquisitionState renders one album's attempt record, projecting the +// next attempt from the last one plus the backoff the current settings imply. +func buildReacquisitionState( + state dbq.MissingReacquisition, + backoff time.Duration, +) *reacquisitionStateView { + out := &reacquisitionStateView{Attempts: int(state.Attempts)} + if state.LastAttemptAt.Valid { + s := formatTimestamp(state.LastAttemptAt) + out.LastAttemptAt = &s + // Only meaningful while more attempts remain; an album that has given + // up has no next attempt to promise. + if !state.GaveUpAt.Valid { + next := formatTimestamp(pgtype.Timestamptz{ + Time: state.LastAttemptAt.Time.Add(backoff), + Valid: true, + }) + out.NextAttemptAt = &next + } + } + if state.GaveUpAt.Valid { + s := formatTimestamp(state.GaveUpAt) + out.GaveUpAt = &s + } + return out +} diff --git a/internal/api/admin_library_missing_test.go b/internal/api/admin_library_missing_test.go new file mode 100644 index 00000000..9d28a8b5 --- /dev/null +++ b/internal/api/admin_library_missing_test.go @@ -0,0 +1,130 @@ +package api + +import ( + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// missingRow builds a ListMissingTracksRow with just the fields the grouping +// fold reads, so a test case states its directory and title and nothing else. +func missingRow(dir, title string, missingAt time.Time) dbq.ListMissingTracksRow { + return dbq.ListMissingTracksRow{ + ID: pgtype.UUID{Bytes: [16]byte{1}, Valid: true}, + Title: title, + FilePath: dir + "/" + title + ".flac", + Directory: dir, + MissingSince: pgtype.Timestamptz{Time: missingAt, Valid: true}, + DurationMs: 180_000, + AlbumTitle: "Album", + ArtistName: "Artist", + } +} + +func TestGroupMissingByDirectory(t *testing.T) { + base := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC) + + t.Run("consecutive rows in one directory collapse to one group", func(t *testing.T) { + groups := groupMissingByDirectory([]dbq.ListMissingTracksRow{ + missingRow("/music/Linkin Park/Minutes to Midnight", "Given Up", base), + missingRow("/music/Linkin Park/Minutes to Midnight", "Leave Out All the Rest", base), + missingRow("/music/Linkin Park/Minutes to Midnight", "Bleed It Out", base), + }) + + if len(groups) != 1 { + t.Fatalf("want 1 group, got %d", len(groups)) + } + if got := len(groups[0].Tracks); got != 3 { + t.Errorf("want 3 tracks in the group, got %d", got) + } + if groups[0].Directory != "/music/Linkin Park/Minutes to Midnight" { + t.Errorf("unexpected directory %q", groups[0].Directory) + } + }) + + t.Run("distinct directories stay separate and keep query order", func(t *testing.T) { + groups := groupMissingByDirectory([]dbq.ListMissingTracksRow{ + missingRow("/music/A/One", "a1", base), + missingRow("/music/B/Two", "b1", base), + missingRow("/music/B/Two", "b2", base), + missingRow("/music/C/Three", "c1", base), + }) + + if len(groups) != 3 { + t.Fatalf("want 3 groups, got %d", len(groups)) + } + wantDirs := []string{"/music/A/One", "/music/B/Two", "/music/C/Three"} + for i, want := range wantDirs { + if groups[i].Directory != want { + t.Errorf("group %d: want %q, got %q", i, want, groups[i].Directory) + } + } + if got := len(groups[1].Tracks); got != 2 { + t.Errorf("middle group: want 2 tracks, got %d", got) + } + }) + + // The fold is run-length, not a map, so a directory that appears in two + // non-adjacent runs legitimately produces two groups. That can only happen + // if the query's ORDER BY directory is dropped — pinning it here means such + // a change fails a test instead of silently fragmenting the UI. + t.Run("a directory split by a foreign row yields two groups", func(t *testing.T) { + groups := groupMissingByDirectory([]dbq.ListMissingTracksRow{ + missingRow("/music/A", "a1", base), + missingRow("/music/B", "b1", base), + missingRow("/music/A", "a2", base), + }) + + if len(groups) != 3 { + t.Fatalf("want 3 groups from an unordered input, got %d", len(groups)) + } + }) + + t.Run("no rows yields an empty, non-nil slice", func(t *testing.T) { + groups := groupMissingByDirectory(nil) + if groups == nil { + t.Fatal("want a non-nil slice so the JSON encoder emits [] not null") + } + if len(groups) != 0 { + t.Errorf("want 0 groups, got %d", len(groups)) + } + }) + + t.Run("a never-played track carries a null last_played_at", func(t *testing.T) { + groups := groupMissingByDirectory([]dbq.ListMissingTracksRow{ + missingRow("/music/A", "a1", base), + }) + + if groups[0].Tracks[0].LastPlayedAt != nil { + t.Errorf("want nil last_played_at, got %v", *groups[0].Tracks[0].LastPlayedAt) + } + }) + + t.Run("a played track carries its timestamp", func(t *testing.T) { + row := missingRow("/music/A", "a1", base) + row.LastPlayedAt = pgtype.Timestamptz{Time: base.Add(-48 * time.Hour), Valid: true} + + groups := groupMissingByDirectory([]dbq.ListMissingTracksRow{row}) + + got := groups[0].Tracks[0].LastPlayedAt + if got == nil { + t.Fatal("want a last_played_at, got nil") + } + if want := "2026-08-04T12:00:00Z"; *got != want { + t.Errorf("want %q, got %q", want, *got) + } + }) + + t.Run("duration is reported in seconds", func(t *testing.T) { + groups := groupMissingByDirectory([]dbq.ListMissingTracksRow{ + missingRow("/music/A", "a1", base), + }) + + if got := groups[0].Tracks[0].DurationSec; got != 180 { + t.Errorf("want 180s from 180000ms, got %d", got) + } + }) +} diff --git a/internal/api/admin_reacquisition.go b/internal/api/admin_reacquisition.go new file mode 100644 index 00000000..b4f3b30a --- /dev/null +++ b/internal/api/admin_reacquisition.go @@ -0,0 +1,88 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + + "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition" +) + +// reacquisitionSettingsResp is the admin card's payload (milestone #290). +// +// Carries more than the stored settings: [UnnameableAlbums] is the count of +// albums with missing files that can never be auto-requested because neither +// they nor their artist has an MBID. The card states that number rather than +// leaving the operator to wonder why some rows never get a request — a +// feature that silently does nothing for part of its input reads as broken. +type reacquisitionSettingsResp struct { + Enabled bool `json:"enabled"` + GraceHours int32 `json:"grace_hours"` + BackoffBaseHours int32 `json:"backoff_base_hours"` + BackoffMaxHours int32 `json:"backoff_max_hours"` + MaxAttempts int32 `json:"max_attempts"` + MaxPerPass int32 `json:"max_per_pass"` + AutoApprove bool `json:"auto_approve"` + + UnnameableAlbums int64 `json:"unnameable_albums"` +} + +// handleGetReacquisitionSettings implements GET /api/admin/library/reacquisition. +func (h *handlers) handleGetReacquisitionSettings(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, h.reacquisitionPayload(r)) +} + +// handleUpdateReacquisitionSettings implements PUT /api/admin/library/reacquisition. +func (h *handlers) handleUpdateReacquisitionSettings(w http.ResponseWriter, r *http.Request) { + var req reacquisitionSettingsResp + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON")) + return + } + _, err := h.reacqSettings.Set(r.Context(), reacquisition.Settings{ + Enabled: req.Enabled, + GraceHours: req.GraceHours, + BackoffBaseHours: req.BackoffBaseHours, + BackoffMaxHours: req.BackoffMaxHours, + MaxAttempts: req.MaxAttempts, + MaxPerPass: req.MaxPerPass, + AutoApprove: req.AutoApprove, + }) + if err != nil { + // The Go-side validation mirrors migration 0056's CHECKs so the + // operator gets a readable message naming the field, rather than a + // constraint-violation string leaking through as a 500. + if errors.Is(err, reacquisition.ErrOutOfRange) { + writeErr(w, apierror.BadRequest("invalid_setting", err.Error())) + return + } + writeErrWithLog(w, h.logger, "admin reacquisition: update failed", apierror.Internal(err)) + return + } + // Echo the payload recomputed under the new values so the card reflects + // what it just did without a reload. + writeJSON(w, http.StatusOK, h.reacquisitionPayload(r)) +} + +func (h *handlers) reacquisitionPayload(r *http.Request) reacquisitionSettingsResp { + cur := h.reacqSettings.Get() + out := reacquisitionSettingsResp{ + Enabled: cur.Enabled, + GraceHours: cur.GraceHours, + BackoffBaseHours: cur.BackoffBaseHours, + BackoffMaxHours: cur.BackoffMaxHours, + MaxAttempts: cur.MaxAttempts, + MaxPerPass: cur.MaxPerPass, + AutoApprove: cur.AutoApprove, + } + // Best-effort: the settings are the point of this endpoint, and failing + // the whole card because a count query hiccuped would be the wrong trade. + if n, err := dbq.New(h.pool).CountAlbumsMissingWithoutMbid(r.Context()); err == nil { + out.UnnameableAlbums = n + } else { + h.logger.Warn("admin reacquisition: unnameable count failed", "err", err) + } + return out +} diff --git a/internal/api/api.go b/internal/api/api.go index 2f9f189e..d9d99990 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -23,6 +23,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/netsettings" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" + "git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition" "git.fabledsword.com/bvandeusen/minstrel/internal/recsettings" "git.fabledsword.com/bvandeusen/minstrel/internal/tags" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks" @@ -31,7 +32,7 @@ import ( // Mount attaches /api/* handlers to r. Public endpoints (login) are outside // RequireUser; everything else is gated by the middleware. The events writer // is shared with the Subsonic mount so /rest/scrobble feeds the same store. -func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service) { +func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService) { rng := rand.New(rand.NewSource(rand.Int63())) h := &handlers{ pool: pool, logger: logger, events: events, recCfg: recCfg, @@ -53,6 +54,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev playlistScheduler: playlistScheduler, streamSecret: streamSecret, netSettings: netSettings, + reacqSettings: reacqSettings, } r.Route("/api", func(api chi.Router) { @@ -195,8 +197,20 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev admin.Get("/network-settings", h.handleGetNetworkSettings) admin.Put("/network-settings", h.handleUpdateNetworkSettings) + // Policy for turning a missing file back into a Lidarr + // request (#290). Sits beside the missing-files list it + // governs rather than under /lidarr, because the operator + // meets it on the missing-files surface. + admin.Get("/library/reacquisition", h.handleGetReacquisitionSettings) + admin.Put("/library/reacquisition", h.handleUpdateReacquisitionSettings) + admin.Get("/scan/status", h.handleGetScanStatus) admin.Post("/scan/run", h.handleTriggerScan) + // Sits under /library rather than /tracks because what it + // reports is a property of the library's relationship to disk, + // and because the destructive /tracks/{id} route above must + // not be mistaken for it (#2527). + admin.Get("/library/missing", h.handleListMissingTracks) admin.Get("/library/coverage", h.handleGetLibraryCoverage) @@ -274,6 +288,10 @@ type handlers struct { mailer mailer.Sender eventbus *eventbus.Bus playlistScheduler *playlists.Scheduler + // reacqSettings is the DB-backed policy for auto re-acquisition of + // missing files (milestone #290) — grace window, backoff, attempt caps. + // Cached in the service, so the admin card reads it without a query. + reacqSettings *reacquisition.SettingsService // netSettings caches the trusted reverse-proxy depth read by the auth // middleware on every request and edited from the admin network card. netSettings *netsettings.Service diff --git a/internal/api/convert.go b/internal/api/convert.go index a7148825..2ea8b1da 100644 --- a/internal/api/convert.go +++ b/internal/api/convert.go @@ -139,6 +139,7 @@ func trackRefFrom(t dbq.Track, albumTitle, artistName string) TrackRef { ArtistName: artistName, DurationSec: durationMsToSec(t.DurationMs), StreamURL: streamURL(t.ID), + Unavailable: t.MissingSince.Valid, } if t.TrackNumber != nil { ref.TrackNumber = int(*t.TrackNumber) diff --git a/internal/api/library_browse_test.go b/internal/api/library_browse_test.go index dee9b7ed..1b591a5d 100644 --- a/internal/api/library_browse_test.go +++ b/internal/api/library_browse_test.go @@ -1,11 +1,16 @@ package api import ( + "context" "encoding/json" "net/http" "net/http/httptest" "net/url" "testing" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // parseYearFilter is pure, so this runs in the fast lane rather than waiting @@ -218,11 +223,21 @@ func TestListLibraryAlbums_GenreWithSlashSurvives(t *testing.T) { func TestListLibraryAlbums_YearRangeFilter(t *testing.T) { h, pool := testHandlers(t) artist := seedArtist(t, pool, "Chronology") - seedAlbum(t, pool, artist.ID, "Old Record", 1972) - seedAlbum(t, pool, artist.ID, "Middle Record", 1995) - seedAlbum(t, pool, artist.ID, "New Record", 2020) - // An undated album must not appear in ANY year range. - seedAlbum(t, pool, artist.ID, "Undated Record", 0) + // Each album needs a playable track: since #2702 the year axis lists only + // albums with something to play, matching what the genre axis already did. + for _, a := range []struct { + title string + year int + }{ + {"Old Record", 1972}, + {"Middle Record", 1995}, + {"New Record", 2020}, + // An undated album must not appear in ANY year range. + {"Undated Record", 0}, + } { + album := seedAlbum(t, pool, artist.ID, a.title, a.year) + seedTrack(t, pool, album.ID, artist.ID, a.title+" T1", 1, 120_000) + } titles := func(query string) map[string]bool { t.Helper() @@ -281,8 +296,10 @@ func TestListLibraryAlbums_RejectsGenreAndYearTogether(t *testing.T) { func TestListAlbumYears_ExcludesUndatedAlbums(t *testing.T) { h, pool := testHandlers(t) artist := seedArtist(t, pool, "Years Only") - seedAlbum(t, pool, artist.ID, "Dated One", 1984) - seedAlbum(t, pool, artist.ID, "No Date", 0) + dated := seedAlbum(t, pool, artist.ID, "Dated One", 1984) + seedTrack(t, pool, dated.ID, artist.ID, "Dated T1", 1, 120_000) + undated := seedAlbum(t, pool, artist.ID, "No Date", 0) + seedTrack(t, pool, undated.ID, artist.ID, "Undated T1", 1, 120_000) req := httptest.NewRequest(http.MethodGet, "/api/library/years", nil) w := httptest.NewRecorder() @@ -323,3 +340,90 @@ func keysOf(m map[string]bool) []string { } return out } + +// #2702: an album whose every file has gone leaves the year axis, matching +// what the genre axis already did. Before this the two disagreed — the same +// album was absent from genre and still listed under its year, where opening +// it found nothing playable. +func TestListAlbumYears_ExcludesFullyMissingAlbums(t *testing.T) { + h, pool := testHandlers(t) + q := dbq.New(pool) + artist := seedArtist(t, pool, "Gone Records") + + // Every file missing — should vanish from the axis entirely. + dead := seedAlbum(t, pool, artist.ID, "All Gone", 1991) + deadTrack := seedTrack(t, pool, dead.ID, artist.ID, "Gone A", 1, 120_000) + // One of two missing — the album still has something to play, so it stays. + partial := seedAlbum(t, pool, artist.ID, "Half Gone", 1992) + partialGone := seedTrack(t, pool, partial.ID, artist.ID, "Half A", 1, 120_000) + seedTrack(t, pool, partial.ID, artist.ID, "Half B", 2, 120_000) + + if _, err := q.MarkTracksMissing( + context.Background(), []pgtype.UUID{deadTrack.ID, partialGone.ID}, + ); err != nil { + t.Fatalf("mark missing: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/library/years", nil) + w := httptest.NewRecorder() + h.handleListAlbumYears(w, req) + var years []yearCount + if err := json.NewDecoder(w.Body).Decode(&years); err != nil { + t.Fatalf("decode years: %v", err) + } + for _, y := range years { + if y.Year == 1991 { + t.Error("1991 still on the axis — its only album has no playable files") + } + } + found1992 := false + for _, y := range years { + if y.Year == 1992 { + found1992 = true + } + } + if !found1992 { + t.Error("1992 missing — its album still has a playable track") + } +} + +// The index, the list and the count must agree. #367 needed care for exactly +// this reason at the genre level: if they diverge, a year leads to an empty +// page or "Load more" promises rows that never arrive. +func TestListLibraryAlbums_YearFilterAndCountAgreeOnMissing(t *testing.T) { + h, pool := testHandlers(t) + q := dbq.New(pool) + artist := seedArtist(t, pool, "Agreement") + + dead := seedAlbum(t, pool, artist.ID, "Vanished", 2003) + deadTrack := seedTrack(t, pool, dead.ID, artist.ID, "Vanished A", 1, 120_000) + alive := seedAlbum(t, pool, artist.ID, "Present", 2003) + seedTrack(t, pool, alive.ID, artist.ID, "Present A", 1, 120_000) + + if _, err := q.MarkTracksMissing( + context.Background(), []pgtype.UUID{deadTrack.ID}, + ); err != nil { + t.Fatalf("mark missing: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/library/albums?year_from=2003&year_to=2003&limit=200", nil) + w := httptest.NewRecorder() + h.handleListLibraryAlbums(w, req) + var page Page[AlbumRef] + if err := json.NewDecoder(w.Body).Decode(&page); err != nil { + t.Fatalf("decode: %v", err) + } + + for _, a := range page.Items { + if a.Title == "Vanished" { + t.Error("a fully-missing album is still listed under its year") + } + } + if len(page.Items) != 1 { + t.Fatalf("want 1 album listed, got %d", len(page.Items)) + } + // The count drives paging; a stale one is how "Load more" starts lying. + if page.Total != 1 { + t.Errorf("total = %d, want 1 — the count must match the filtered list", page.Total) + } +} diff --git a/internal/api/library_sync_views.go b/internal/api/library_sync_views.go index d33dbb9e..1e8520f9 100644 --- a/internal/api/library_sync_views.go +++ b/internal/api/library_sync_views.go @@ -10,9 +10,11 @@ package api // pgtype.Timestamptz / Date into strings, mirroring the pattern // playlistRowView already established for /api/playlists. // -// Field names match flutter_client/lib/cache/sync_controller.dart's -// _*FromJson helpers exactly. Adding a field server-side requires a -// matching read in the Flutter helper or it'll be silently dropped. +// Field names match what the Android client deserialises in +// models/wire/SyncResponseWire.kt (Sync{Artist,Album,Track}Wire) exactly. +// Adding a field server-side requires a matching @SerialName there or it is +// silently dropped — kotlinx.serialization ignores unknown keys, so the +// failure is a missing value at runtime, not an error at parse time. import ( "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" @@ -77,6 +79,19 @@ type trackSyncView struct { FilePath string `json:"file_path"` FileFormat string `json:"file_format"` Genre *string `json:"genre"` + // Missing reports that the file is currently absent from disk (#2704). + // + // Shipped as state rather than filtered out of the feed, because a + // missing file is expected to come back: the scanner clears the mark + // when it does, and adopts the row if it returns under a new name + // (#2528). Dropping the row instead would mean a delete-and-recreate on + // every client for what is often a transient unmount, churning caches + // and discarding the identity #2528 works to preserve. + // + // A bool rather than the timestamp: clients need it to decide whether a + // track is playable, which is a yes/no. The "gone since" clock is an + // operator concern and lives on the admin surface. + Missing bool `json:"missing"` } func toTrackSyncView(t dbq.Track) trackSyncView { @@ -91,6 +106,7 @@ func toTrackSyncView(t dbq.Track) trackSyncView { FilePath: t.FilePath, FileFormat: t.FileFormat, Genre: t.Genre, + Missing: t.MissingSince.Valid, } } diff --git a/internal/api/library_sync_views_test.go b/internal/api/library_sync_views_test.go index 3691ed69..bd322d23 100644 --- a/internal/api/library_sync_views_test.go +++ b/internal/api/library_sync_views_test.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" "testing" + "time" "github.com/jackc/pgx/v5/pgtype" @@ -11,9 +12,11 @@ import ( // These tests pin the wire-format keys for /api/library/sync upserts. // Without them, sqlc model field-name drift or accidental -// `json.Marshal(rawStruct)` regressions would silently break the -// Flutter client (which reads snake_case keys via _*FromJson helpers -// in flutter_client/lib/cache/sync_controller.dart). +// `json.Marshal(rawStruct)` regressions would silently break the Android +// client, which reads these snake_case keys via @SerialName in +// models/wire/SyncResponseWire.kt. "Silently" is the operative word: +// kotlinx.serialization skips unknown keys, so a renamed field arrives as +// a default value rather than an error. // validUUID is a deterministic test UUID — not a real value, just // something that pgtype.UUID.Valid will accept. @@ -73,9 +76,26 @@ func TestTrackSyncView_WireKeys(t *testing.T) { assertJSONKeys(t, "track", b, []string{ "id", "album_id", "artist_id", "title", "duration_ms", "track_number", "disc_number", "file_path", "file_format", "genre", + "missing", }) } +// The mark is what the client filters on, so a wrong value here silently +// re-introduces #2704: a present track marked missing vanishes from the +// client's library, a missing one stays playable and fails at the speaker. +func TestTrackSyncView_MissingReflectsTheMark(t *testing.T) { + present := dbq.Track{ID: validUUID, AlbumID: validUUID, ArtistID: validUUID} + if toTrackSyncView(present).Missing { + t.Error("a track with no missing_since must not be marked missing") + } + + gone := present + gone.MissingSince = pgtype.Timestamptz{Time: time.Now(), Valid: true} + if !toTrackSyncView(gone).Missing { + t.Error("a track with missing_since must be marked missing") + } +} + func TestPlaylistSyncView_WireKeys(t *testing.T) { variant := "discover" p := dbq.Playlist{ diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 7edb4407..fa2b80d9 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) { r := chi.NewRouter() w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)), 30*time.Minute, 0.5, 30000) - Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings) + Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings, nil) paths := []string{ "/api/artists", @@ -478,6 +478,12 @@ func TestRoutesRegisteredInMount(t *testing.T) { // Browse indexes (#367). "/api/library/genres", "/api/library/years", + // Admin surfaces for the missing-file lifecycle (#2527). Both must + // 401 at the middleware rather than 404 — they live inside the + // admin group, so reaching the auth check is what proves they are + // wired. + "/api/admin/library/missing", + "/api/admin/library/reacquisition", } for _, p := range paths { req := httptest.NewRequest(http.MethodGet, p, nil) diff --git a/internal/api/playlists.go b/internal/api/playlists.go index 6efba6ee..af262bd7 100644 --- a/internal/api/playlists.go +++ b/internal/api/playlists.go @@ -60,6 +60,11 @@ type playlistTrackView struct { DurationSec int32 `json:"duration_sec"` StreamURL *string `json:"stream_url"` AddedAt string `json:"added_at"` + // Unavailable marks an entry whose file is missing from disk (#2527). + // The track is still a real, known track — its history and likes are + // intact and the file may come back — so the entry keeps its place and + // its text; clients render it greyed out and skip over it on playback. + Unavailable bool `json:"unavailable"` } // playlistDetailView extends playlistRowView with the ordered track list. @@ -472,12 +477,20 @@ func playlistDetailToView(d *playlists.PlaylistDetail) playlistDetailView { AlbumTitle: t.AlbumTitle, DurationSec: t.DurationSec, AddedAt: formatTimestamp(t.AddedAt), + Unavailable: t.Unavailable, } if t.TrackID != nil { s := uuidToString(*t.TrackID) v.TrackID = &s - url := streamURL(*t.TrackID) - v.StreamURL = &url + // No stream URL for a missing file. The wire refuses to offer + // a URL that cannot serve rather than trusting every client to + // honour Unavailable — and `"stream_url": null` is a shape the + // clients already handle (the track-removed case), so an older + // build degrades to "present but not playable" on its own. + if !t.Unavailable { + url := streamURL(*t.TrackID) + v.StreamURL = &url + } } if t.AlbumID != nil { s := uuidToString(*t.AlbumID) diff --git a/internal/api/types.go b/internal/api/types.go index 53e872f6..bcab22cf 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -82,6 +82,16 @@ type TrackRef struct { DiscNumber int `json:"disc_number,omitempty"` DurationSec int `json:"duration_sec"` StreamURL string `json:"stream_url"` + // Unavailable reports that the file is missing from disk (#2704). + // + // Present on every TrackRef rather than only where it can be true: the + // selection surfaces (recommendation, discover, mixes, browse) filter + // missing tracks out entirely, so it is always false there. The surfaces + // that CAN return one are the direct lookups — album detail and search — + // where the user asked for that specific thing by name or container and + // hiding it would be the wrong answer. Marking it lets the client grey + // it and keep it out of a queue. + Unavailable bool `json:"unavailable,omitempty"` } // ArtistDetail is the response body of GET /api/artists/{id}. Embeds the ref diff --git a/internal/db/dbq/browse.sql.go b/internal/db/dbq/browse.sql.go index bf428524..3b22be31 100644 --- a/internal/db/dbq/browse.sql.go +++ b/internal/db/dbq/browse.sql.go @@ -38,6 +38,11 @@ SELECT COUNT(*) FROM albums WHERE release_date IS NOT NULL AND EXTRACT(YEAR FROM release_date)::int BETWEEN $1::int AND $2::int + AND EXISTS ( + SELECT 1 FROM tracks + WHERE tracks.album_id = albums.id + AND tracks.missing_since IS NULL + ) ` type CountAlbumsByYearRangeParams struct { @@ -56,6 +61,11 @@ const listAlbumYearsWithCount = `-- name: ListAlbumYearsWithCount :many SELECT EXTRACT(YEAR FROM release_date)::int AS year, COUNT(*)::bigint AS album_count FROM albums WHERE release_date IS NOT NULL + AND EXISTS ( + SELECT 1 FROM tracks + WHERE tracks.album_id = albums.id + AND tracks.missing_since IS NULL + ) GROUP BY year ORDER BY year DESC ` @@ -161,6 +171,11 @@ JOIN artists ON artists.id = albums.artist_id WHERE albums.release_date IS NOT NULL AND EXTRACT(YEAR FROM albums.release_date)::int BETWEEN $1::int AND $2::int + AND EXISTS ( + SELECT 1 FROM tracks + WHERE tracks.album_id = albums.id + AND tracks.missing_since IS NULL + ) ORDER BY albums.sort_title, albums.id LIMIT $4 OFFSET $3 ` @@ -304,10 +319,22 @@ type ListGenresWithCountRow struct { // Browsing is a way of finding something to play, so a track that cannot play // should not shape it. // -// Year queries below join albums only and are deliberately left alone: an album -// is still a real release even if some of its tracks are gone. An album whose -// EVERY track is missing will linger on the year axis; that's a narrower case, -// tracked with the rest of the cleanup work. +// The year axis filters too (#2702). It used to join albums only, on the +// reasoning that an album is a real release even when some of its tracks are +// gone — true, but it left the two browse axes disagreeing: the genre queries +// above filter tracks.missing_since, so an album whose every file had vanished +// was already absent from genre while still listed under its year, where +// opening it found nothing playable. +// +// Hiding is the answer rather than showing-and-marking because browsing is how +// you go looking for something to play, and the operator's rule for that case +// is to take it out of view; the admin missing-files surface is where absence +// is reported. It also means changing the axis that was inconsistent rather +// than the one that was already right. +// +// The predicate is "has at least one playable track", so it also excludes an +// album with no tracks at all. That is the same answer for the same reason: +// nothing to play, nothing to browse to. // Genre browse index (#367). // // Genres live inline on tracks.genre as a delimited string, so this splits on diff --git a/internal/db/dbq/library_changes.sql.go b/internal/db/dbq/library_changes.sql.go index de326379..0eeda6c4 100644 --- a/internal/db/dbq/library_changes.sql.go +++ b/internal/db/dbq/library_changes.sql.go @@ -102,3 +102,26 @@ func (q *Queries) InsertLibraryChange(ctx context.Context, arg InsertLibraryChan _, err := q.db.Exec(ctx, insertLibraryChange, arg.EntityType, arg.EntityID, arg.Op) return err } + +const insertLibraryChanges = `-- name: InsertLibraryChanges :exec +INSERT INTO library_changes (entity_type, entity_id, op) +SELECT $1::text, + unnest($2::text[]), + $3::text +` + +type InsertLibraryChangesParams struct { + EntityType string + EntityIds []string + Op string +} + +// Batch form, for a mutation that touches many rows at once (#2704: the scan's +// reconcile pass can mark up to a quarter of a library missing in one go). +// Every caller before this one changed a single entity, so per-row inserts +// were the right shape; a loop here would be thousands of round-trips inside +// a scan that is already the slow path. +func (q *Queries) InsertLibraryChanges(ctx context.Context, arg InsertLibraryChangesParams) error { + _, err := q.db.Exec(ctx, insertLibraryChanges, arg.EntityType, arg.EntityIds, arg.Op) + return err +} diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index e4e01981..d0d4a5f1 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -381,6 +381,16 @@ type LidarrRequest struct { LidarrAddConfirmedAt pgtype.Timestamptz } +type MissingReacquisition struct { + AlbumID pgtype.UUID + Attempts int32 + LastAttemptAt pgtype.Timestamptz + LastRequestID pgtype.UUID + GaveUpAt pgtype.Timestamptz + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + type NetworkSetting struct { ID bool TrustedProxyHops int32 @@ -463,6 +473,17 @@ type PlaylistTrack struct { PickKind *string } +type ReacquisitionSetting struct { + ID bool + Enabled bool + GraceHours int32 + BackoffBaseHours int32 + BackoffMaxHours int32 + MaxAttempts int32 + MaxPerPass int32 + AutoApprove bool +} + type RecommendationTuningAudit struct { ID int64 ChangedAt pgtype.Timestamptz diff --git a/internal/db/dbq/playlists.sql.go b/internal/db/dbq/playlists.sql.go index e4a9855e..ce928088 100644 --- a/internal/db/dbq/playlists.sql.go +++ b/internal/db/dbq/playlists.sql.go @@ -262,9 +262,10 @@ func (q *Queries) ListAllPlaylistTracksForCollage(ctx context.Context, arg ListA const listPlaylistTracks = `-- name: ListPlaylistTracks :many SELECT pt.playlist_id, pt.position, pt.track_id, pt.title, pt.artist_name, pt.album_title, pt.duration_sec, pt.added_at, pt.pick_kind, - t.id AS live_track_id, - albums.id AS album_id, - artists.id AS artist_id + t.id AS live_track_id, + t.missing_since AS missing_since, + albums.id AS album_id, + artists.id AS artist_id FROM playlist_tracks pt LEFT JOIN tracks t ON t.id = pt.track_id LEFT JOIN albums ON albums.id = t.album_id @@ -274,24 +275,31 @@ ORDER BY pt.position ` type ListPlaylistTracksRow struct { - PlaylistID pgtype.UUID - Position int32 - TrackID pgtype.UUID - Title string - ArtistName string - AlbumTitle string - DurationSec int32 - AddedAt pgtype.Timestamptz - PickKind *string - LiveTrackID pgtype.UUID - AlbumID pgtype.UUID - ArtistID pgtype.UUID + PlaylistID pgtype.UUID + Position int32 + TrackID pgtype.UUID + Title string + ArtistName string + AlbumTitle string + DurationSec int32 + AddedAt pgtype.Timestamptz + PickKind *string + LiveTrackID pgtype.UUID + MissingSince pgtype.Timestamptz + AlbumID pgtype.UUID + ArtistID pgtype.UUID } // Joined to tracks for the live track id (the service layer derives the // stream URL from it); LEFT JOIN preserves the row when track_id is NULL // (track was removed from the library). The denormalized snapshot fields // on playlist_tracks remain authoritative for title/artist/album text. +// +// Deliberately NOT filtered on missing_since (#2527), unlike every browse / +// discover / mix query. A playlist entry is something the user put here on +// purpose, so a missing file stays in the list and renders as a dead row +// rather than silently vanishing; missing_since rides along so the service +// layer can mark it unplayable. func (q *Queries) ListPlaylistTracks(ctx context.Context, playlistID pgtype.UUID) ([]ListPlaylistTracksRow, error) { rows, err := q.db.Query(ctx, listPlaylistTracks, playlistID) if err != nil { @@ -312,6 +320,7 @@ func (q *Queries) ListPlaylistTracks(ctx context.Context, playlistID pgtype.UUID &i.AddedAt, &i.PickKind, &i.LiveTrackID, + &i.MissingSince, &i.AlbumID, &i.ArtistID, ); err != nil { diff --git a/internal/db/dbq/reacquisition.sql.go b/internal/db/dbq/reacquisition.sql.go new file mode 100644 index 00000000..335e2771 --- /dev/null +++ b/internal/db/dbq/reacquisition.sql.go @@ -0,0 +1,310 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: reacquisition.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const clearRecoveredReacquisitions = `-- name: ClearRecoveredReacquisitions :execrows +DELETE FROM missing_reacquisitions r + WHERE NOT EXISTS ( + SELECT 1 FROM tracks + WHERE tracks.album_id = r.album_id + AND tracks.missing_since IS NOT NULL + ) +` + +// Drops state for albums that no longer have any missing track — the files +// came back, or the scanner adopted them at a new path (#2528). Deleting +// rather than resetting counters means a future loss starts from a clean +// budget, which is right: it is a new problem, not a continuation. +func (q *Queries) ClearRecoveredReacquisitions(ctx context.Context) (int64, error) { + result, err := q.db.Exec(ctx, clearRecoveredReacquisitions) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const countAlbumsMissingWithoutMbid = `-- name: CountAlbumsMissingWithoutMbid :one +SELECT COUNT(DISTINCT albums.id)::bigint + FROM albums + JOIN artists ON artists.id = albums.artist_id + JOIN tracks ON tracks.album_id = albums.id + WHERE tracks.missing_since IS NOT NULL + AND (albums.mbid IS NULL OR artists.mbid IS NULL) +` + +// Albums with missing files that can never be auto-requested because nothing +// identifies them to MusicBrainz. Surfaced on the admin card so the gap is +// visible: silently doing nothing for these would read as the feature being +// broken. +func (q *Queries) CountAlbumsMissingWithoutMbid(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, countAlbumsMissingWithoutMbid) + var column_1 int64 + err := row.Scan(&column_1) + return column_1, err +} + +const getReacquisitionForAlbums = `-- name: GetReacquisitionForAlbums :many +SELECT album_id, attempts, last_attempt_at, last_request_id, gave_up_at, created_at, updated_at FROM missing_reacquisitions WHERE album_id = ANY($1::uuid[]) +` + +// State for the admin missing-files surface, so each directory group can say +// whether a re-acquisition is in flight, waiting, or given up. +func (q *Queries) GetReacquisitionForAlbums(ctx context.Context, albumIds []pgtype.UUID) ([]MissingReacquisition, error) { + rows, err := q.db.Query(ctx, getReacquisitionForAlbums, albumIds) + if err != nil { + return nil, err + } + defer rows.Close() + var items []MissingReacquisition + for rows.Next() { + var i MissingReacquisition + if err := rows.Scan( + &i.AlbumID, + &i.Attempts, + &i.LastAttemptAt, + &i.LastRequestID, + &i.GaveUpAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getReacquisitionSettings = `-- name: GetReacquisitionSettings :one + +SELECT id, enabled, grace_hours, backoff_base_hours, backoff_max_hours, max_attempts, max_per_pass, auto_approve FROM reacquisition_settings WHERE id = true +` + +// Auto re-acquisition of missing files (milestone #290). The unit is the +// album: Lidarr acquires releases, and grouping collapses "40 missing files" +// into "3 albums to ask for". +func (q *Queries) GetReacquisitionSettings(ctx context.Context) (ReacquisitionSetting, error) { + row := q.db.QueryRow(ctx, getReacquisitionSettings) + var i ReacquisitionSetting + err := row.Scan( + &i.ID, + &i.Enabled, + &i.GraceHours, + &i.BackoffBaseHours, + &i.BackoffMaxHours, + &i.MaxAttempts, + &i.MaxPerPass, + &i.AutoApprove, + ) + return i, err +} + +const listAlbumsDueReacquisition = `-- name: ListAlbumsDueReacquisition :many +SELECT albums.id AS album_id, + albums.title AS album_title, + albums.mbid AS album_mbid, + artists.id AS artist_id, + artists.name AS artist_name, + artists.mbid AS artist_mbid, + COUNT(tracks.id)::bigint AS missing_track_count, + COALESCE(r.attempts, 0)::int AS attempts + FROM albums + JOIN artists ON artists.id = albums.artist_id + JOIN tracks ON tracks.album_id = albums.id + LEFT JOIN missing_reacquisitions r ON r.album_id = albums.id + WHERE tracks.missing_since IS NOT NULL + AND tracks.missing_since <= now() - make_interval(hours => $1::int) + AND albums.mbid IS NOT NULL + AND artists.mbid IS NOT NULL + AND (r.gave_up_at IS NULL) + AND ( + r.last_attempt_at IS NULL + OR r.last_attempt_at <= now() - make_interval(hours => LEAST( + ($2::int + * POWER(2, GREATEST(COALESCE(r.attempts, 0) - 1, 0)))::int, + $3::int)) + ) + GROUP BY albums.id, albums.title, albums.mbid, + artists.id, artists.name, artists.mbid, r.attempts, r.last_attempt_at + ORDER BY r.last_attempt_at NULLS FIRST, albums.sort_title + LIMIT $4 +` + +type ListAlbumsDueReacquisitionParams struct { + GraceHours int32 + BackoffBaseHours int32 + BackoffMaxHours int32 + PageLimit int32 +} + +type ListAlbumsDueReacquisitionRow struct { + AlbumID pgtype.UUID + AlbumTitle string + AlbumMbid *string + ArtistID pgtype.UUID + ArtistName string + ArtistMbid *string + MissingTrackCount int64 + Attempts int32 +} + +// The sweeper's selection. An album qualifies when: +// +// - it still has at least one track whose file has been missing longer than +// the grace window. Measured on missing_since, which reconcile never +// re-stamps (#2523), so it is a genuine "gone since" clock rather than +// "when we last noticed"; +// - MusicBrainz can name it. Both the album and its artist MBID are +// required — Create rejects an album-kind request without them, and there +// is nothing to ask Lidarr for anyway. Albums failing this are counted +// separately (CountAlbumsMissingWithoutMbid) rather than vanishing; +// - it has not spent its attempt budget (gave_up_at IS NULL); +// - its backoff has elapsed: base * 2^(attempts-1) hours since the last +// attempt, clamped to the configured maximum. First attempt (no row, or +// last_attempt_at NULL) is always due. +// +// Oldest attempt first, never-attempted first, so a large loss drains in a +// stable order across passes instead of re-picking the same head each time. +func (q *Queries) ListAlbumsDueReacquisition(ctx context.Context, arg ListAlbumsDueReacquisitionParams) ([]ListAlbumsDueReacquisitionRow, error) { + rows, err := q.db.Query(ctx, listAlbumsDueReacquisition, + arg.GraceHours, + arg.BackoffBaseHours, + arg.BackoffMaxHours, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAlbumsDueReacquisitionRow + for rows.Next() { + var i ListAlbumsDueReacquisitionRow + if err := rows.Scan( + &i.AlbumID, + &i.AlbumTitle, + &i.AlbumMbid, + &i.ArtistID, + &i.ArtistName, + &i.ArtistMbid, + &i.MissingTrackCount, + &i.Attempts, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markReacquisitionGaveUp = `-- name: MarkReacquisitionGaveUp :exec +UPDATE missing_reacquisitions + SET gave_up_at = now(), + updated_at = now() + WHERE album_id = $1 + AND gave_up_at IS NULL +` + +// Stamped when the attempt budget is spent. Stored as a timestamp rather than +// inferred from `attempts >= max_attempts` so the verdict survives an operator +// later raising the maximum, and so the admin surface can say when. +func (q *Queries) MarkReacquisitionGaveUp(ctx context.Context, albumID pgtype.UUID) error { + _, err := q.db.Exec(ctx, markReacquisitionGaveUp, albumID) + return err +} + +const recordReacquisitionAttempt = `-- name: RecordReacquisitionAttempt :one +INSERT INTO missing_reacquisitions (album_id, attempts, last_attempt_at, last_request_id) +VALUES ($1, 1, now(), $2) +ON CONFLICT (album_id) DO UPDATE + SET attempts = missing_reacquisitions.attempts + 1, + last_attempt_at = now(), + last_request_id = COALESCE(EXCLUDED.last_request_id, + missing_reacquisitions.last_request_id), + updated_at = now() +RETURNING album_id, attempts, last_attempt_at, last_request_id, gave_up_at, created_at, updated_at +` + +type RecordReacquisitionAttemptParams struct { + AlbumID pgtype.UUID + LastRequestID pgtype.UUID +} + +// Bumps the attempt counter and stamps the clock the backoff measures from. +// Upsert because the first attempt has no row yet. +func (q *Queries) RecordReacquisitionAttempt(ctx context.Context, arg RecordReacquisitionAttemptParams) (MissingReacquisition, error) { + row := q.db.QueryRow(ctx, recordReacquisitionAttempt, arg.AlbumID, arg.LastRequestID) + var i MissingReacquisition + err := row.Scan( + &i.AlbumID, + &i.Attempts, + &i.LastAttemptAt, + &i.LastRequestID, + &i.GaveUpAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateReacquisitionSettings = `-- name: UpdateReacquisitionSettings :one +UPDATE reacquisition_settings + SET enabled = $1, + grace_hours = $2, + backoff_base_hours = $3, + backoff_max_hours = $4, + max_attempts = $5, + max_per_pass = $6, + auto_approve = $7 + WHERE id = true +RETURNING id, enabled, grace_hours, backoff_base_hours, backoff_max_hours, max_attempts, max_per_pass, auto_approve +` + +type UpdateReacquisitionSettingsParams struct { + Enabled bool + GraceHours int32 + BackoffBaseHours int32 + BackoffMaxHours int32 + MaxAttempts int32 + MaxPerPass int32 + AutoApprove bool +} + +// Whole-row write from the admin card; the CHECKs in migration 0056 are the +// validation, so a bad value fails loudly rather than being clamped silently. +func (q *Queries) UpdateReacquisitionSettings(ctx context.Context, arg UpdateReacquisitionSettingsParams) (ReacquisitionSetting, error) { + row := q.db.QueryRow(ctx, updateReacquisitionSettings, + arg.Enabled, + arg.GraceHours, + arg.BackoffBaseHours, + arg.BackoffMaxHours, + arg.MaxAttempts, + arg.MaxPerPass, + arg.AutoApprove, + ) + var i ReacquisitionSetting + err := row.Scan( + &i.ID, + &i.Enabled, + &i.GraceHours, + &i.BackoffBaseHours, + &i.BackoffMaxHours, + &i.MaxAttempts, + &i.MaxPerPass, + &i.AutoApprove, + ) + return i, err +} diff --git a/internal/db/dbq/tracks.sql.go b/internal/db/dbq/tracks.sql.go index ad2d0244..3784cfd4 100644 --- a/internal/db/dbq/tracks.sql.go +++ b/internal/db/dbq/tracks.sql.go @@ -57,6 +57,19 @@ func (q *Queries) ClearTracksMissing(ctx context.Context, ids []pgtype.UUID) (in return result.RowsAffected(), nil } +const countMissingTracks = `-- name: CountMissingTracks :one +SELECT COUNT(*) FROM tracks WHERE missing_since IS NOT NULL +` + +// Total for the admin surface's badge and paging. Uses the same partial index +// (tracks_missing_since_idx) as the list above. +func (q *Queries) CountMissingTracks(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, countMissingTracks) + var count int64 + err := row.Scan(&count) + return count, err +} + const countTracksByAlbum = `-- name: CountTracksByAlbum :one SELECT count(*) FROM tracks WHERE album_id = $1 ` @@ -404,6 +417,92 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra return items, nil } +const listMissingTracks = `-- name: ListMissingTracks :many +SELECT t.id, + t.title, + t.file_path, + regexp_replace(t.file_path, '/[^/]*$', '') AS directory, + t.missing_since, + t.duration_ms, + albums.id AS album_id, + albums.title AS album_title, + artists.id AS artist_id, + artists.name AS artist_name, + -- Cast is load-bearing: without it sqlc infers the correlated + -- subquery as interface{} and the Go layer loses the timestamp type. + (SELECT MAX(pe.started_at) FROM play_events pe WHERE pe.track_id = t.id)::timestamptz AS last_played_at + FROM tracks t + JOIN albums ON albums.id = t.album_id + JOIN artists ON artists.id = t.artist_id + WHERE t.missing_since IS NOT NULL + ORDER BY directory, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title + LIMIT $2 OFFSET $1 +` + +type ListMissingTracksParams struct { + PageOffset int32 + PageLimit int32 +} + +type ListMissingTracksRow struct { + ID pgtype.UUID + Title string + FilePath string + Directory string + MissingSince pgtype.Timestamptz + DurationMs int32 + AlbumID pgtype.UUID + AlbumTitle string + ArtistID pgtype.UUID + ArtistName string + LastPlayedAt pgtype.Timestamptz +} + +// The admin review surface for files the scan could not find (#2527). +// +// Ordered by directory, then by the file's own position within its album, +// because the unit an operator actually reasons about is a FOLDER: the case +// this was built for was three whole albums that had been reorganised, and a +// flat list ordered by timestamp presents that as forty unrelated decisions. +// Grouping happens in the handler; the ordering here is what makes a group +// contiguous, so a page boundary splits a directory at worst. +// +// last_played_at is a correlated MAX rather than a join so a track with no +// plays stays in the result with NULL. It is here because "gone six months, +// never played" and "gone yesterday, played 200 times" deserve opposite +// reactions, and the operator can't tell them apart from a path. +func (q *Queries) ListMissingTracks(ctx context.Context, arg ListMissingTracksParams) ([]ListMissingTracksRow, error) { + rows, err := q.db.Query(ctx, listMissingTracks, arg.PageOffset, arg.PageLimit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListMissingTracksRow + for rows.Next() { + var i ListMissingTracksRow + if err := rows.Scan( + &i.ID, + &i.Title, + &i.FilePath, + &i.Directory, + &i.MissingSince, + &i.DurationMs, + &i.AlbumID, + &i.AlbumTitle, + &i.ArtistID, + &i.ArtistName, + &i.LastPlayedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, albums.title AS album_title, diff --git a/internal/db/dbq/users.sql.go b/internal/db/dbq/users.sql.go index fe5fea6f..3ffe3499 100644 --- a/internal/db/dbq/users.sql.go +++ b/internal/db/dbq/users.sql.go @@ -239,6 +239,43 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge return i, err } +const getOldestAdmin = `-- name: GetOldestAdmin :one +SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users + WHERE is_admin = true + ORDER BY created_at, id + LIMIT 1 +` + +// The account a system-initiated action is attributed to (milestone #290). +// lidarr_requests.user_id is NOT NULL and a re-acquisition has no requesting +// human, so the row is owned by the longest-standing admin: it keeps the +// request auditable and puts it in the same admin queue as everything else, +// without inventing a synthetic principal the rest of the schema would have +// to understand. Ordered by id as a tiebreak so the choice is stable across +// calls rather than depending on scan order. +func (q *Queries) GetOldestAdmin(ctx context.Context) (User, error) { + row := q.db.QueryRow(ctx, getOldestAdmin) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.ApiToken, + &i.IsAdmin, + &i.CreatedAt, + &i.SubsonicPassword, + &i.ListenbrainzToken, + &i.ListenbrainzEnabled, + &i.DisplayName, + &i.AutoApproveRequests, + &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, + &i.DebugModeEnabled, + ) + return i, err +} + const getUserByAPIToken = `-- name: GetUserByAPIToken :one SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE api_token = $1 ` diff --git a/internal/db/migrations/0056_missing_reacquisition.down.sql b/internal/db/migrations/0056_missing_reacquisition.down.sql new file mode 100644 index 00000000..5acbc0ba --- /dev/null +++ b/internal/db/migrations/0056_missing_reacquisition.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS reacquisition_settings; +DROP TABLE IF EXISTS missing_reacquisitions; diff --git a/internal/db/migrations/0056_missing_reacquisition.up.sql b/internal/db/migrations/0056_missing_reacquisition.up.sql new file mode 100644 index 00000000..a4e73b4b --- /dev/null +++ b/internal/db/migrations/0056_missing_reacquisition.up.sql @@ -0,0 +1,98 @@ +-- Auto re-acquisition of missing files via Lidarr (#2527 slice 3, milestone +-- #290). A file going missing has been a dead end until now: reconcile marks +-- it (#2523), every selection path skips it, the admin surface lists it, and +-- there it sits. +-- +-- The unit here is the ALBUM, not the track, and that is the design decision +-- carrying most of the safety. Lidarr acquires releases; there is no +-- meaningful "fetch me one track" operation, and a track-kind request needs a +-- recording MBID plenty of files simply don't have. Grouping by album means +-- the case that produced #2523 -- three reorganised albums, ~40 missing files +-- -- becomes three requests instead of forty. + +CREATE TABLE missing_reacquisitions ( + album_id uuid PRIMARY KEY REFERENCES albums (id) ON DELETE CASCADE, + attempts int NOT NULL DEFAULT 0, + last_attempt_at timestamptz, + -- The request this album's most recent attempt produced. SET NULL rather + -- than CASCADE: a purged request row must not erase the attempt history + -- that stops us asking again in a loop. + last_request_id uuid REFERENCES lidarr_requests (id) ON DELETE SET NULL, + -- Set when the attempt budget is spent. Distinct from "attempts = max" + -- so the reason survives a later change to the configured maximum, and so + -- the admin surface can say "gave up on the 3rd of August" rather than + -- inferring it. + gave_up_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT missing_reacquisitions_attempts_nonneg CHECK (attempts >= 0) +); + +-- The sweeper's own read: albums due another attempt, oldest attempt first. +-- Partial on the not-given-up rows because a spent album is never selected +-- again and would otherwise grow the index forever. +CREATE INDEX missing_reacquisitions_due_idx + ON missing_reacquisitions (last_attempt_at NULLS FIRST) + WHERE gave_up_at IS NULL; + +-- Settings, singleton in the style of network_settings (0053). Every value an +-- operator might want to tune lives here rather than in YAML (rule #25). +CREATE TABLE reacquisition_settings ( + id boolean PRIMARY KEY DEFAULT true, + + -- Master switch. Default true: the operator asked for this to happen by + -- itself, and a feature that ships switched off is a feature nobody finds. + enabled boolean NOT NULL DEFAULT true, + + -- How long a file must have been missing before the FIRST attempt. This + -- is what separates "automatic" from "trigger-happy": a filesystem lies + -- transiently -- an unmounted volume, a container that started before its + -- media mount attached, a NAS mid-reboot -- and every one of those + -- resolves itself well inside a day at no cost. tracks.missing_since is + -- never re-stamped (#2523), so it is a true "gone since" clock to measure + -- against. + grace_hours int NOT NULL DEFAULT 24, + + -- Exponential spacing between attempts: base * 2^(attempts-1), clamped to + -- backoff_max_hours. 6h -> 12h -> 24h -> 48h by default. An album Lidarr + -- genuinely cannot find must get quieter, not keep pace. + backoff_base_hours int NOT NULL DEFAULT 6, + backoff_max_hours int NOT NULL DEFAULT 168, -- one week + + -- Attempts before giving up. Three real tries spread over days is enough + -- to ride out a transient Lidarr/indexer outage; past that the answer is + -- "this release is not obtainable" and asking again is noise. + max_attempts int NOT NULL DEFAULT 3, + + -- Ceiling on requests created per sweep. Album grouping already collapses + -- the common case, but a genuinely large loss (a whole drive slipping + -- under reconcile's 25% mark cap) should still trickle rather than dump + -- hundreds of requests into the queue at once. + max_per_pass int NOT NULL DEFAULT 20, + + -- Whether the sweeper approves what it creates. Requests are created + -- pending and nothing reaches Lidarr until approval, so with this off the + -- feature is a notification rather than an attempt -- which is why the + -- default is on. Off is the review-first posture: rows appear in the + -- admin Requests queue for a human to release. + auto_approve boolean NOT NULL DEFAULT true, + + CONSTRAINT reacquisition_settings_singleton CHECK (id = true), + -- Ranges exist to stop a typo becoming a behaviour change: a 0-hour grace + -- would fire on every transient unmount, and a 10000-per-pass cap would + -- defeat the point of having one. + CONSTRAINT reacquisition_settings_grace_range + CHECK (grace_hours >= 1 AND grace_hours <= 720), + CONSTRAINT reacquisition_settings_backoff_base_range + CHECK (backoff_base_hours >= 1 AND backoff_base_hours <= 168), + CONSTRAINT reacquisition_settings_backoff_max_range + CHECK (backoff_max_hours >= 1 AND backoff_max_hours <= 720), + CONSTRAINT reacquisition_settings_backoff_ordered + CHECK (backoff_max_hours >= backoff_base_hours), + CONSTRAINT reacquisition_settings_attempts_range + CHECK (max_attempts >= 1 AND max_attempts <= 10), + CONSTRAINT reacquisition_settings_per_pass_range + CHECK (max_per_pass >= 1 AND max_per_pass <= 200) +); + +INSERT INTO reacquisition_settings (id) VALUES (true) ON CONFLICT (id) DO NOTHING; diff --git a/internal/db/queries/browse.sql b/internal/db/queries/browse.sql index 00d47d29..4b3d9ced 100644 --- a/internal/db/queries/browse.sql +++ b/internal/db/queries/browse.sql @@ -5,10 +5,22 @@ -- Browsing is a way of finding something to play, so a track that cannot play -- should not shape it. -- --- Year queries below join albums only and are deliberately left alone: an album --- is still a real release even if some of its tracks are gone. An album whose --- EVERY track is missing will linger on the year axis; that's a narrower case, --- tracked with the rest of the cleanup work. +-- The year axis filters too (#2702). It used to join albums only, on the +-- reasoning that an album is a real release even when some of its tracks are +-- gone — true, but it left the two browse axes disagreeing: the genre queries +-- above filter tracks.missing_since, so an album whose every file had vanished +-- was already absent from genre while still listed under its year, where +-- opening it found nothing playable. +-- +-- Hiding is the answer rather than showing-and-marking because browsing is how +-- you go looking for something to play, and the operator's rule for that case +-- is to take it out of view; the admin missing-files surface is where absence +-- is reported. It also means changing the axis that was inconsistent rather +-- than the one that was already right. +-- +-- The predicate is "has at least one playable track", so it also excludes an +-- album with no tracks at all. That is the same answer for the same reason: +-- nothing to play, nothing to browse to. -- name: ListGenresWithCount :many -- Genre browse index (#367). @@ -82,6 +94,11 @@ WHERE EXISTS ( SELECT EXTRACT(YEAR FROM release_date)::int AS year, COUNT(*)::bigint AS album_count FROM albums WHERE release_date IS NOT NULL + AND EXISTS ( + SELECT 1 FROM tracks + WHERE tracks.album_id = albums.id + AND tracks.missing_since IS NULL + ) GROUP BY year ORDER BY year DESC; @@ -93,6 +110,11 @@ JOIN artists ON artists.id = albums.artist_id WHERE albums.release_date IS NOT NULL AND EXTRACT(YEAR FROM albums.release_date)::int BETWEEN sqlc.arg(year_from)::int AND sqlc.arg(year_to)::int + AND EXISTS ( + SELECT 1 FROM tracks + WHERE tracks.album_id = albums.id + AND tracks.missing_since IS NULL + ) ORDER BY albums.sort_title, albums.id LIMIT sqlc.arg(lim) OFFSET sqlc.arg(off); @@ -100,7 +122,12 @@ LIMIT sqlc.arg(lim) OFFSET sqlc.arg(off); SELECT COUNT(*) FROM albums WHERE release_date IS NOT NULL AND EXTRACT(YEAR FROM release_date)::int - BETWEEN sqlc.arg(year_from)::int AND sqlc.arg(year_to)::int; + BETWEEN sqlc.arg(year_from)::int AND sqlc.arg(year_to)::int + AND EXISTS ( + SELECT 1 FROM tracks + WHERE tracks.album_id = albums.id + AND tracks.missing_since IS NULL + ); -- name: ListGenresForAlbum :many -- Distinct genres carried by an album's tracks, for the album detail page's diff --git a/internal/db/queries/library_changes.sql b/internal/db/queries/library_changes.sql index 463e48e0..86a17848 100644 --- a/internal/db/queries/library_changes.sql +++ b/internal/db/queries/library_changes.sql @@ -2,6 +2,17 @@ INSERT INTO library_changes (entity_type, entity_id, op) VALUES ($1, $2, $3); +-- name: InsertLibraryChanges :exec +-- Batch form, for a mutation that touches many rows at once (#2704: the scan's +-- reconcile pass can mark up to a quarter of a library missing in one go). +-- Every caller before this one changed a single entity, so per-row inserts +-- were the right shape; a loop here would be thousands of round-trips inside +-- a scan that is already the slow path. +INSERT INTO library_changes (entity_type, entity_id, op) +SELECT sqlc.arg(entity_type)::text, + unnest(sqlc.arg(entity_ids)::text[]), + sqlc.arg(op)::text; + -- name: GetLibraryChangesSince :many SELECT id, entity_type, entity_id, op, changed_at FROM library_changes diff --git a/internal/db/queries/playlists.sql b/internal/db/queries/playlists.sql index 058a1ffa..fa414aaa 100644 --- a/internal/db/queries/playlists.sql +++ b/internal/db/queries/playlists.sql @@ -54,10 +54,17 @@ RETURNING id, cover_path; -- stream URL from it); LEFT JOIN preserves the row when track_id is NULL -- (track was removed from the library). The denormalized snapshot fields -- on playlist_tracks remain authoritative for title/artist/album text. +-- +-- Deliberately NOT filtered on missing_since (#2527), unlike every browse / +-- discover / mix query. A playlist entry is something the user put here on +-- purpose, so a missing file stays in the list and renders as a dead row +-- rather than silently vanishing; missing_since rides along so the service +-- layer can mark it unplayable. SELECT pt.*, - t.id AS live_track_id, - albums.id AS album_id, - artists.id AS artist_id + t.id AS live_track_id, + t.missing_since AS missing_since, + albums.id AS album_id, + artists.id AS artist_id FROM playlist_tracks pt LEFT JOIN tracks t ON t.id = pt.track_id LEFT JOIN albums ON albums.id = t.album_id diff --git a/internal/db/queries/reacquisition.sql b/internal/db/queries/reacquisition.sql new file mode 100644 index 00000000..5f638e3e --- /dev/null +++ b/internal/db/queries/reacquisition.sql @@ -0,0 +1,119 @@ +-- Auto re-acquisition of missing files (milestone #290). The unit is the +-- album: Lidarr acquires releases, and grouping collapses "40 missing files" +-- into "3 albums to ask for". + +-- name: GetReacquisitionSettings :one +SELECT * FROM reacquisition_settings WHERE id = true; + +-- name: UpdateReacquisitionSettings :one +-- Whole-row write from the admin card; the CHECKs in migration 0056 are the +-- validation, so a bad value fails loudly rather than being clamped silently. +UPDATE reacquisition_settings + SET enabled = sqlc.arg(enabled), + grace_hours = sqlc.arg(grace_hours), + backoff_base_hours = sqlc.arg(backoff_base_hours), + backoff_max_hours = sqlc.arg(backoff_max_hours), + max_attempts = sqlc.arg(max_attempts), + max_per_pass = sqlc.arg(max_per_pass), + auto_approve = sqlc.arg(auto_approve) + WHERE id = true +RETURNING *; + +-- name: ListAlbumsDueReacquisition :many +-- The sweeper's selection. An album qualifies when: +-- +-- * it still has at least one track whose file has been missing longer than +-- the grace window. Measured on missing_since, which reconcile never +-- re-stamps (#2523), so it is a genuine "gone since" clock rather than +-- "when we last noticed"; +-- * MusicBrainz can name it. Both the album and its artist MBID are +-- required — Create rejects an album-kind request without them, and there +-- is nothing to ask Lidarr for anyway. Albums failing this are counted +-- separately (CountAlbumsMissingWithoutMbid) rather than vanishing; +-- * it has not spent its attempt budget (gave_up_at IS NULL); +-- * its backoff has elapsed: base * 2^(attempts-1) hours since the last +-- attempt, clamped to the configured maximum. First attempt (no row, or +-- last_attempt_at NULL) is always due. +-- +-- Oldest attempt first, never-attempted first, so a large loss drains in a +-- stable order across passes instead of re-picking the same head each time. +SELECT albums.id AS album_id, + albums.title AS album_title, + albums.mbid AS album_mbid, + artists.id AS artist_id, + artists.name AS artist_name, + artists.mbid AS artist_mbid, + COUNT(tracks.id)::bigint AS missing_track_count, + COALESCE(r.attempts, 0)::int AS attempts + FROM albums + JOIN artists ON artists.id = albums.artist_id + JOIN tracks ON tracks.album_id = albums.id + LEFT JOIN missing_reacquisitions r ON r.album_id = albums.id + WHERE tracks.missing_since IS NOT NULL + AND tracks.missing_since <= now() - make_interval(hours => sqlc.arg(grace_hours)::int) + AND albums.mbid IS NOT NULL + AND artists.mbid IS NOT NULL + AND (r.gave_up_at IS NULL) + AND ( + r.last_attempt_at IS NULL + OR r.last_attempt_at <= now() - make_interval(hours => LEAST( + (sqlc.arg(backoff_base_hours)::int + * POWER(2, GREATEST(COALESCE(r.attempts, 0) - 1, 0)))::int, + sqlc.arg(backoff_max_hours)::int)) + ) + GROUP BY albums.id, albums.title, albums.mbid, + artists.id, artists.name, artists.mbid, r.attempts, r.last_attempt_at + ORDER BY r.last_attempt_at NULLS FIRST, albums.sort_title + LIMIT sqlc.arg(page_limit); + +-- name: CountAlbumsMissingWithoutMbid :one +-- Albums with missing files that can never be auto-requested because nothing +-- identifies them to MusicBrainz. Surfaced on the admin card so the gap is +-- visible: silently doing nothing for these would read as the feature being +-- broken. +SELECT COUNT(DISTINCT albums.id)::bigint + FROM albums + JOIN artists ON artists.id = albums.artist_id + JOIN tracks ON tracks.album_id = albums.id + WHERE tracks.missing_since IS NOT NULL + AND (albums.mbid IS NULL OR artists.mbid IS NULL); + +-- name: RecordReacquisitionAttempt :one +-- Bumps the attempt counter and stamps the clock the backoff measures from. +-- Upsert because the first attempt has no row yet. +INSERT INTO missing_reacquisitions (album_id, attempts, last_attempt_at, last_request_id) +VALUES (sqlc.arg(album_id), 1, now(), sqlc.narg(last_request_id)) +ON CONFLICT (album_id) DO UPDATE + SET attempts = missing_reacquisitions.attempts + 1, + last_attempt_at = now(), + last_request_id = COALESCE(EXCLUDED.last_request_id, + missing_reacquisitions.last_request_id), + updated_at = now() +RETURNING *; + +-- name: MarkReacquisitionGaveUp :exec +-- Stamped when the attempt budget is spent. Stored as a timestamp rather than +-- inferred from `attempts >= max_attempts` so the verdict survives an operator +-- later raising the maximum, and so the admin surface can say when. +UPDATE missing_reacquisitions + SET gave_up_at = now(), + updated_at = now() + WHERE album_id = sqlc.arg(album_id) + AND gave_up_at IS NULL; + +-- name: ClearRecoveredReacquisitions :execrows +-- Drops state for albums that no longer have any missing track — the files +-- came back, or the scanner adopted them at a new path (#2528). Deleting +-- rather than resetting counters means a future loss starts from a clean +-- budget, which is right: it is a new problem, not a continuation. +DELETE FROM missing_reacquisitions r + WHERE NOT EXISTS ( + SELECT 1 FROM tracks + WHERE tracks.album_id = r.album_id + AND tracks.missing_since IS NOT NULL + ); + +-- name: GetReacquisitionForAlbums :many +-- State for the admin missing-files surface, so each directory group can say +-- whether a re-acquisition is in flight, waiting, or given up. +SELECT * FROM missing_reacquisitions WHERE album_id = ANY(sqlc.arg(album_ids)::uuid[]); diff --git a/internal/db/queries/tracks.sql b/internal/db/queries/tracks.sql index d11d11c2..1f4719a2 100644 --- a/internal/db/queries/tracks.sql +++ b/internal/db/queries/tracks.sql @@ -212,3 +212,42 @@ UPDATE tracks SET missing_since = NULL WHERE id = ANY(sqlc.arg(ids)::uuid[]) AND missing_since IS NOT NULL; + +-- name: ListMissingTracks :many +-- The admin review surface for files the scan could not find (#2527). +-- +-- Ordered by directory, then by the file's own position within its album, +-- because the unit an operator actually reasons about is a FOLDER: the case +-- this was built for was three whole albums that had been reorganised, and a +-- flat list ordered by timestamp presents that as forty unrelated decisions. +-- Grouping happens in the handler; the ordering here is what makes a group +-- contiguous, so a page boundary splits a directory at worst. +-- +-- last_played_at is a correlated MAX rather than a join so a track with no +-- plays stays in the result with NULL. It is here because "gone six months, +-- never played" and "gone yesterday, played 200 times" deserve opposite +-- reactions, and the operator can't tell them apart from a path. +SELECT t.id, + t.title, + t.file_path, + regexp_replace(t.file_path, '/[^/]*$', '') AS directory, + t.missing_since, + t.duration_ms, + albums.id AS album_id, + albums.title AS album_title, + artists.id AS artist_id, + artists.name AS artist_name, + -- Cast is load-bearing: without it sqlc infers the correlated + -- subquery as interface{} and the Go layer loses the timestamp type. + (SELECT MAX(pe.started_at) FROM play_events pe WHERE pe.track_id = t.id)::timestamptz AS last_played_at + FROM tracks t + JOIN albums ON albums.id = t.album_id + JOIN artists ON artists.id = t.artist_id + WHERE t.missing_since IS NOT NULL + ORDER BY directory, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title + LIMIT sqlc.arg(page_limit) OFFSET sqlc.arg(page_offset); + +-- name: CountMissingTracks :one +-- Total for the admin surface's badge and paging. Uses the same partial index +-- (tracks_missing_since_idx) as the list above. +SELECT COUNT(*) FROM tracks WHERE missing_since IS NOT NULL; diff --git a/internal/db/queries/users.sql b/internal/db/queries/users.sql index 971a60b3..bbdaac3e 100644 --- a/internal/db/queries/users.sql +++ b/internal/db/queries/users.sql @@ -173,3 +173,16 @@ SELECT u.id, u.timezone FROM users u WHERE pe.user_id = u.id AND pe.started_at > now() - INTERVAL '7 days' ); + +-- name: GetOldestAdmin :one +-- The account a system-initiated action is attributed to (milestone #290). +-- lidarr_requests.user_id is NOT NULL and a re-acquisition has no requesting +-- human, so the row is owned by the longest-standing admin: it keeps the +-- request auditable and puts it in the same admin queue as everything else, +-- without inventing a synthetic principal the rest of the schema would have +-- to understand. Ordered by id as a tiebreak so the choice is stable across +-- calls rather than depending on scan order. +SELECT * FROM users + WHERE is_admin = true + ORDER BY created_at, id + LIMIT 1; diff --git a/internal/library/delete.go b/internal/library/delete.go index f496871e..81f5bd58 100644 --- a/internal/library/delete.go +++ b/internal/library/delete.go @@ -28,13 +28,22 @@ var ErrTrackNotFound = errors.New("library: track not found") // 3. Delete the tracks row. // // Order matters: file first, then DB. If the file delete fails (permission, -// I/O error), we leave the DB row alone so the admin can retry. The reverse -// failure mode — file gone, DB row still present — is currently NOT -// auto-reconciled (drift #572 audit found the misleading prior claim -// that a scan would clean it up — the scanner only walks + upserts; -// it does not enumerate orphan rows). An admin must re-trigger -// DeleteTrackFile or delete the row manually. A scanrun orphan-row -// sweep is tracked as future work in the audit queue. +// I/O error), we leave the DB row alone so the admin can retry. +// +// The reverse failure mode — file gone, DB row still present — IS reconciled +// now, and not by this function: the scan's reconcile pass stamps +// tracks.missing_since (#2523), every selection path filters on it, and a file +// that returns is un-marked or adopted at its new path (#2528). That is the +// normal life of a vanished file and it is deliberately non-destructive: the +// row, its play history and its likes survive, because a missing file is a +// track Minstrel still knows about (#2527). +// +// So this function is NOT the missing-file path. It is the explicit admin +// action "remove this recording from disk and from the library", and it is +// irreversible: tracks CASCADEs to play_events, general_likes_tracks, +// contextual_likes, track_tags and playback_errors. Reach for it when the +// operator means to destroy the record, never to tidy up a row whose file +// merely went away. func DeleteTrackFile(ctx context.Context, pool *pgxpool.Pool, trackID pgtype.UUID) error { q := dbq.New(pool) track, err := q.GetTrackByID(ctx, trackID) diff --git a/internal/library/reconcile.go b/internal/library/reconcile.go index 6c406cf2..534b42e8 100644 --- a/internal/library/reconcile.go +++ b/internal/library/reconcile.go @@ -9,6 +9,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" ) // trackReconciler is the slice of dbq.Queries reconcileMissing needs. Narrowed @@ -18,6 +19,7 @@ type trackReconciler interface { ListTrackPathsForReconcile(ctx context.Context) ([]dbq.ListTrackPathsForReconcileRow, error) MarkTracksMissing(ctx context.Context, ids []pgtype.UUID) (int64, error) ClearTracksMissing(ctx context.Context, ids []pgtype.UUID) (int64, error) + InsertLibraryChanges(ctx context.Context, arg dbq.InsertLibraryChangesParams) error } // Reconcile marks tracks whose files have disappeared (#2523). @@ -91,6 +93,9 @@ func (s *Scanner) reconcileMissing( // otherwise a library that tripped the cap once could never recover its // marks even after the mount came back. if len(toClear) > 0 { + if err := logTrackChanges(ctx, q, toClear); err != nil { + return fmt.Errorf("log restored changes: %w", err) + } n, err := q.ClearTracksMissing(ctx, toClear) if err != nil { return fmt.Errorf("clear missing marks: %w", err) @@ -110,6 +115,9 @@ func (s *Scanner) reconcileMissing( ) } + if err := logTrackChanges(ctx, q, toMark); err != nil { + return fmt.Errorf("log missing changes: %w", err) + } n, err := q.MarkTracksMissing(ctx, toMark) if err != nil { return fmt.Errorf("mark tracks missing: %w", err) @@ -123,6 +131,41 @@ func (s *Scanner) reconcileMissing( return nil } +// logTrackChanges tells the delta sync that these tracks changed, so clients +// pick up the missing mark (#2704). +// +// Without this the mark was invisible to every client: MarkTracksMissing and +// ClearTracksMissing are plain UPDATEs, and /api/library/sync is a change-log +// feed — a row that never produces a change is never re-sent, so a client +// would keep its stale copy until an unrelated edit touched the track or the +// cursor fell out of the retention window. +// +// Logged BEFORE the mutation, deliberately, which is the opposite of the +// scanner's log-after-success pattern. The two failure modes are not +// symmetric: log-then-fail-to-mark makes clients re-fetch a track that has +// not changed, which costs one wasted read. Mark-then-fail-to-log leaves the +// mark in place with no change row, and because both statements are +// idempotent (`missing_since IS NULL` / `IS NOT NULL` guards) the next scan +// will not retry the pair — so the client never learns, permanently. A +// spurious re-read is the cheaper mistake. +func logTrackChanges(ctx context.Context, q trackReconciler, ids []pgtype.UUID) error { + if len(ids) == 0 { + return nil + } + strIDs := make([]string, 0, len(ids)) + for _, id := range ids { + strIDs = append(strIDs, syncpkg.FormatUUID(id)) + } + // OpUpsert, not OpDelete: the track still exists and keeps its history — + // only its playability changed. A delete would tell clients to drop the + // row, which is the behaviour #2704 deliberately rejected. + return q.InsertLibraryChanges(ctx, dbq.InsertLibraryChangesParams{ + EntityType: string(syncpkg.EntityTrack), + EntityIds: strIDs, + Op: string(syncpkg.OpUpsert), + }) +} + // verifyRootsPresent is the first and most important guard. If a configured root // doesn't resolve to a readable directory, the walk beneath it found nothing and // every row under it would look deleted. An unmounted media volume is the diff --git a/internal/library/reconcile_test.go b/internal/library/reconcile_test.go index a5248f73..55cb8e45 100644 --- a/internal/library/reconcile_test.go +++ b/internal/library/reconcile_test.go @@ -26,6 +26,8 @@ type fakeReconciler struct { listErr error markErr error clearErr error + // Change rows the reconcile pass asked the delta sync to emit (#2704). + loggedChanges []dbq.InsertLibraryChangesParams } func (f *fakeReconciler) ListTrackPathsForReconcile(context.Context) ([]dbq.ListTrackPathsForReconcileRow, error) { @@ -40,6 +42,13 @@ func (f *fakeReconciler) MarkTracksMissing(_ context.Context, ids []pgtype.UUID) return int64(len(ids)), nil } +func (f *fakeReconciler) InsertLibraryChanges( + _ context.Context, arg dbq.InsertLibraryChangesParams, +) error { + f.loggedChanges = append(f.loggedChanges, arg) + return nil +} + func (f *fakeReconciler) ClearTracksMissing(_ context.Context, ids []pgtype.UUID) (int64, error) { if f.clearErr != nil { return 0, f.clearErr @@ -324,3 +333,68 @@ func TestVerifyRootsPresent_NoRootsConfigured(t *testing.T) { t.Fatal("expected an error with no scan roots configured") } } + +// Without a change row the mark is invisible to every client: +// /api/library/sync is a change-log feed, so a plain UPDATE never reaches +// anyone. This was the gap that made #2704's wire field inert — the flag +// existed and nothing ever told a client to re-read the track. +func TestReconcileMissing_MarkingEmitsSyncChanges(t *testing.T) { + s := testScanner(t, populatedRoot(t)) + + // 10 rows, 2 absent — under the cap, so this exercises marking. + rows := make([]dbq.ListTrackPathsForReconcileRow, 0, 10) + seen := map[string]struct{}{} + for i := 0; i < 10; i++ { + path := fmt.Sprintf("/music/track-%02d.mp3", i) + rows = append(rows, row(byte(i), path, false)) + if i >= 2 { + seen[path] = struct{}{} + } + } + q := &fakeReconciler{rows: rows} + var stats Stats + + if err := s.reconcileMissing(context.Background(), q, seen, &stats); err != nil { + t.Fatalf("reconcile: %v", err) + } + + if len(q.loggedChanges) != 1 { + t.Fatalf("want one batch of change rows, got %d", len(q.loggedChanges)) + } + got := q.loggedChanges[0] + if got.EntityType != "track" { + t.Errorf("entity_type = %q, want track", got.EntityType) + } + // Upsert, not delete: the track still exists and keeps its history. A + // delete would tell clients to drop the row, which is the behaviour + // #2704 deliberately rejected. + if got.Op != "upsert" { + t.Errorf("op = %q, want upsert — the row survives, only its state changed", got.Op) + } + if len(got.EntityIds) != 2 { + t.Errorf("want both missing tracks logged, got %d ids", len(got.EntityIds)) + } +} + +// A file coming back must reach clients too, or a restored track stays +// greyed out on every device until something unrelated touches it. +func TestReconcileMissing_RestoringEmitsSyncChanges(t *testing.T) { + s := testScanner(t, populatedRoot(t)) + q := &fakeReconciler{rows: []dbq.ListTrackPathsForReconcileRow{ + row(1, "/music/back.mp3", true), + }} + var stats Stats + + if err := s.reconcileMissing( + context.Background(), q, map[string]struct{}{"/music/back.mp3": {}}, &stats, + ); err != nil { + t.Fatalf("reconcile: %v", err) + } + + if len(q.loggedChanges) != 1 { + t.Fatalf("a restored file must produce a change row, got %d batches", len(q.loggedChanges)) + } + if q.loggedChanges[0].Op != "upsert" { + t.Errorf("op = %q, want upsert", q.loggedChanges[0].Op) + } +} diff --git a/internal/library/watcher.go b/internal/library/watcher.go index 94a66900..dc6946c7 100644 --- a/internal/library/watcher.go +++ b/internal/library/watcher.go @@ -55,6 +55,14 @@ func classifyEvent(op fsnotify.Op, isDir, isAudio bool) watchAction { // It is recursive: a watch is added per directory, and new directories get a // watch as they appear. inotify watch-limit exhaustion on huge libraries is // logged, not fatal -- the periodic safety-net scan covers anything missed. +// +// "Covers anything missed" is true in two different ways, worth separating +// because the walk alone only ever gave one of them: the walk re-visits every +// path that EXISTS, which catches additions and edits, and the reconcile pass +// that runs with it compares the whole tracks table against what the walk saw, +// which is what catches removals (#2523). classifyEvent still ignores fsnotify +// removals by design, so a deleted file surfaces at the next scan, not the +// next inotify event. type Watcher struct { scanner *Scanner enricher *coverart.Enricher diff --git a/internal/playlists/service.go b/internal/playlists/service.go index f919abb8..b6ae36fe 100644 --- a/internal/playlists/service.go +++ b/internal/playlists/service.go @@ -110,6 +110,11 @@ type PlaylistTrack struct { AlbumTitle string DurationSec int32 AddedAt pgtype.Timestamptz + // Unavailable reports that the track still exists in the library but + // its file is currently missing from disk (tracks.missing_since is + // set, #2527). The entry keeps its place in the playlist — the user + // put it there — but nothing should try to play it. + Unavailable bool } // Create makes a new playlist owned by userID. @@ -173,6 +178,7 @@ func (s *Service) Get(ctx context.Context, callerID, playlistID pgtype.UUID) (*P AlbumTitle: t.AlbumTitle, DurationSec: t.DurationSec, AddedAt: t.AddedAt, + Unavailable: t.MissingSince.Valid, } // pt.track_id (snapshot FK, ON DELETE SET NULL) and the joined // live_track_id should agree on validity in normal operation — diff --git a/internal/reacquisition/settings.go b/internal/reacquisition/settings.go new file mode 100644 index 00000000..455f9f36 --- /dev/null +++ b/internal/reacquisition/settings.go @@ -0,0 +1,166 @@ +// Package reacquisition turns a missing file back into a Lidarr request +// without anyone pressing anything (milestone #290). +// +// The unit of work is the ALBUM, not the track. Lidarr acquires releases; +// there is no meaningful "fetch me one track", and a track-kind request needs +// a recording MBID plenty of files lack. Grouping also does most of the +// safety work: the loss that produced #2523 — three reorganised albums, ~40 +// missing files — becomes three requests rather than forty. +package reacquisition + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// Settings is the operator-tunable policy, mirroring the columns and CHECK +// ranges in migration 0056. +type Settings struct { + Enabled bool + GraceHours int32 + BackoffBaseHours int32 + BackoffMaxHours int32 + MaxAttempts int32 + MaxPerPass int32 + AutoApprove bool +} + +// Defaults mirror migration 0056's column defaults. Duplicated here so a +// database that cannot be read still yields a sane policy rather than a +// zero-valued one — a zero grace window would fire on every transient +// unmount, which is the exact failure the grace period exists to prevent. +var Defaults = Settings{ + Enabled: true, + GraceHours: 24, + BackoffBaseHours: 6, + BackoffMaxHours: 168, + MaxAttempts: 3, + MaxPerPass: 20, + AutoApprove: true, +} + +// ErrOutOfRange is returned by Set for values migration 0056's CHECKs would +// reject, so the API layer answers 400 instead of surfacing a constraint +// violation. +var ErrOutOfRange = errors.New("reacquisition setting out of range") + +// SettingsService caches the settings and owns their persistence. Cached +// because the sweeper reads them every pass and the admin card reads them on +// every render; neither needs a round-trip. +type SettingsService struct { + pool *pgxpool.Pool + logger *slog.Logger + + mu sync.RWMutex + cur Settings +} + +// NewSettingsService loads once and caches. +// +// Always returns a usable service, even alongside a non-nil error: a +// boot-time database hiccup should leave the sweeper running on defaults +// rather than take it out entirely. The error is returned so the caller can +// log that the cache holds defaults rather than stored state. +func NewSettingsService(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*SettingsService, error) { + s := &SettingsService{pool: pool, logger: logger, cur: Defaults} + row, err := dbq.New(pool).GetReacquisitionSettings(ctx) + if err != nil { + return s, fmt.Errorf("reacquisition: load settings: %w", err) + } + s.cur = fromRow(row) + return s, nil +} + +// Get returns the cached settings. +func (s *SettingsService) Get() Settings { + s.mu.RLock() + defer s.mu.RUnlock() + return s.cur +} + +// Set validates, persists and re-caches. +func (s *SettingsService) Set(ctx context.Context, in Settings) (Settings, error) { + if err := validate(in); err != nil { + return Settings{}, err + } + row, err := dbq.New(s.pool).UpdateReacquisitionSettings(ctx, dbq.UpdateReacquisitionSettingsParams{ + Enabled: in.Enabled, + GraceHours: in.GraceHours, + BackoffBaseHours: in.BackoffBaseHours, + BackoffMaxHours: in.BackoffMaxHours, + MaxAttempts: in.MaxAttempts, + MaxPerPass: in.MaxPerPass, + AutoApprove: in.AutoApprove, + }) + if err != nil { + return Settings{}, fmt.Errorf("reacquisition: save settings: %w", err) + } + out := fromRow(row) + s.mu.Lock() + s.cur = out + s.mu.Unlock() + return out, nil +} + +// Backoff is how long to wait before the next attempt on an album that has +// already been tried [attempts] times: base * 2^(attempts-1), clamped to the +// configured maximum. Zero attempts means "never tried", which is always due. +// +// Exported and pure so the schedule is testable without a database, and so +// the admin surface can show the same number the sweeper will act on. +func (s Settings) Backoff(attempts int32) time.Duration { + if attempts <= 0 { + return 0 + } + hours := s.BackoffBaseHours + for i := int32(1); i < attempts; i++ { + hours *= 2 + // Clamp inside the loop as well as after: doubling from a large base + // enough times would overflow int32 before the comparison ran. + if hours >= s.BackoffMaxHours { + return time.Duration(s.BackoffMaxHours) * time.Hour + } + } + if hours > s.BackoffMaxHours { + hours = s.BackoffMaxHours + } + return time.Duration(hours) * time.Hour +} + +func validate(in Settings) error { + switch { + case in.GraceHours < 1 || in.GraceHours > 720: + return fmt.Errorf("%w: grace_hours must be 1-720", ErrOutOfRange) + case in.BackoffBaseHours < 1 || in.BackoffBaseHours > 168: + return fmt.Errorf("%w: backoff_base_hours must be 1-168", ErrOutOfRange) + case in.BackoffMaxHours < 1 || in.BackoffMaxHours > 720: + return fmt.Errorf("%w: backoff_max_hours must be 1-720", ErrOutOfRange) + case in.BackoffMaxHours < in.BackoffBaseHours: + return fmt.Errorf("%w: backoff_max_hours must be >= backoff_base_hours", ErrOutOfRange) + case in.MaxAttempts < 1 || in.MaxAttempts > 10: + return fmt.Errorf("%w: max_attempts must be 1-10", ErrOutOfRange) + case in.MaxPerPass < 1 || in.MaxPerPass > 200: + return fmt.Errorf("%w: max_per_pass must be 1-200", ErrOutOfRange) + } + return nil +} + +func fromRow(row dbq.ReacquisitionSetting) Settings { + return Settings{ + Enabled: row.Enabled, + GraceHours: row.GraceHours, + BackoffBaseHours: row.BackoffBaseHours, + BackoffMaxHours: row.BackoffMaxHours, + MaxAttempts: row.MaxAttempts, + MaxPerPass: row.MaxPerPass, + AutoApprove: row.AutoApprove, + } +} diff --git a/internal/reacquisition/settings_test.go b/internal/reacquisition/settings_test.go new file mode 100644 index 00000000..6f7ed3e2 --- /dev/null +++ b/internal/reacquisition/settings_test.go @@ -0,0 +1,126 @@ +package reacquisition + +import ( + "errors" + "testing" + "time" +) + +func TestBackoffSchedule(t *testing.T) { + s := Defaults // 6h base, 168h (one week) cap, 3 attempts + + cases := []struct { + name string + attempts int32 + want time.Duration + }{ + // Never tried is always due — the grace window, not the backoff, is + // what holds the first attempt back. + {"never attempted", 0, 0}, + {"negative is treated as never", -1, 0}, + {"after one attempt", 1, 6 * time.Hour}, + {"after two", 2, 12 * time.Hour}, + {"after three", 3, 24 * time.Hour}, + {"after four", 4, 48 * time.Hour}, + {"after five", 5, 96 * time.Hour}, + // 6h * 2^5 = 192h, past the one-week cap. + {"clamped at the cap", 6, 168 * time.Hour}, + {"still clamped far out", 20, 168 * time.Hour}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := s.Backoff(c.attempts); got != c.want { + t.Errorf("Backoff(%d) = %v, want %v", c.attempts, got, c.want) + } + }) + } +} + +// The doubling must not be able to overflow int32 before the clamp is +// consulted — a large base with a high attempt count is the case that would +// wrap negative and make a spent album look due immediately. +func TestBackoffDoesNotOverflow(t *testing.T) { + s := Settings{BackoffBaseHours: 168, BackoffMaxHours: 720} + for attempts := int32(1); attempts <= 40; attempts++ { + got := s.Backoff(attempts) + if got <= 0 { + t.Fatalf("Backoff(%d) = %v, want a positive duration", attempts, got) + } + if got > 720*time.Hour { + t.Fatalf("Backoff(%d) = %v, want <= the 720h cap", attempts, got) + } + } +} + +func TestBackoffRespectsCustomSettings(t *testing.T) { + s := Settings{BackoffBaseHours: 1, BackoffMaxHours: 4} + for attempts, want := range map[int32]time.Duration{ + 1: 1 * time.Hour, + 2: 2 * time.Hour, + 3: 4 * time.Hour, + 4: 4 * time.Hour, // clamped + } { + if got := s.Backoff(attempts); got != want { + t.Errorf("Backoff(%d) = %v, want %v", attempts, got, want) + } + } +} + +func TestValidateRejectsWhatTheCheckWouldReject(t *testing.T) { + // Each case mirrors a CHECK in migration 0056. Validating in Go as well + // means the API answers 400 with a readable message instead of surfacing + // a constraint violation. + cases := []struct { + name string + in Settings + }{ + {"zero grace would fire on every transient unmount", + mutate(func(s *Settings) { s.GraceHours = 0 })}, + {"grace beyond a month", mutate(func(s *Settings) { s.GraceHours = 721 })}, + {"zero backoff base", mutate(func(s *Settings) { s.BackoffBaseHours = 0 })}, + {"backoff base beyond a week", mutate(func(s *Settings) { s.BackoffBaseHours = 169 })}, + {"zero backoff cap", mutate(func(s *Settings) { s.BackoffMaxHours = 0 })}, + {"cap below base is incoherent", mutate(func(s *Settings) { + s.BackoffBaseHours = 48 + s.BackoffMaxHours = 24 + })}, + {"zero attempts means never try", mutate(func(s *Settings) { s.MaxAttempts = 0 })}, + {"attempts beyond ten", mutate(func(s *Settings) { s.MaxAttempts = 11 })}, + {"zero per pass means never sweep", mutate(func(s *Settings) { s.MaxPerPass = 0 })}, + {"per pass beyond the cap", mutate(func(s *Settings) { s.MaxPerPass = 201 })}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if err := validate(c.in); !errors.Is(err, ErrOutOfRange) { + t.Errorf("validate() = %v, want ErrOutOfRange", err) + } + }) + } +} + +func TestValidateAcceptsDefaults(t *testing.T) { + if err := validate(Defaults); err != nil { + t.Fatalf("the shipped defaults must be valid, got %v", err) + } +} + +// Equal base and cap is legal — it is how an operator asks for a flat retry +// interval rather than an escalating one. +func TestValidateAcceptsFlatBackoff(t *testing.T) { + s := mutate(func(s *Settings) { + s.BackoffBaseHours = 12 + s.BackoffMaxHours = 12 + }) + if err := validate(s); err != nil { + t.Fatalf("flat backoff should be allowed, got %v", err) + } + if got := s.Backoff(5); got != 12*time.Hour { + t.Errorf("flat backoff gave %v, want 12h at every attempt", got) + } +} + +func mutate(f func(*Settings)) Settings { + s := Defaults + f(&s) + return s +} diff --git a/internal/reacquisition/sweeper.go b/internal/reacquisition/sweeper.go new file mode 100644 index 00000000..e14d1820 --- /dev/null +++ b/internal/reacquisition/sweeper.go @@ -0,0 +1,228 @@ +package reacquisition + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" +) + +// requestCreator is the slice of lidarrrequests.Service the sweeper needs, +// narrowed to an interface so the pass can be tested without a Lidarr client +// or an approval path that talks to one. +type requestCreator interface { + Create(ctx context.Context, userID pgtype.UUID, p lidarrrequests.CreateParams) (dbq.LidarrRequest, error) + Approve(ctx context.Context, requestID, adminID pgtype.UUID, ov lidarrrequests.ApproveOverrides) (dbq.LidarrRequest, error) +} + +// Sweeper periodically turns albums with long-missing files into Lidarr +// requests (milestone #290). +// +// Deliberately a periodic worker rather than a hook inside the scanner's +// reconcile pass. Reconcile runs inside a scan and has no business deciding +// to talk to a third-party service; it also re-runs often, which would make +// "attempt once, then back off" awkward to express. A worker paces itself, +// survives a restart, and retries without needing another scan. +type Sweeper struct { + pool *pgxpool.Pool + settings *SettingsService + requests requestCreator + logger *slog.Logger + tick time.Duration +} + +// NewSweeper constructs a Sweeper. The tick is deliberately coarse: the +// smallest meaningful backoff is measured in hours, so waking more often than +// hourly would only re-read settings and find nothing due. +func NewSweeper( + pool *pgxpool.Pool, + settings *SettingsService, + requests requestCreator, + logger *slog.Logger, +) *Sweeper { + return &Sweeper{ + pool: pool, + settings: settings, + requests: requests, + logger: logger, + tick: 1 * time.Hour, + } +} + +// Run drives the sweep loop until ctx is cancelled. +func (s *Sweeper) Run(ctx context.Context) { + t := time.NewTicker(s.tick) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := s.SweepOnce(ctx); err != nil { + s.logger.Warn("reacquisition: sweep failed", "err", err) + } + } + } +} + +// PassResult reports what a single sweep did, for logging and tests. +type PassResult struct { + Cleared int64 // albums whose files came back; state dropped + Requested int // requests created this pass + Approved int // of those, sent on to Lidarr + GaveUp int // albums that spent their attempt budget + Unnameable int64 // albums with missing files but no MBID to ask for +} + +// SweepOnce runs one pass. Exported so the admin surface can offer a "run +// now" without waiting out the tick, and so tests drive it directly. +func (s *Sweeper) SweepOnce(ctx context.Context) error { + cfg := s.settings.Get() + if !cfg.Enabled { + return nil + } + q := dbq.New(s.pool) + + // Before selecting work: drop state for albums whose files came back, or + // were adopted at a new path (#2528). Doing this first means a recovered + // album cannot be picked in the same pass that would have retried it. + cleared, err := q.ClearRecoveredReacquisitions(ctx) + if err != nil { + return fmt.Errorf("clear recovered: %w", err) + } + res := PassResult{Cleared: cleared} + + // Counted, not acted on: an album MusicBrainz cannot name is not a + // failure to retry, it is a permanent gap the operator should see. + if n, cerr := q.CountAlbumsMissingWithoutMbid(ctx); cerr == nil { + res.Unnameable = n + } + + due, err := q.ListAlbumsDueReacquisition(ctx, dbq.ListAlbumsDueReacquisitionParams{ + GraceHours: cfg.GraceHours, + BackoffBaseHours: cfg.BackoffBaseHours, + BackoffMaxHours: cfg.BackoffMaxHours, + PageLimit: cfg.MaxPerPass, + }) + if err != nil { + return fmt.Errorf("list due: %w", err) + } + if len(due) == 0 { + s.logSummary(res) + return nil + } + + // One admin lookup per pass, not per album. A library with no admin at + // all cannot own a request, so the pass stops rather than half-working. + admin, err := q.GetOldestAdmin(ctx) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + s.logger.Warn("reacquisition: no admin account to own requests; skipping pass") + return nil + } + return fmt.Errorf("owner lookup: %w", err) + } + + for _, album := range due { + if err := s.attempt(ctx, q, cfg, admin.ID, album, &res); err != nil { + // One album's failure must not abandon the rest of the pass: a + // single unmatched MBID or a transient Lidarr error says nothing + // about the next album in the list. + s.logger.Warn("reacquisition: attempt failed", + "album", album.AlbumTitle, "err", err) + } + } + s.logSummary(res) + return nil +} + +// attempt creates (and optionally approves) the request for one album, then +// records the attempt against its backoff budget. +func (s *Sweeper) attempt( + ctx context.Context, + q *dbq.Queries, + cfg Settings, + adminID pgtype.UUID, + album dbq.ListAlbumsDueReacquisitionRow, + res *PassResult, +) error { + // The query already filters these out; belt and braces, because Create + // would reject the request and burn an attempt for no reason. + if album.AlbumMbid == nil || album.ArtistMbid == nil { + return nil + } + + req, err := s.requests.Create(ctx, adminID, lidarrrequests.CreateParams{ + Kind: "album", + LidarrArtistMBID: *album.ArtistMbid, + ArtistName: album.ArtistName, + LidarrAlbumMBID: *album.AlbumMbid, + AlbumTitle: album.AlbumTitle, + }) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + res.Requested++ + + // Record the attempt even when Create deduped into somebody else's + // existing request: the point of the counter is "how often have we gone + // looking for this album", and a manual request in flight is a reason to + // wait rather than to keep asking. + row, err := q.RecordReacquisitionAttempt(ctx, dbq.RecordReacquisitionAttemptParams{ + AlbumID: album.AlbumID, + LastRequestID: req.ID, + }) + if err != nil { + return fmt.Errorf("record attempt: %w", err) + } + + if cfg.AutoApprove { + _, aerr := s.requests.Approve(ctx, req.ID, adminID, lidarrrequests.ApproveOverrides{}) + switch { + case aerr == nil: + res.Approved++ + case errors.Is(aerr, lidarrrequests.ErrLidarrDisabled): + // Leave it pending rather than treating it as a failure. The + // request is still the right record of intent, and it becomes + // actionable the moment Lidarr is configured. + s.logger.Info("reacquisition: request left pending, Lidarr disabled", + "album", album.AlbumTitle) + case errors.Is(aerr, lidarrrequests.ErrNotPending): + // Deduped onto a request somebody already approved. Nothing to do + // and nothing wrong. + default: + return fmt.Errorf("approve: %w", aerr) + } + } + + if row.Attempts >= cfg.MaxAttempts { + if err := q.MarkReacquisitionGaveUp(ctx, album.AlbumID); err != nil { + return fmt.Errorf("mark gave up: %w", err) + } + res.GaveUp++ + } + return nil +} + +func (s *Sweeper) logSummary(res PassResult) { + // Silence when a pass did nothing at all — this runs hourly forever, and + // an unconditional line would bury the passes that mattered. + if res.Requested == 0 && res.Cleared == 0 && res.GaveUp == 0 { + return + } + s.logger.Info("reacquisition: sweep", + "requested", res.Requested, + "approved", res.Approved, + "gave_up", res.GaveUp, + "cleared", res.Cleared, + "unnameable", res.Unnameable, + ) +} diff --git a/internal/server/server.go b/internal/server/server.go index d0dede52..622e2a9b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -28,6 +28,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/netsettings" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" + "git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition" "git.fabledsword.com/bvandeusen/minstrel/internal/recsettings" "git.fabledsword.com/bvandeusen/minstrel/internal/subsonic" "git.fabledsword.com/bvandeusen/minstrel/internal/tags" @@ -151,6 +152,14 @@ func (s *Server) Router() http.Handler { return lidarr.NewClient(cfg.BaseURL, cfg.APIKey) } lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, lidarrClientFn, nil) + // Always usable even when the load fails — it falls back to the + // shipped defaults rather than leaving the admin card unable to + // render (same posture as netsettings above). + reacqSettings, raErr := reacquisition.NewSettingsService( + context.Background(), s.Pool, s.Logger) + if raErr != nil { + s.Logger.Warn("reacquisition settings unavailable; serving defaults", "err", raErr) + } lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn) tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn}, s.DataDir) playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir) @@ -177,7 +186,7 @@ func (s *Server) Router() http.Handler { s.Logger.Error("server: recsettings boot failed", "err", err) } } - api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings) + api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings, reacqSettings) // /api/admin/scan is the only admin route owned by the server package // (it needs the Scanner). Register it as a single inline-middleware // route — using r.Route("/api/admin", ...) here would create a second diff --git a/internal/sync/changes.go b/internal/sync/changes.go index 1734e611..a14b617d 100644 --- a/internal/sync/changes.go +++ b/internal/sync/changes.go @@ -36,6 +36,29 @@ func LogChange(ctx context.Context, dbtx dbq.DBTX, entityType EntityType, entity return nil } +// LogChanges is LogChange for a set of entities of one type sharing one op. +// +// Exists because the scan's reconcile pass can mark a quarter of a library +// missing in a single sweep (#2704), and a per-row loop there would be +// thousands of round-trips inside an operation that is already slow. Every +// other caller mutates one entity and should keep using LogChange. +// +// A no-op on an empty set, so callers don't have to guard. +func LogChanges(ctx context.Context, dbtx dbq.DBTX, entityType EntityType, entityIDs []string, op Op) error { + if len(entityIDs) == 0 { + return nil + } + q := dbq.New(dbtx) + if err := q.InsertLibraryChanges(ctx, dbq.InsertLibraryChangesParams{ + EntityType: string(entityType), + EntityIds: entityIDs, + Op: string(op), + }); err != nil { + return fmt.Errorf("sync.LogChanges(%s, %d ids, %s): %w", entityType, len(entityIDs), op, err) + } + return nil +} + // FormatUUID renders a pgtype.UUID as the canonical 8-4-4-4-12 hex form. // Returns "" if the UUID is not valid. func FormatUUID(u pgtype.UUID) string { diff --git a/renovate.json b/renovate.json index 3d6ea392..b9663ff3 100644 --- a/renovate.json +++ b/renovate.json @@ -11,8 +11,7 @@ "prConcurrentLimit": 8, "ignorePaths": [ "**/node_modules/**", - "**/vendor/**", - "flutter_client/**" + "**/vendor/**" ], "lockFileMaintenance": { "enabled": true, diff --git a/shared/README.md b/shared/README.md new file mode 100644 index 00000000..e6f862e4 --- /dev/null +++ b/shared/README.md @@ -0,0 +1,16 @@ +# Shared design tokens + +`fabledsword.tokens.json` is the canonical statement of the FabledSword +palette — the dark, light and flat cohorts in one machine-readable place. + +It lived under `flutter_client/shared/` until that client was deleted +(2026-08-16). It was never Flutter-specific: the Android theme +(`FabledSwordTokens.kt`) names it as its source of truth, and the web +client's CSS custom properties carry the same values. It moved here rather +than going with the client, because losing the one place all three cohorts +are written down together would have been collateral damage. + +Nothing generates from it today — both clients hold their own transcription, +so this is the reference they must agree with, not a build input. If the +values and a client ever disagree, this file is what the disagreement is +measured against. diff --git a/flutter_client/shared/fabledsword.tokens.json b/shared/fabledsword.tokens.json similarity index 100% rename from flutter_client/shared/fabledsword.tokens.json rename to shared/fabledsword.tokens.json diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index d1eb16c5..2428d79f 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -3,6 +3,7 @@ import { api } from './client'; import { qk } from './queries'; import type { ActionResult, + AdminMissingResponse, AdminPlaybackError, AdminQuarantineRow, LidarrConfig, @@ -669,3 +670,55 @@ export async function updateNetworkSettings(hops: number): Promise { + return api.get( + `/api/admin/library/missing?limit=${limit}&offset=${offset}` + ); +} + +// Takes a plain offset rather than a getter: callers wrap the call in +// $derived (as the playback-errors and requests pages do for their tab +// state), so changing the page re-creates the query with a new key. A +// getter would capture the key once and paging would silently not refetch. +// +// staleTime is generous because this list only changes when a scan runs — +// no point re-fetching on every focus like a live triage queue. +export function createMissingFilesQuery(offset: number = 0, limit: number = 50) { + return createQuery({ + queryKey: qk.adminMissingFiles(offset), + queryFn: () => listMissingFiles(offset, limit), + staleTime: 120_000 + }); +} + +// Missing-file re-acquisition (#2527 / milestone #290) ---------------------- + +export type ReacquisitionSettings = { + enabled: boolean; + grace_hours: number; + backoff_base_hours: number; + backoff_max_hours: number; + max_attempts: number; + max_per_pass: number; + auto_approve: boolean; + // Albums with missing files that can never be auto-requested because + // neither they nor their artist carries an MBID. Read-only; the server + // computes it, and the card states it so the gap isn't a mystery. + unnameable_albums: number; +}; + +export async function getReacquisitionSettings(): Promise { + return api.get('/api/admin/library/reacquisition'); +} + +export async function updateReacquisitionSettings( + s: ReacquisitionSettings +): Promise { + return api.put('/api/admin/library/reacquisition', s); +} diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 584c234a..f5586019 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -53,6 +53,8 @@ export const qk = { adminInvites: () => ['adminInvites'] as const, adminDiagnostics: (f: Record) => ['adminDiagnostics', f] as const, + adminMissingFiles: (offset?: number) => + ['adminMissingFiles', { offset: offset ?? 0 }] as const, adminDiagnosticDevices: (userId?: string) => ['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const, smtpConfig: () => ['smtpConfig'] as const, diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index bb1645c3..51cab7fa 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -87,6 +87,12 @@ export type PlaylistTrack = { duration_sec: number; stream_url: string | null; added_at: string; + // True when the track still exists but its file is missing from disk + // (#2527). Distinct from track_id === null: that one is gone for good, + // this one keeps its play history and likes and may come back — the + // scanner un-marks it, or adopts it if it returns renamed. Both are + // unplayable; only this one is worth telling the user about. + unavailable: boolean; }; export type PlaylistDetail = Playlist & { @@ -363,3 +369,52 @@ export type HomePayload = { you_might_like_albums: AlbumRef[]; you_might_like_artists: ArtistRef[]; }; + +// Missing files (#2527) ----------------------------------------------------- + +// One track whose file the scan could not find. The text fields come from the +// tracks row rather than the filesystem — the recording is still a known thing +// with a history, only its bytes are absent. last_played_at is null for a file +// that was never played, which is the signal that separates "worth chasing" +// from "let it go". +export type AdminMissingTrack = { + track_id: string; + title: string; + artist_id: string; + artist_name: string; + album_id: string; + album_title: string; + file_path: string; + duration_sec: number; + missing_since: string; + last_played_at: string | null; +}; + +// What the re-acquisition sweeper has done about this group's album (#290). +// null when nothing has been attempted yet — the common case for a folder +// that just went missing, and distinct from attempts: 0, which cannot occur. +export type AdminReacquisitionState = { + attempts: number; + last_attempt_at: string | null; + // Projected from the last attempt plus the configured backoff, so it moves + // when the operator edits the schedule. Null once the album has given up. + next_attempt_at: string | null; + gave_up_at: string | null; +}; + +// A directory's worth of missing tracks. The server groups because the unit an +// operator reasons about is a folder: three reorganised albums are three +// decisions, not forty. +export type AdminMissingGroup = { + directory: string; + missing_since: string; + tracks: AdminMissingTrack[]; + reacquisition: AdminReacquisitionState | null; +}; + +export type AdminMissingResponse = { + total: number; + limit: number; + offset: number; + groups: AdminMissingGroup[]; +}; diff --git a/web/src/lib/components/AdminTabs.svelte b/web/src/lib/components/AdminTabs.svelte index 08a21264..1be389cf 100644 --- a/web/src/lib/components/AdminTabs.svelte +++ b/web/src/lib/components/AdminTabs.svelte @@ -8,6 +8,7 @@ { href: '/admin/integrations', label: 'Integrations' }, { href: '/admin/requests', label: 'Requests' }, { href: '/admin/quarantine', label: 'Quarantine' }, + { href: '/admin/missing-files', label: 'Missing files' }, { href: '/admin/playback-errors', label: 'Playback errors' }, { href: '/admin/diagnostics', label: 'Diagnostics' }, { href: '/admin/tuning', label: 'Tuning' }, diff --git a/web/src/lib/components/AdminTabs.test.ts b/web/src/lib/components/AdminTabs.test.ts index 171b4d3e..85065bbc 100644 --- a/web/src/lib/components/AdminTabs.test.ts +++ b/web/src/lib/components/AdminTabs.test.ts @@ -52,7 +52,7 @@ describe('AdminTabs', () => { ); }); - test('renders all eight tabs in order', () => { + test('renders all nine tabs in order', () => { state.pageUrl = new URL('http://localhost/admin'); render(AdminTabs); const links = screen.getAllByRole('link'); @@ -61,6 +61,9 @@ describe('AdminTabs', () => { 'Integrations', 'Requests', 'Quarantine', + // Missing files sits with Quarantine and Playback errors: the three + // surfaces that show tracks needing an operator's attention. + 'Missing files', 'Playback errors', 'Diagnostics', 'Tuning', diff --git a/web/src/lib/components/PlaylistCard.test.ts b/web/src/lib/components/PlaylistCard.test.ts index c5d3bf3c..ef859b29 100644 --- a/web/src/lib/components/PlaylistCard.test.ts +++ b/web/src/lib/components/PlaylistCard.test.ts @@ -35,7 +35,8 @@ vi.mock('$lib/api/playlists', () => ({ album_title: 'Test Album', duration_sec: 60, stream_url: '/s/t-1', - added_at: '2026-01-01T00:00:00Z' + added_at: '2026-01-01T00:00:00Z', + unavailable: false } ] } as PlaylistDetail), @@ -66,7 +67,8 @@ vi.mock('$lib/api/playlists', () => ({ album_title: 'Test Album', duration_sec: 60, stream_url: '/s/t-9', - added_at: '2026-01-01T00:00:00Z' + added_at: '2026-01-01T00:00:00Z', + unavailable: false } ] } as PlaylistDetail), diff --git a/web/src/lib/components/PlaylistTrackRow.svelte b/web/src/lib/components/PlaylistTrackRow.svelte index d3fb6d0c..9238da84 100644 --- a/web/src/lib/components/PlaylistTrackRow.svelte +++ b/web/src/lib/components/PlaylistTrackRow.svelte @@ -22,7 +22,15 @@ onMove?: (fromPos: number, toPos: number) => void; } = $props(); - const isUnavailable = $derived(row.track_id === null); + // Two different ways a row can be dead, with the same behaviour and + // deliberately different copy. A removed track is gone for good; a + // missing file is a track we still have — history, likes and all — + // whose bytes aren't on disk right now (#2527). Telling the user + // "file missing" invites them to fix it; a strikethrough would say + // "deleted", which is a lie about a file that may well come back. + const isMissingFile = $derived(row.unavailable); + const isRemoved = $derived(row.track_id === null); + const isUnavailable = $derived(isRemoved || isMissingFile); // Reconstruct a minimal TrackRef for the kebab menu when the // upstream track still exists. When unavailable, the menu is hidden. @@ -108,10 +116,16 @@ class="min-w-0 flex-1 text-left" onclick={() => !isUnavailable && onPlay(row.position)} disabled={isUnavailable} + title={isMissingFile + ? 'This track’s file is missing from the library, so it can’t be played. It stays in the playlist, and returns automatically if the file comes back.' + : undefined} > -
{row.title}
+
{row.title}
{row.artist_name} · {row.album_title} + {#if isMissingFile} + · File missing + {/if}
diff --git a/web/src/lib/components/PlaylistTrackRow.test.ts b/web/src/lib/components/PlaylistTrackRow.test.ts index 5ea34d6b..f743db15 100644 --- a/web/src/lib/components/PlaylistTrackRow.test.ts +++ b/web/src/lib/components/PlaylistTrackRow.test.ts @@ -41,7 +41,8 @@ const live: PlaylistTrack = { album_title: 'MHTRTC', duration_sec: 137, stream_url: '/api/tracks/t-1/stream', - added_at: '' + added_at: '', + unavailable: false }; const removed: PlaylistTrack = { @@ -52,6 +53,15 @@ const removed: PlaylistTrack = { stream_url: null }; +// Missing file (#2527): the track row still exists — ids intact, history +// intact — but the server withholds the stream URL because the bytes are +// not on disk. Deliberately distinct from `removed` above. +const missingFile: PlaylistTrack = { + ...live, + stream_url: null, + unavailable: true +}; + afterEach(() => vi.clearAllMocks()); describe('PlaylistTrackRow', () => { @@ -119,4 +129,31 @@ describe('PlaylistTrackRow', () => { await fireEvent.click(screen.getByText('Roygbiv')); expect(onPlay).toHaveBeenCalledWith(2); }); + + // A missing file is NOT a removed track: the row says so, and says it + // without the strikethrough that would imply the track is gone for good. + test('missing file is labelled and not struck through', () => { + render(PlaylistTrackRow, { + props: { row: missingFile, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() } + }); + expect(screen.getByText(/file missing/i)).toBeTruthy(); + expect(screen.getByText('Roygbiv').className).not.toContain('line-through'); + }); + + test('clicking a missing-file row does not play it', async () => { + const onPlay = vi.fn(); + render(PlaylistTrackRow, { + props: { row: missingFile, isOwner: true, onRemove: vi.fn(), onPlay } + }); + await fireEvent.click(screen.getByText('Roygbiv')); + expect(onPlay).not.toHaveBeenCalled(); + }); + + // The owner must still be able to take the dead entry out of their list. + test('remove stays available on a missing-file row', () => { + render(PlaylistTrackRow, { + props: { row: missingFile, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() } + }); + expect(screen.getByLabelText(/remove roygbiv from playlist/i)).toBeTruthy(); + }); }); diff --git a/web/src/lib/components/ReacquisitionSettingsCard.svelte b/web/src/lib/components/ReacquisitionSettingsCard.svelte new file mode 100644 index 00000000..9f37b1dc --- /dev/null +++ b/web/src/lib/components/ReacquisitionSettingsCard.svelte @@ -0,0 +1,236 @@ + + +
+
+

Automatic re-acquisition

+

+ When a file has been missing for a while, Minstrel can ask Lidarr for the album + again by itself. Requests are made per album, not per track — a whole folder + going missing is one request, not forty. +

+
+ + {#if loadError} +

+ Couldn't load re-acquisition settings. + +

+ {:else if form === null} +

Loading…

+ {:else} + + +
+ + + + + + + + + +
+ +

+ Retry schedule: {schedule} after the + first attempt. +

+ + + + {#if saved && saved.unnameable_albums > 0} + +

+

+ {/if} + +
+ +
+ {/if} +
diff --git a/web/src/lib/components/ReacquisitionSettingsCard.test.ts b/web/src/lib/components/ReacquisitionSettingsCard.test.ts new file mode 100644 index 00000000..7cfea1dd --- /dev/null +++ b/web/src/lib/components/ReacquisitionSettingsCard.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import type { ReacquisitionSettings } from '$lib/api/admin'; + +vi.mock('$lib/api/admin', () => ({ + getReacquisitionSettings: vi.fn(), + updateReacquisitionSettings: vi.fn() +})); + +vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() })); + +import ReacquisitionSettingsCard from './ReacquisitionSettingsCard.svelte'; +import { getReacquisitionSettings, updateReacquisitionSettings } from '$lib/api/admin'; +import { pushToast } from '$lib/stores/toast.svelte'; + +const base: ReacquisitionSettings = { + enabled: true, + grace_hours: 24, + backoff_base_hours: 6, + backoff_max_hours: 168, + max_attempts: 3, + max_per_pass: 20, + auto_approve: true, + unnameable_albums: 0 +}; + +afterEach(() => vi.clearAllMocks()); + +async function renderCard(over: Partial = {}) { + vi.mocked(getReacquisitionSettings).mockResolvedValue({ ...base, ...over }); + const r = render(ReacquisitionSettingsCard); + await waitFor(() => expect(getReacquisitionSettings).toHaveBeenCalled()); + return r; +} + +describe('ReacquisitionSettingsCard', () => { + // The individual numbers say nothing on their own — "6 hours" is meaningless + // until you know it doubles — so the card spells the schedule out. + test('states the retry schedule the numbers add up to', async () => { + await renderCard(); + await waitFor(() => expect(screen.getByText('6h → 12h → 24h')).toBeTruthy()); + }); + + test('the schedule clamps at the longest gap', async () => { + await renderCard({ backoff_base_hours: 6, backoff_max_hours: 12, max_attempts: 4 }); + await waitFor(() => expect(screen.getByText('6h → 12h → 12h → 12h')).toBeTruthy()); + }); + + test('the schedule follows the attempt count', async () => { + await renderCard({ max_attempts: 1 }); + await waitFor(() => expect(screen.getByText('6h')).toBeTruthy()); + }); + + // Saving an unchanged form would be a pointless round-trip, and a live Save + // button invites the operator to wonder whether anything happened. + test('save is disabled until something changes', async () => { + await renderCard(); + const save = await screen.findByRole('button', { name: /save/i }); + expect(save).toHaveProperty('disabled', true); + + const grace = screen.getByRole('spinbutton', { name: /wait before the first attempt/i }); + await fireEvent.input(grace, { target: { value: '48' } }); + await waitFor(() => expect(save).toHaveProperty('disabled', false)); + }); + + test('saving sends the edited values', async () => { + vi.mocked(updateReacquisitionSettings).mockResolvedValue({ ...base, grace_hours: 48 }); + await renderCard(); + + const grace = screen.getByRole('spinbutton', { name: /wait before the first attempt/i }); + await fireEvent.input(grace, { target: { value: '48' } }); + await fireEvent.click(await screen.findByRole('button', { name: /save/i })); + + await waitFor(() => + expect(updateReacquisitionSettings).toHaveBeenCalledWith( + expect.objectContaining({ grace_hours: 48 }) + ) + ); + }); + + // The server names the offending field ("grace_hours must be 1-720"); a + // generic "couldn't save" would throw that away. + test('a rejected save surfaces the server message', async () => { + vi.mocked(updateReacquisitionSettings).mockRejectedValue( + new Error('grace_hours must be 1-720') + ); + await renderCard(); + + const grace = screen.getByRole('spinbutton', { name: /wait before the first attempt/i }); + await fireEvent.input(grace, { target: { value: '900' } }); + await fireEvent.click(await screen.findByRole('button', { name: /save/i })); + + await waitFor(() => + expect(pushToast).toHaveBeenCalledWith('grace_hours must be 1-720', 'error') + ); + }); + + // Without this the operator watches those albums never get a request and + // reasonably concludes the feature is broken. + test('unnameable albums are called out with the reason', async () => { + await renderCard({ unnameable_albums: 3 }); + await waitFor(() => expect(screen.getByText(/no\s+MusicBrainz ID/i)).toBeTruthy()); + expect(screen.getByText(/3\s+albums have/i)).toBeTruthy(); + }); + + test('no warning when every missing album is identifiable', async () => { + await renderCard({ unnameable_albums: 0 }); + await waitFor(() => expect(screen.getByText('6h → 12h → 24h')).toBeTruthy()); + expect(screen.queryByText(/MusicBrainz ID/i)).toBeNull(); + }); + + test('a failed load offers a retry rather than an empty card', async () => { + vi.mocked(getReacquisitionSettings).mockRejectedValue(new Error('nope')); + render(ReacquisitionSettingsCard); + await waitFor(() => + expect(screen.getByText(/couldn't load re-acquisition settings/i)).toBeTruthy() + ); + expect(screen.getByRole('button', { name: /try again/i })).toBeTruthy(); + }); +}); diff --git a/web/src/lib/playlists/playlistTrackToRef.test.ts b/web/src/lib/playlists/playlistTrackToRef.test.ts new file mode 100644 index 00000000..3bee4760 --- /dev/null +++ b/web/src/lib/playlists/playlistTrackToRef.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'vitest'; +import type { PlaylistTrack } from '$lib/api/types'; +import { playlistTrackToRef } from './playlistTrackToRef'; + +const row: PlaylistTrack = { + position: 0, + track_id: 't-1', + album_id: 'a-1', + artist_id: 'ar-1', + title: 'Roygbiv', + artist_name: 'Boards of Canada', + album_title: 'MHTRTC', + duration_sec: 137, + stream_url: '/api/tracks/t-1/stream', + added_at: '', + unavailable: false +}; + +describe('playlistTrackToRef', () => { + test('maps a playable row onto a TrackRef', () => { + expect(playlistTrackToRef(row)).toEqual({ + id: 't-1', + title: 'Roygbiv', + album_id: 'a-1', + album_title: 'MHTRTC', + artist_id: 'ar-1', + artist_name: 'Boards of Canada', + duration_sec: 137, + stream_url: '/api/tracks/t-1/stream' + }); + }); + + test('returns null when the upstream track was removed', () => { + expect(playlistTrackToRef({ ...row, track_id: null, stream_url: null })).toBeNull(); + }); + + // This is what makes a missing file "get skipped": every queue builder + // filters the nulls out of this mapper, so nothing downstream has to know + // about missing_since. If this stops returning null, the player will try + // to stream a file that is not there. + test('returns null when the file is missing, even with ids intact', () => { + expect(playlistTrackToRef({ ...row, unavailable: true, stream_url: null })).toBeNull(); + }); + + // Belt and braces: the server withholds stream_url for a missing file, but + // the flag alone must be enough — a client that trusted only the URL would + // happily queue a stale one from cache. + test('the flag alone disqualifies a row', () => { + expect(playlistTrackToRef({ ...row, unavailable: true })).toBeNull(); + }); +}); diff --git a/web/src/lib/playlists/playlistTrackToRef.ts b/web/src/lib/playlists/playlistTrackToRef.ts index 929c1d4e..2ff34dd6 100644 --- a/web/src/lib/playlists/playlistTrackToRef.ts +++ b/web/src/lib/playlists/playlistTrackToRef.ts @@ -2,13 +2,19 @@ // by play queues + track menus. Centralizes the mapping so server // additions (new TrackRef fields) only update one site. // -// Returns null when the upstream track has been removed (track_id -// is null on the playlist row); callers should filter nulls. +// Returns null when the row cannot be played, for either of the two +// reasons a playlist entry can outlive its track: +// - track_id is null — the track was removed from the library and only +// the playlist's text snapshot remains. +// - unavailable — the track is still there, with its history intact, +// but its file is missing from disk (#2527). +// Callers filter nulls, which is what makes a missing file "get skipped" +// everywhere a queue is built rather than at each call site. import type { PlaylistTrack, TrackRef } from '$lib/api/types'; export function playlistTrackToRef(row: PlaylistTrack): TrackRef | null { - if (!row.track_id) return null; + if (!row.track_id || row.unavailable) return null; return { id: row.track_id, title: row.title, diff --git a/web/src/lib/utils/relativeTime.test.ts b/web/src/lib/utils/relativeTime.test.ts new file mode 100644 index 00000000..3e3c3f63 --- /dev/null +++ b/web/src/lib/utils/relativeTime.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { relativeTime, timeUntil } from './relativeTime'; + +// Fixed "now" so the thresholds are exercised deterministically rather than +// against the wall clock, which would make the minute boundary flaky. +const NOW = new Date('2026-08-16T12:00:00Z'); + +function ago(ms: number): string { + return new Date(NOW.getTime() - ms).toISOString(); +} + +afterEach(() => vi.useRealTimers()); + +describe('relativeTime', () => { + test.each([ + ['under a minute', 30 * 1_000, 'just now'], + ['exactly a minute', 60 * 1_000, '1m ago'], + ['minutes', 42 * 60 * 1_000, '42m ago'], + ['exactly an hour', 3_600_000, '1h ago'], + ['hours', 5 * 3_600_000, '5h ago'], + ['exactly a day', 24 * 3_600_000, '1d ago'], + ['days', 9 * 24 * 3_600_000, '9d ago'] + ])('%s', (_label, delta, expected) => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + expect(relativeTime(ago(delta))).toBe(expected); + }); + + // The unit steps down at each boundary rather than compounding, so 25 + // hours is "1d ago" and never "1d 1h ago". + test('reports only the largest whole unit', () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + expect(relativeTime(ago(25 * 3_600_000))).toBe('1d ago'); + expect(relativeTime(ago(90 * 60 * 1_000))).toBe('1h ago'); + }); + + // A clock skewed behind the server produces a future timestamp; it must + // degrade to "just now" rather than rendering a negative age. + test('a future timestamp reads as just now', () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + expect(relativeTime(new Date(NOW.getTime() + 60_000).toISOString())).toBe('just now'); + }); +}); + +describe('timeUntil', () => { + test.each([ + ['minutes out', 42 * 60 * 1_000, 'in 42m'], + ['exactly an hour', 3_600_000, 'in 1h'], + ['hours out', 5 * 3_600_000, 'in 5h'], + ['exactly a day', 24 * 3_600_000, 'in 1d'], + ['days out', 6 * 24 * 3_600_000, 'in 6d'] + ])('%s', (_label, delta, expected) => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + expect(timeUntil(new Date(NOW.getTime() + delta).toISOString())).toBe(expected); + }); + + // The bug CI caught: flooring rendered a 4h-away attempt as "in 3h", + // sending the operator back an hour early. + test('rounds rather than floors, so nearly-4h reads as 4h', () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + const almost4h = new Date(NOW.getTime() + 4 * 3_600_000 - 2_000).toISOString(); + expect(timeUntil(almost4h)).toBe('in 4h'); + }); + + // A due-or-overdue attempt is the sweeper's next tick away, not "3h ago" — + // the operator wants to know it is imminent, not how late it is. + test('a moment already passed reads as imminent', () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + expect(timeUntil(ago(3 * 3_600_000))).toBe('any moment'); + }); + + test('under a minute reads as imminent', () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + expect(timeUntil(new Date(NOW.getTime() + 30_000).toISOString())).toBe('any moment'); + }); + + // The pair must not converge: relativeTime collapses a future timestamp to + // "just now", which is right for clock skew on a past event and wrong for a + // scheduled one. That difference is why both exist. + test('the two formatters disagree about the future, deliberately', () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + const soon = new Date(NOW.getTime() + 4 * 3_600_000).toISOString(); + expect(relativeTime(soon)).toBe('just now'); + expect(timeUntil(soon)).toBe('in 4h'); + }); +}); diff --git a/web/src/lib/utils/relativeTime.ts b/web/src/lib/utils/relativeTime.ts new file mode 100644 index 00000000..cc7eb5be --- /dev/null +++ b/web/src/lib/utils/relativeTime.ts @@ -0,0 +1,52 @@ +/** + * Coarse "how long ago" label for an ISO timestamp — "3d ago", "5h ago", + * "12m ago", or "just now" under a minute. + * + * The single source for the admin/triage surfaces, which had three + * byte-identical private copies of this before it was extracted (admin + * quarantine, admin playback-errors, library/hidden). Each showed the age + * of something an operator is deciding about, so they must read the same + * — a row that says "2d ago" on one screen and "2 days" on another makes + * the reader wonder whether they mean different things. + * + * Deliberately NOT the formatter used by listening history. HistoryRow + * shows a weekday and clock time for anything under a week ("Tue 21:40") + * because when you played something is more useful than how long ago, + * and it floors at "1m ago" rather than "just now". That is a different + * intent, not a copy that drifted, and it stays where it is. + */ +export function relativeTime(iso: string): string { + const ms = Date.now() - new Date(iso).getTime(); + const days = Math.floor(ms / (24 * 3_600_000)); + if (days >= 1) return `${days}d ago`; + const hours = Math.floor(ms / 3_600_000); + if (hours >= 1) return `${hours}h ago`; + const minutes = Math.floor(ms / 60_000); + if (minutes >= 1) return `${minutes}m ago`; + return 'just now'; +} + +/** + * Forward-looking companion to [relativeTime]: "in 4h", "in 2d", or "any + * moment" once the moment has passed. + * + * Separate function rather than a sign-aware relativeTime, because the two + * read differently in a sentence ("last tried 3d ago, next in 4h") and + * because relativeTime deliberately collapses future timestamps to "just + * now" — that is the right answer for a clock-skewed past event and the + * wrong one for a scheduled future one. + */ +export function timeUntil(iso: string): string { + const ms = new Date(iso).getTime() - Date.now(); + if (ms < 60_000) return 'any moment'; + // Rounds where relativeTime floors, and the difference matters. Flooring an + // elapsed time is honest — "3h ago" for 3h59m means "at least three hours". + // Flooring a countdown is not: it would show "in 3h" for something 3h59m + // away, so the operator comes back an hour early and finds nothing has + // happened. Caught by CI rendering a 4h-away attempt as "in 3h". + const minutes = Math.round(ms / 60_000); + if (minutes < 60) return `in ${minutes}m`; + const hours = Math.round(ms / 3_600_000); + if (hours < 24) return `in ${hours}h`; + return `in ${Math.round(ms / (24 * 3_600_000))}d`; +} diff --git a/web/src/routes/admin/missing-files/+page.svelte b/web/src/routes/admin/missing-files/+page.svelte new file mode 100644 index 00000000..c2c91e68 --- /dev/null +++ b/web/src/routes/admin/missing-files/+page.svelte @@ -0,0 +1,200 @@ + + +{pageTitle('Admin · Missing files')} + +
+
+
+

Missing files

+ {#if total > 0} + + {total} + + {/if} +
+

+ Tracks whose audio file the last scan couldn't find. They keep their play + history and likes, and stop being offered anywhere until the file returns. +

+
+ + + + + {#if query.isPending} +

Checking what's missing…

+ {:else if query.isError} +

Couldn't load the missing-files list.

+ {:else if groups.length === 0} + +
+ +

Every track's file is where it should be.

+

+ A track lands here when a library scan can't find its file — moved outside + Minstrel, deleted, or on a drive that didn't mount. It leaves on its own + when the file comes back. +

+
+ {:else} +
    + {#each groups as group (group.directory)} +
  • + +
    +

    + {group.directory} +

    + + {trackCountLabel(group.tracks.length)} · gone {relativeTime(group.missing_since)} + +
    + + + {#if group.reacquisition} + {@const r = group.reacquisition} +

    + {#if r.gave_up_at} + Gave up + after {attemptLabel(r.attempts)} — last tried + {relativeTime(r.last_attempt_at ?? r.gave_up_at)}. It'll be tried again + if the files come back and go missing later. + {:else if r.last_attempt_at} + Asked Lidarr {attemptLabel(r.attempts)}, last + {relativeTime(r.last_attempt_at)}{#if r.next_attempt_at}, next + {timeUntil(r.next_attempt_at)}{/if}. + {/if} +

    + {/if} + +
      + {#each group.tracks as t (t.track_id)} +
    • + + +
      +
      {t.title}
      +
      + {t.artist_name} · {t.album_title} +
      +
      + + + + {#if t.last_played_at} + last played {relativeTime(t.last_played_at)} + {:else} + never played + {/if} + + {durationLabel(t.duration_sec)} +
    • + {/each} +
    +
  • + {/each} +
+ + {#if hasMore || offset > 0} + + {/if} + {/if} +
diff --git a/web/src/routes/admin/missing-files/missing-files.test.ts b/web/src/routes/admin/missing-files/missing-files.test.ts new file mode 100644 index 00000000..7e069762 --- /dev/null +++ b/web/src/routes/admin/missing-files/missing-files.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { mockQuery } from '../../../test-utils/query'; +import type { AdminMissingResponse } from '$lib/api/types'; + +// The page now embeds ReacquisitionSettingsCard, which loads its own settings +// from this same module on mount. Stubbing both keeps these tests about the +// missing-files list — the card has its own suite. +vi.mock('$lib/api/admin', () => ({ + createMissingFilesQuery: vi.fn(), + getReacquisitionSettings: vi.fn().mockResolvedValue({ + enabled: true, + grace_hours: 24, + backoff_base_hours: 6, + backoff_max_hours: 168, + max_attempts: 3, + max_per_pass: 20, + auto_approve: true, + unnameable_albums: 0 + }), + updateReacquisitionSettings: vi.fn() +})); + +import AdminMissingFilesPage from './+page.svelte'; +import { createMissingFilesQuery } from '$lib/api/admin'; + +const DAY = 24 * 3_600_000; + +function track(title: string, opts: { lastPlayed?: string | null } = {}) { + return { + track_id: `t-${title}`, + title, + artist_id: 'ar-1', + artist_name: 'Linkin Park', + album_id: 'al-1', + album_title: 'Minutes to Midnight', + file_path: `/music/Linkin Park/Minutes to Midnight/${title}.flac`, + duration_sec: 185, + missing_since: new Date(Date.now() - 3 * DAY).toISOString(), + last_played_at: opts.lastPlayed === undefined ? null : opts.lastPlayed + }; +} + +const response: AdminMissingResponse = { + total: 3, + limit: 50, + offset: 0, + groups: [ + { + directory: '/music/Linkin Park/Minutes to Midnight', + missing_since: new Date(Date.now() - 3 * DAY).toISOString(), + reacquisition: null, + tracks: [ + track('Given Up', { lastPlayed: new Date(Date.now() - 2 * DAY).toISOString() }), + track('Bleed It Out') + ] + }, + { + directory: '/music/Boards of Canada/Geogaddi', + missing_since: new Date(Date.now() - 9 * DAY).toISOString(), + reacquisition: null, + tracks: [track('1969')] + } + ] +}; + +afterEach(() => vi.clearAllMocks()); + +// Whitespace-normalised text of a testid'd element. Svelte templates wrap +// mid-sentence, so raw textContent has newlines where the rendered page has +// single spaces — asserting on it directly makes tests fail on reflow. +function text(testId: string): string { + return (screen.getByTestId(testId).textContent ?? '').replace(/\s+/g, ' ').trim(); +} + +function renderWith(data: AdminMissingResponse | undefined, extra = {}) { + vi.mocked(createMissingFilesQuery).mockReturnValue( + mockQuery({ data, ...extra }) as ReturnType + ); + return render(AdminMissingFilesPage); +} + +describe('admin missing files', () => { + test('groups rows under their directory', () => { + renderWith(response); + expect(screen.getByText('/music/Linkin Park/Minutes to Midnight')).toBeTruthy(); + expect(screen.getByText('/music/Boards of Canada/Geogaddi')).toBeTruthy(); + expect(screen.getAllByTestId('missing-track-row')).toHaveLength(3); + }); + + test('the count pill reports the server total, not the page size', () => { + renderWith(response); + expect(screen.getByTestId('missing-count-pill').textContent?.trim()).toBe('3'); + }); + + test('a group states how many tracks and how long gone', () => { + renderWith(response); + expect(screen.getByText(/2 tracks · gone 3d ago/)).toBeTruthy(); + expect(screen.getByText(/1 track · gone 9d ago/)).toBeTruthy(); + }); + + // The deciding fact for whether a missing file is worth chasing. + test('distinguishes a played track from one never played', () => { + renderWith(response); + expect(screen.getByText(/last played 2d ago/)).toBeTruthy(); + expect(screen.getAllByText('never played')).toHaveLength(2); + }); + + // An operator who has never seen this page should not have to guess what + // would put a row here. + test('the empty state explains what missing means', () => { + renderWith({ total: 0, limit: 50, offset: 0, groups: [] }); + expect(screen.getByText(/every track's file is where it should be/i)).toBeTruthy(); + expect(screen.getByText(/can't find its file/i)).toBeTruthy(); + expect(screen.queryByTestId('missing-count-pill')).toBeNull(); + }); + + test('shows a loading line while pending', () => { + renderWith(undefined, { isPending: true }); + expect(screen.getByText(/checking what's missing/i)).toBeTruthy(); + }); + + test('shows an error line when the query fails', () => { + renderWith(undefined, { isError: true }); + expect(screen.getByText(/couldn't load the missing-files list/i)).toBeTruthy(); + }); + + // Nothing attempted yet is the common case for a folder that just went + // missing; a line saying so would be noise on every row. + test('no re-acquisition line before anything has been attempted', () => { + renderWith(response); + expect(screen.queryByTestId('reacquisition-state')).toBeNull(); + }); + + test('an in-flight re-acquisition says how often and when next', () => { + const withState = structuredClone(response); + withState.groups[0].reacquisition = { + attempts: 2, + last_attempt_at: new Date(Date.now() - 2 * DAY).toISOString(), + next_attempt_at: new Date(Date.now() + 4 * 3_600_000).toISOString(), + gave_up_at: null + }; + renderWith(withState); + // Collapsed: the markup wraps mid-sentence, so textContent carries the + // template's newlines and indentation between the words. + const line = text('reacquisition-state'); + expect(line).toMatch(/asked lidarr twice/i); + expect(line).toMatch(/last 2d ago/i); + // Forward-looking, not relativeTime — which would say "just now" for a + // future timestamp and read as nonsense. + expect(line).toMatch(/next in 4h/i); + }); + + test('a given-up album says so and says it can come back', () => { + const withState = structuredClone(response); + withState.groups[0].reacquisition = { + attempts: 3, + last_attempt_at: new Date(Date.now() - 5 * DAY).toISOString(), + next_attempt_at: null, + gave_up_at: new Date(Date.now() - 5 * DAY).toISOString() + }; + renderWith(withState); + const line = text('reacquisition-state'); + expect(line).toMatch(/gave up/i); + expect(line).toMatch(/after 3 times/i); + expect(line).toMatch(/tried again/i); + }); + + // Paging only appears when it can do something: a single page of results + // should not render dead Previous/Next buttons. + test('no pager when everything fits on one page', () => { + renderWith(response); + expect(screen.queryByLabelText('Missing files pages')).toBeNull(); + }); + + test('pager appears when the server reports more than this page holds', () => { + renderWith({ ...response, total: 120 }); + expect(screen.getByLabelText('Missing files pages')).toBeTruthy(); + expect(screen.getByText('1–3 of 120')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Previous' })).toHaveProperty('disabled', true); + expect(screen.getByRole('button', { name: 'Next' })).toHaveProperty('disabled', false); + }); +}); diff --git a/web/src/routes/admin/playback-errors/+page.svelte b/web/src/routes/admin/playback-errors/+page.svelte index d43993f0..0008619a 100644 --- a/web/src/routes/admin/playback-errors/+page.svelte +++ b/web/src/routes/admin/playback-errors/+page.svelte @@ -13,6 +13,7 @@ import { pushToast } from '$lib/stores/toast.svelte'; import Modal from '$lib/components/Modal.svelte'; import type { AdminPlaybackError, PlaybackErrorResolution } from '$lib/api/types'; + import { relativeTime } from '$lib/utils/relativeTime'; // Client-reported playback errors inbox. Two tabs — Unresolved // (default) / Resolved. Per-row: copy details to clipboard, delete @@ -31,17 +32,6 @@ const query = $derived($queryStore); const rows = $derived((query.data ?? []) as AdminPlaybackError[]); - function relativeTime(iso: string): string { - const ms = Date.now() - new Date(iso).getTime(); - const days = Math.floor(ms / (24 * 3_600_000)); - if (days >= 1) return `${days}d ago`; - const hours = Math.floor(ms / 3_600_000); - if (hours >= 1) return `${hours}h ago`; - const minutes = Math.floor(ms / 60_000); - if (minutes >= 1) return `${minutes}m ago`; - return 'just now'; - } - // Maps the kind enum to a short readable badge label. function kindLabel(kind: string): string { switch (kind) { diff --git a/web/src/routes/admin/quarantine/+page.svelte b/web/src/routes/admin/quarantine/+page.svelte index 9e5b148b..420274c9 100644 --- a/web/src/routes/admin/quarantine/+page.svelte +++ b/web/src/routes/admin/quarantine/+page.svelte @@ -16,6 +16,7 @@ import { coverUrl } from '$lib/media/covers'; import Modal from '$lib/components/Modal.svelte'; import type { AdminQuarantineRow, LidarrQuarantineReason } from '$lib/api/types'; + import { relativeTime } from '$lib/utils/relativeTime'; // Aggregated triage queue. One row per track, with per-row resolution // actions: Resolve (clears reports), Delete file (Bronze; modal-confirm), @@ -42,17 +43,6 @@ other: 'Other' }; - function relativeTime(iso: string): string { - const ms = Date.now() - new Date(iso).getTime(); - const days = Math.floor(ms / (24 * 3_600_000)); - if (days >= 1) return `${days}d ago`; - const hours = Math.floor(ms / 3_600_000); - if (hours >= 1) return `${hours}h ago`; - const minutes = Math.floor(ms / 60_000); - if (minutes >= 1) return `${minutes}m ago`; - return 'just now'; - } - // Row-level expand state: tracks which rows have their per-user report // details revealed. Keyed by track_id. let expanded = $state>({}); diff --git a/web/src/routes/library/hidden/+page.svelte b/web/src/routes/library/hidden/+page.svelte index f9165925..3566e437 100644 --- a/web/src/routes/library/hidden/+page.svelte +++ b/web/src/routes/library/hidden/+page.svelte @@ -7,6 +7,7 @@ import type { LidarrQuarantineMineRow, LidarrQuarantineReason } from '$lib/api/types'; import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte'; import { coverUrl } from '$lib/media/covers'; + import { relativeTime } from '$lib/utils/relativeTime'; const client = useQueryClient(); const queryStore = createMyQuarantineQuery(); @@ -21,17 +22,6 @@ other: 'Other' }; - function relativeTime(iso: string): string { - const ms = Date.now() - new Date(iso).getTime(); - const days = Math.floor(ms / (24 * 3_600_000)); - if (days >= 1) return `${days}d ago`; - const hours = Math.floor(ms / 3_600_000); - if (hours >= 1) return `${hours}h ago`; - const minutes = Math.floor(ms / 60_000); - if (minutes >= 1) return `${minutes}m ago`; - return 'just now'; - } - async function onUnhide(trackID: string) { try { await unflagTrack(trackID); diff --git a/web/src/routes/playlists/[id]/playlist.test.ts b/web/src/routes/playlists/[id]/playlist.test.ts index fd1fbfd6..21b638d5 100644 --- a/web/src/routes/playlists/[id]/playlist.test.ts +++ b/web/src/routes/playlists/[id]/playlist.test.ts @@ -52,8 +52,8 @@ const ownDetail: PlaylistDetail = { description: '', is_public: false, kind: 'user', system_variant: null, refreshable: false, seed_artist_id: null, cover_url: '', track_count: 2, duration_sec: 274, created_at: '', updated_at: '', tracks: [ - { position: 0, track_id: 't1', album_id: 'a1', artist_id: 'ar1', title: 'A', artist_name: 'X', album_title: 'Y', duration_sec: 137, stream_url: '/api/tracks/t1/stream', added_at: '' }, - { position: 1, track_id: 't2', album_id: 'a1', artist_id: 'ar1', title: 'B', artist_name: 'X', album_title: 'Y', duration_sec: 137, stream_url: '/api/tracks/t2/stream', added_at: '' } + { position: 0, track_id: 't1', album_id: 'a1', artist_id: 'ar1', title: 'A', artist_name: 'X', album_title: 'Y', duration_sec: 137, stream_url: '/api/tracks/t1/stream', added_at: '', unavailable: false }, + { position: 1, track_id: 't2', album_id: 'a1', artist_id: 'ar1', title: 'B', artist_name: 'X', album_title: 'Y', duration_sec: 137, stream_url: '/api/tracks/t2/stream', added_at: '', unavailable: false } ] };