From 20bd7bfaf8dc88709db93e3620bdcf64db192f5c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 16 Aug 2026 10:40:05 -0400 Subject: [PATCH 01/23] =?UTF-8?q?fix(android):=20let=20list=20content=20re?= =?UTF-8?q?ach=20the=20MiniPlayer=20=E2=80=94=20#2681?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell is a Column (content weight(1f), then the bar), so the content viewport already ends at the MiniPlayer's top edge. But nothing owned the bottom navigation-bar inset under edge-to-edge: each in-shell screen's own Scaffold claimed it via the default contentWindowInsets and padded its content up by the nav-bar height a second time. That padding is the dead strip the operator sees between the last list row and the bar — and the bar's own bottom was drawing under the gesture pill. ShellScaffold now owns the inset end to end: the content region consumes it, and a Spacer below the MiniPlayer re-holds the space for the system bar (unconditional — MiniPlayer renders nothing when no track is loaded). Modifier.consumeWindowInsets alone can't fix it: ScaffoldLayout reads contentWindowInsets.asPaddingValues() directly, outside the modifier consumption chain, so every in-shell Scaffold is handed the new zero ShellContentWindowInsets. The full-screen routes (NowPlaying / Queue / Login / ServerUrl) keep the default — no shell sits above them. Also drops the hardcoded 140dp bottom contentPadding on Album and Playlist detail, a Flutter-era value for a player bar that overlaid its list; here the shell reserves that space in layout already. --- .../minstrel/admin/ui/AdminLandingScreen.kt | 2 + .../admin/ui/AdminQuarantineScreen.kt | 2 + .../minstrel/admin/ui/AdminRequestsScreen.kt | 2 + .../admin/ui/AdminTagSourcesScreen.kt | 2 + .../minstrel/admin/ui/AdminUsersScreen.kt | 2 + .../minstrel/discover/ui/DiscoverScreen.kt | 2 + .../minstrel/home/ui/HomeScreen.kt | 2 + .../minstrel/library/ui/AlbumDetailScreen.kt | 4 +- .../minstrel/library/ui/ArtistDetailScreen.kt | 2 + .../minstrel/library/ui/LibraryScreen.kt | 2 + .../playlists/ui/PlaylistDetailScreen.kt | 4 +- .../playlists/ui/PlaylistsListScreen.kt | 2 + .../minstrel/requests/ui/RequestsScreen.kt | 2 + .../minstrel/search/ui/SearchScreen.kt | 2 + .../minstrel/settings/ui/SettingsScreen.kt | 2 + .../minstrel/shared/widgets/ShellScaffold.kt | 46 ++++++++++++++++++- 16 files changed, 75 insertions(+), 5 deletions(-) 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/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/home/ui/HomeScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt index c5c22095..83d66e9f 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( 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/LibraryScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt index ee290b44..1520d394 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 @@ -77,6 +78,7 @@ fun LibraryScreen( val scope = rememberCoroutineScope() Scaffold( + contentWindowInsets = ShellContentWindowInsets, modifier = Modifier.fillMaxSize(), topBar = { Column { 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..4e69ffd6 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() } 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/requests/ui/RequestsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt index 2856a704..3a55e608 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( 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/ShellScaffold.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt index d5e3310d..5e8b6881 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 @@ -82,12 +107,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)) } } From c3f3a17c6db46d3ba6872b3894b535af5eccd920 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 16 Aug 2026 11:43:05 -0400 Subject: [PATCH 02/23] =?UTF-8?q?feat(library):=20a=20missing=20file=20sta?= =?UTF-8?q?ys=20in=20the=20playlist,=20greyed=20and=20unplayable=20?= =?UTF-8?q?=E2=80=94=20#2527?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every browse, discover and mix query filters missing_since, so a track whose file vanished disappears from the places Minstrel chooses music. A playlist is different: the entry is there because the user put it there, and silently dropping it rewrites their list behind their back. So playlists keep the row and mark it instead. ListPlaylistTracks now carries missing_since (still deliberately unfiltered), the service layer surfaces it as PlaylistTrack.Unavailable, and the wire gains "unavailable" on each entry. A missing entry also loses its stream_url. Refusing to hand out a URL that cannot serve is stronger than trusting every client to honour the flag, and "stream_url": null is a shape the clients already model -- PlaylistWire.streamUrl is documented nullable for the track-removed case -- so an older build degrades to "present but not playable" with no change. Nothing is deleted here and nothing should be: the row, its play history, its likes and its taste contribution all survive a file going missing, because the file may come back (and #2528 will adopt it if it comes back renamed). Also corrects two comments that had drifted into lying. delete.go still claimed the file-gone case was NOT auto-reconciled and told admins to delete rows by hand -- untrue since f6d1cf24, and that exact staleness is what produced drift #572. It now says what DeleteTrackFile really is: the destructive admin action, which CASCADEs play_events and likes, and is emphatically not the missing-file path. watcher.go claimed the safety-net scan "covers anything missed"; the walk only covers additions, and it is reconcile that covers removals. --- internal/api/playlists.go | 17 ++++++++++++-- internal/db/dbq/playlists.sql.go | 39 +++++++++++++++++++------------ internal/db/queries/playlists.sql | 13 ++++++++--- internal/library/delete.go | 23 ++++++++++++------ internal/library/watcher.go | 8 +++++++ internal/playlists/service.go | 6 +++++ 6 files changed, 79 insertions(+), 27 deletions(-) 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/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/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/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/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 — From 4dd0a58d63ae56f9ac8d71c5173bf73d34942edf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 16 Aug 2026 11:48:45 -0400 Subject: [PATCH 03/23] =?UTF-8?q?feat(api):=20admin=20surface=20for=20file?= =?UTF-8?q?s=20the=20library=20has=20lost=20=E2=80=94=20#2527?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan has marked missing files since f6d1cf24 and every selection path filters them out, so they cause no harm -- and are invisible. The operator found out about the first batch only because an unrelated MBID backfill logged "no such file or directory" forty times. GET /api/admin/library/missing reports them, grouped by directory. The grouping is the whole ergonomic argument: the case that produced #2523 was three reorganised albums, which a flat list renders as forty unrelated problems and a folder list renders as three decisions. ListMissingTracks orders by directory so the handler can fold runs without a map, which also keeps the query's ordering instead of Go's random map iteration. Each row carries last_played_at, nullable, because "gone six months, never played" and "gone yesterday, played 200 times" deserve opposite reactions and a file path tells you neither. The correlated MAX needs its ::timestamptz cast or sqlc infers interface{} and the Go layer loses the type. Read-only, deliberately. Nothing here deletes: a missing file keeps its row, its play history and its likes because it may come back, and if it comes back renamed the scanner adopts it (#2528). The route sits under /library rather than /tracks so it can't be confused with the destructive DELETE /admin/tracks/{id} beside it. --- internal/api/admin_library_missing.go | 134 +++++++++++++++++++++ internal/api/admin_library_missing_test.go | 130 ++++++++++++++++++++ internal/api/api.go | 5 + internal/db/dbq/tracks.sql.go | 99 +++++++++++++++ internal/db/queries/tracks.sql | 39 ++++++ 5 files changed, 407 insertions(+) create mode 100644 internal/api/admin_library_missing.go create mode 100644 internal/api/admin_library_missing_test.go diff --git a/internal/api/admin_library_missing.go b/internal/api/admin_library_missing.go new file mode 100644 index 00000000..6725f8ef --- /dev/null +++ b/internal/api/admin_library_missing.go @@ -0,0 +1,134 @@ +package api + +import ( + "net/http" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// 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"` +} + +// 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"` +} + +// 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 + } + + out := adminMissingResponse{ + Total: total, + Limit: limit, + Offset: offset, + Groups: groupMissingByDirectory(rows), + } + 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 +} 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/api.go b/internal/api/api.go index 2f9f189e..5c04caa7 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -197,6 +197,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev admin.Get("/scan/status", h.handleGetScanStatus) admin.Post("/scan/run", h.handleTriggerScan) + // 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) 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/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; From 4c49ee2cc6fefe9f1ef3c901ca95852a3182de1e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 16 Aug 2026 11:55:36 -0400 Subject: [PATCH 04/23] =?UTF-8?q?feat(web):=20a=20playlist=20entry=20whose?= =?UTF-8?q?=20file=20is=20missing=20greys=20out=20and=20is=20skipped=20?= =?UTF-8?q?=E2=80=94=20#2527?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row treatment for a dead playlist entry already existed -- muted text, no play on click, no drag, no kebab, never "now playing" -- but it only fired for track_id === null, the track-deleted case. A missing file kept a live-looking row that failed on click. The behavioural gate now covers both, and the presentation distinguishes them, because they mean different things to the person reading the list. A removed track is gone for good and keeps the strikethrough. A missing file is a track we still have -- history, likes, the lot -- whose bytes aren't on disk right now, so it gets an explicit "File missing" and a title explaining it stays in the playlist and comes back on its own if the file does. A strikethrough there would claim it was deleted, which is a lie about a file the scanner may well adopt back tomorrow (#2528). Skipping routes through playlistTrackToRef, which already returned null for removed tracks and whose callers already filter nulls. Adding the unavailable check there means every queue builder -- PlaylistCard, systemRefetch, the detail page -- skips a missing file without any of them learning what missing_since is. Remove stays available on a dead row: the owner must still be able to take it out of their own list. --- web/src/lib/api/types.ts | 6 +++ web/src/lib/components/PlaylistCard.test.ts | 6 ++- .../lib/components/PlaylistTrackRow.svelte | 18 ++++++- .../lib/components/PlaylistTrackRow.test.ts | 39 +++++++++++++- .../lib/playlists/playlistTrackToRef.test.ts | 51 +++++++++++++++++++ web/src/lib/playlists/playlistTrackToRef.ts | 12 +++-- .../routes/playlists/[id]/playlist.test.ts | 4 +- 7 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 web/src/lib/playlists/playlistTrackToRef.test.ts diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index bb1645c3..34f5d364 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 & { 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/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/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 } ] }; From aab90a7a39e6756858348ffc7665431b39faba0c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 16 Aug 2026 11:57:55 -0400 Subject: [PATCH 05/23] =?UTF-8?q?feat(android):=20name=20the=20missing=20f?= =?UTF-8?q?ile=20behind=20a=20greyed=20playlist=20row=20=E2=80=94=20#2527?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android was already skipping these by accident: toPlayableTrackRefs filters on a non-empty streamUrl, and the server stopped emitting one for a missing file, so they never reached the queue. Correct behaviour, no idea why -- the row just sat there greyed with the same treatment as a track deleted from the library, which is a different and permanent thing. isAvailable now covers both cases explicitly rather than inferring one from an empty URL, so every reader (row alpha, click gating, queue building) gets the same answer from one place. The flag stands on its own deliberately: a detail fetched before the file went missing can still carry a stale streamUrl from cache, and that must not resurrect the row. The row says which kind of dead it is. A missing file gets "· File missing" on the subtitle line, because that one can fix itself -- the scanner clears the mark when the file returns and adopts the row if it returns renamed (#2528) -- so it is worth telling the user about. A removed track keeps its bare greyed treatment; there is nothing to act on once it's gone from the library. Matches the web treatment landed in 4c49ee2c (rules #23/#27: parity, not web-only). --- .../fabledsword/minstrel/models/Playlist.kt | 20 +++++- .../minstrel/models/wire/PlaylistWire.kt | 9 +++ .../playlists/data/PlaylistsRepository.kt | 1 + .../playlists/ui/PlaylistDetailScreen.kt | 9 ++- .../data/PlaylistTrackAvailabilityTest.kt | 71 +++++++++++++++++++ 5 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/playlists/data/PlaylistTrackAvailabilityTest.kt 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..7e918400 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 @@ -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/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/playlists/data/PlaylistsRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt index d7b697ce..b62ada1a 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 @@ -326,4 +326,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 4e69ffd6..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 @@ -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/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 }) + } +} From 845f45fb0bc8a11e66a133060c314030a8ecf236 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 16 Aug 2026 12:01:13 -0400 Subject: [PATCH 06/23] =?UTF-8?q?refactor(web):=20one=20relativeTime=20for?= =?UTF-8?q?=20the=20triage=20surfaces=20=E2=80=94=20#2527?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin quarantine, admin playback-errors and library/hidden each carried a byte-identical private copy of the same coarse "3d ago / 5h ago / 12m ago / just now" formatter. Writing the missing-files surface would have made it four, so extract it instead. They are one concept, not three that happen to look alike: each shows the age of something an operator is deciding about, and they have to agree -- a row reading "2d ago" on one screen and "2 days" on another makes the reader wonder whether the two mean different things. Three near neighbours are deliberately NOT folded in, because they are different intents rather than drifted copies: - HistoryRow shows a weekday and clock time under a week ("Tue 21:40"): for listening history, WHEN you played something beats how long ago. - ActiveSessions.when() writes prose ("1 hour ago", "yesterday") and falls back to a locale date past 30 days -- a security surface where the longer form reads better. - PlaylistCard.refreshedLabel() is day-boundary aware and prefixed ("Refreshed today"), and already carries a comment saying it is deliberately not the m/h-ago style. Merging any of those would mean forcing one caller's wording onto another, which is the wrong-abstraction failure, so they stay put. Tests pin the boundaries the copies never covered: each unit step, that only the largest whole unit is reported (25h is "1d ago", never "1d 1h ago"), and that a future timestamp from a skewed client clock degrades to "just now" instead of rendering a negative age. --- web/src/lib/utils/relativeTime.test.ts | 45 +++++++++++++++++++ web/src/lib/utils/relativeTime.ts | 27 +++++++++++ .../routes/admin/playback-errors/+page.svelte | 12 +---- web/src/routes/admin/quarantine/+page.svelte | 12 +---- web/src/routes/library/hidden/+page.svelte | 12 +---- 5 files changed, 75 insertions(+), 33 deletions(-) create mode 100644 web/src/lib/utils/relativeTime.test.ts create mode 100644 web/src/lib/utils/relativeTime.ts diff --git a/web/src/lib/utils/relativeTime.test.ts b/web/src/lib/utils/relativeTime.test.ts new file mode 100644 index 00000000..05fc20da --- /dev/null +++ b/web/src/lib/utils/relativeTime.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { relativeTime } 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'); + }); +}); diff --git a/web/src/lib/utils/relativeTime.ts b/web/src/lib/utils/relativeTime.ts new file mode 100644 index 00000000..7ab3398a --- /dev/null +++ b/web/src/lib/utils/relativeTime.ts @@ -0,0 +1,27 @@ +/** + * 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'; +} 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); From 8d1f2674fdf1ab9f8070a5d404cf7481d6f8c7a5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 16 Aug 2026 12:03:59 -0400 Subject: [PATCH 07/23] =?UTF-8?q?feat(web):=20admin=20page=20for=20files?= =?UTF-8?q?=20the=20library=20has=20lost=20=E2=80=94=20#2527?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders GET /api/admin/library/missing under Admin -> Missing files. Folder-grouped, because that is the unit an operator decides about: the case behind #2523 was three reorganised albums, and forty individual rows hides that it is really three decisions. Each row leads with the fact that settles whether a missing file is worth chasing -- "last played 2d ago" against "never played". The group header carries how many tracks and how long they have been gone. Read-only. No remove button anywhere: the row, its play history and its likes survive a file going missing, and the scanner clears the mark by itself when the file returns (or adopts the row if it returns renamed, #2528). The page says so in its own copy rather than leaving the operator to infer it. Empty state explains the feature instead of the emptiness -- what puts a row here (moved outside Minstrel, deleted, a drive that didn't mount) and that rows leave on their own. Someone who has never seen this page should not have to guess. Paging follows the house pattern -- plain offset into the factory, wrapped in $derived so a page change re-creates the query with a new key. Passing a getter instead would capture the key once and paging would silently not refetch. The pager only renders when it can do something. --- web/src/lib/api/admin.ts | 27 +++ web/src/lib/api/queries.ts | 2 + web/src/lib/api/types.ts | 36 ++++ web/src/lib/components/AdminTabs.svelte | 1 + .../routes/admin/missing-files/+page.svelte | 164 ++++++++++++++++++ .../admin/missing-files/missing-files.test.ts | 119 +++++++++++++ 6 files changed, 349 insertions(+) create mode 100644 web/src/routes/admin/missing-files/+page.svelte create mode 100644 web/src/routes/admin/missing-files/missing-files.test.ts diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index d1eb16c5..c4cdd519 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,29 @@ 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 + }); +} 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 34f5d364..a954814c 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -369,3 +369,39 @@ 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; +}; + +// 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[]; +}; + +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/routes/admin/missing-files/+page.svelte b/web/src/routes/admin/missing-files/+page.svelte new file mode 100644 index 00000000..9c552ef7 --- /dev/null +++ b/web/src/routes/admin/missing-files/+page.svelte @@ -0,0 +1,164 @@ + + +{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)} + +
    + +
      + {#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..e1e5c515 --- /dev/null +++ b/web/src/routes/admin/missing-files/missing-files.test.ts @@ -0,0 +1,119 @@ +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'; + +vi.mock('$lib/api/admin', () => ({ + createMissingFilesQuery: 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(), + 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(), + tracks: [track('1969')] + } + ] +}; + +afterEach(() => vi.clearAllMocks()); + +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(); + }); + + // 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); + }); +}); From a31b672b14852b3261b871bd2a17fe6c5e84c214 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 16 Aug 2026 12:08:42 -0400 Subject: [PATCH 08/23] =?UTF-8?q?test(web):=20admin=20nav=20is=20nine=20ta?= =?UTF-8?q?bs=20=E2=80=94=20#2527?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tab list is pinned by name and order, so adding Missing files failed the assertion. That is the test doing its job: the nav is a deliberate ordering, not an accident, and a new entry should have to be declared rather than slipping in. --- web/src/lib/components/AdminTabs.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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', From d9238ec5bee7679662c46ceed253066c37c49ea7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 16 Aug 2026 12:43:17 -0400 Subject: [PATCH 09/23] =?UTF-8?q?fix(android):=20notice=20when=20a=20Sonos?= =?UTF-8?q?=20stops=20on=20its=20own,=20and=20get=20it=20going=20again=20?= =?UTF-8?q?=E2=80=94=20#2700?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-08-16 diagnostics show a session that did not stutter so much as end. In Doze the 1Hz poll freezes -- 14:22:53 and 14:26:14 report byte-identical snapshots, and that flat 5000ms sonos-vs-local delta is the last poll's staleness held still, not drift. When the screen came back on, the first poll in 3.5 minutes found queue track 10 at 113s, stopped. The Sonos had advanced, played 1:53 of a flac and quit while the phone slept. The app then reported that faithfully and did nothing about it, twice more, until the operator noticed. A UPnP renderer streams autonomously, which is the whole point of casting and also why a dead stream is invisible: pollOnce read STOPPED, called applyTransportStopped and returned. Nothing asked "we meant to be playing -- why aren't we?" RemoteStallWatchdog asks. It is pure decision state -- no coroutines, no SOAP -- so the counting, keying and giving-up is testable without a renderer, and pollOnce just acts on the verdict. Conservative by construction: - STOPPED or an error status only. PAUSED is left alone: that is somebody at the Sonos app or a wall controller, and taking the transport back off a person is a fight they always lose. A stream that dies stops, it does not pause. - Only against play intent. A stop we asked for is not a stall. - Three consecutive polls must agree. Sonos passes through STOPPED between queue items, so one reading would make every track change fight itself. - Three attempts per track, 5s apart, then give up -- an unplayable file must not become an infinite retry loop against a speaker. - Resume seeks back to the last position seen while playing, so a stream that died 90s in comes back near there, not at zero. GetTransportInfo now keeps CurrentTransportStatus, which it previously parsed and discarded. ERROR_OCCURRED is the only unambiguous way to tell "the stream died" from "somebody pressed stop", since both land in STOPPED. Absent or unrecognised reads as OK so a quiet renderer is never mistaken for a broken one. Giving up reports kind="stalled" through the existing PlaybackErrorReporter: snackbar for the user, admin-inbox row for the operator. That kind has been in migration 0032's CHECK whitelist and labelled on the admin page since the table was built, and nothing had ever emitted it. This does not explain WHY the stream died -- see #2700 for the hairpin-routing lead. It does mean a dropout is a recoverable hiccup instead of the end of the session. --- .../player/MinstrelForwardingPlayer.kt | 64 ++++++ .../minstrel/player/PlayerController.kt | 22 +++ .../minstrel/player/PlayerFactory.kt | 10 + .../minstrel/player/RemoteStallWatchdog.kt | 159 +++++++++++++++ .../player/output/upnp/AVTransportClient.kt | 16 +- .../player/RemoteStallWatchdogTest.kt | 187 ++++++++++++++++++ 6 files changed, 456 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/RemoteStallWatchdog.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/player/RemoteStallWatchdogTest.kt 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/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt index 0a4222b6..28a8481c 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 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/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/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/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) + } +} From 3eada70aac62971b454344be68b34c2efa285abe Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 16 Aug 2026 15:59:37 -0400 Subject: [PATCH 10/23] =?UTF-8?q?feat(android):=20Genres=20and=20Years=20b?= =?UTF-8?q?rowse=20axes=20in=20the=20Library=20=E2=80=94=20#2467?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #367 shipped genre and year browsing on web only, which left the web tab bar's own comment -- "mirrors Android's LibraryScreen" -- half aspirational. Android now has both, straight after Albums, in the same order the web bar uses. Server-backed, and that is the one real decision here. Every other Library tab reads Room, and building these indexes locally was the obvious move: the cache is a full mirror and carries both genre and releaseDate. It does not work. /api/library/sync hydrates through GetTracksByIDs, which has no missing_since filter, and neither SyncTrackWire nor CachedTrackEntity has a field for it -- so the cache holds tracks whose files are gone and cannot tell you which, while the browse index excludes them. A locally-derived index would quietly disagree with the server's and with the web client, and could offer a genre that exists only in missing files. Filed as #2704; until it is resolved these two tabs need a connection, and their empty states say what they are rather than looking broken. Genre is a query parameter end to end, never a path segment: "Rock/Pop" is a real ID3 tag and a slash does not survive a path. That is also why the drill-down is a second state inside the tab instead of a nav destination -- a route would have had to carry the label. Index shapes mirror web because the reasoning was already worked out there: genres default to count order, since raw tags carry a long tail of one-offs that A-Z buries the real genres under, with an A-Z chip for when you already know the name; years group by decade, newest first, because a flat list of every year in a decades-deep library is a wall of numbers. Page size matches web's BROWSE_PAGE_SIZE so "Load more (N left)" steps identically on both. The orderings and grouping are pure functions, tested: server order left alone under count sort, case-insensitive A-Z, no mutation of the loaded state's list, a slashed tag surviving the filter intact, decade bucketing including the boundary year, and a count label that stays blank rather than flashing "0 albums" while the first page loads. --- .../minstrel/api/endpoints/LibraryApi.kt | 46 +++ .../library/data/LibraryRepository.kt | 45 +++ .../minstrel/library/ui/BrowseViewModel.kt | 266 ++++++++++++++ .../minstrel/library/ui/GenresTab.kt | 341 ++++++++++++++++++ .../minstrel/library/ui/LibraryScreen.kt | 26 +- .../minstrel/library/ui/YearsTab.kt | 161 +++++++++ .../minstrel/models/wire/BrowseWire.kt | 33 ++ .../minstrel/library/ui/BrowseIndexTest.kt | 148 ++++++++ 8 files changed, 1060 insertions(+), 6 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/library/ui/BrowseViewModel.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/library/ui/GenresTab.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/library/ui/YearsTab.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/models/wire/BrowseWire.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/library/ui/BrowseIndexTest.kt 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..51156f25 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,7 +3,10 @@ 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 @@ -54,6 +57,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/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/BrowseViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/BrowseViewModel.kt new file mode 100644 index 00000000..e0229dc3 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/BrowseViewModel.kt @@ -0,0 +1,266 @@ +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 }) + } +} + +/** 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 / 10) * 10 } + .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 1520d394..3e70fb43 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 @@ -52,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 + * `flutter_client/lib/library/library_screen.dart`. * * Artists + Albums are wired against the existing LibraryViewModel * (cache-first reads of cached_artists / cached_albums). The other @@ -118,6 +120,12 @@ fun LibraryScreen( 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)) }, @@ -131,11 +139,17 @@ fun LibraryScreen( 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/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/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)) + } +} From bfb6c9acfe37cc3701959041153ccb63402a1565 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 16 Aug 2026 16:04:22 -0400 Subject: [PATCH 11/23] =?UTF-8?q?style(android):=20satisfy=20detekt=20on?= =?UTF-8?q?=20the=20new=20browse=20tabs=20=E2=80=94=20#2467?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings, both fair: LibraryScreen was one line over the 60-line cap once Genres and Years were added to its pager. Split the page bodies into LibraryTabPage, so the screen is the scaffold and tab bar while the routing table lives on its own -- adding a tab is now one line there and one label in LIBRARY_TABS, rather than growing a function that was already at its limit. The decade arithmetic used a bare 10 twice. Named it YEARS_PER_DECADE: floor-to-decade reads as arbitrary without it. --- .../minstrel/library/ui/BrowseViewModel.kt | 7 ++- .../minstrel/library/ui/LibraryScreen.kt | 46 ++++++++++++------- 2 files changed, 36 insertions(+), 17 deletions(-) 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 index e0229dc3..957c1ed4 100644 --- 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 @@ -240,6 +240,11 @@ fun visibleGenres( } } +// 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, @@ -255,7 +260,7 @@ data class DecadeGroup( * same reason as [visibleGenres]. */ fun groupByDecade(years: List): List = - years.groupBy { (it.year / 10) * 10 } + years.groupBy { (it.year / YEARS_PER_DECADE) * YEARS_PER_DECADE } .map { (decade, entries) -> DecadeGroup( decade = decade, 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 3e70fb43..47e4ab5b 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 @@ -117,26 +117,40 @@ 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_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() - } + 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_GENRES = 2 From 0036f534dbd077e66e277f5074066c2c9235b632 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 16 Aug 2026 22:31:39 -0400 Subject: [PATCH 12/23] =?UTF-8?q?chore:=20delete=20the=20Flutter=20client?= =?UTF-8?q?=20=E2=80=94=20#2710?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Superseded by the M8 native Android rewrite. Last touched 2026-05-31, no workflow has built it since flutter.yml was removed, and rule #22 says a replaced path goes rather than lingering as something a reader has to work out the status of. 245 files, ~24.6k lines. Config references go with it: the .gitignore block (and its now-empty "# Flutter" header), the .dockerignore entry, and renovate's ignorePaths entry, which was suppressing dependency scanning for a directory that no longer exists. ci-requirements.md said ci-flutter "will retire once that directory goes". It has gone, so the doc now says so -- CI-Runner can drop the image, and nothing in this repo needs a Flutter toolchain. One thing is kept rather than deleted: shared/fabledsword.tokens.json. It lived under flutter_client/shared/ but was never Flutter's property -- it is the canonical statement of the palette, the only place the dark, light and flat cohorts are written down together, and FabledSwordTokens.kt names it as its source of truth. Losing it would have been collateral damage, so it moves to the repo root with a README saying what it is and that neither client generates from it. That comment in FabledSwordTokens.kt is repointed here. What deliberately does NOT change: `runs-on: flutter-ci` in android.yml and release.yml. That is a runner LABEL, not a path -- the Android jobs schedule on it while pulling ci-android:36, per the label/image split ci-requirements.md documents. Removing it would break scheduling for a cosmetic win, so the doc now spells that out beside the retirement note. Left for #2710: 64 files whose comments still name flutter_client/ paths. Sweeping them here would have buried the deletion, and each needs a judgement -- keep the substance and drop the dead path, delete pure "ported from" bookkeeping, or leave design rationale that happens to mention the Flutter build. --- .dockerignore | 1 - .gitignore | 14 - .../minstrel/theme/FabledSwordTokens.kt | 3 +- ci-requirements.md | 15 +- flutter_client/.gitignore | 49 - flutter_client/.metadata | 33 - flutter_client/README.md | 81 -- flutter_client/analysis_options.yaml | 12 - flutter_client/android/.gitignore | 14 - flutter_client/android/app/build.gradle.kts | 71 - .../android/app/src/debug/AndroidManifest.xml | 7 - .../android/app/src/main/AndroidManifest.xml | 89 -- .../com/fabledsword/minstrel/MainActivity.kt | 70 - .../res/drawable-v21/launch_background.xml | 12 - .../main/res/drawable/ic_stat_favorite.xml | 11 - .../res/drawable/ic_stat_favorite_border.xml | 15 - .../main/res/drawable/launch_background.xml | 12 - .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 544 -> 0 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 442 -> 0 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 721 -> 0 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 1031 -> 0 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 1443 -> 0 bytes .../app/src/main/res/values-night/styles.xml | 18 - .../app/src/main/res/values/styles.xml | 18 - .../app/src/main/res/xml/file_paths.xml | 10 - .../main/res/xml/network_security_config.xml | 21 - .../app/src/profile/AndroidManifest.xml | 7 - flutter_client/android/build.gradle.kts | 24 - flutter_client/android/gradle.properties | 2 - .../gradle/wrapper/gradle-wrapper.properties | 5 - flutter_client/android/settings.gradle.kts | 26 - flutter_client/assets/error-copy.json | 48 - flutter_client/assets/svg/album-fallback.svg | 20 - flutter_client/ios/.gitignore | 34 - .../ios/Flutter/AppFrameworkInfo.plist | 24 - flutter_client/ios/Flutter/Debug.xcconfig | 1 - flutter_client/ios/Flutter/Release.xcconfig | 1 - .../ios/Runner.xcodeproj/project.pbxproj | 620 -------- .../contents.xcworkspacedata | 7 - .../xcshareddata/IDEWorkspaceChecks.plist | 8 - .../xcshareddata/WorkspaceSettings.xcsettings | 8 - .../xcshareddata/xcschemes/Runner.xcscheme | 101 -- .../contents.xcworkspacedata | 7 - .../xcshareddata/IDEWorkspaceChecks.plist | 8 - .../xcshareddata/WorkspaceSettings.xcsettings | 8 - flutter_client/ios/Runner/AppDelegate.swift | 16 - .../AppIcon.appiconset/Contents.json | 122 -- .../Icon-App-1024x1024@1x.png | Bin 10932 -> 0 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 295 -> 0 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 406 -> 0 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 450 -> 0 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 282 -> 0 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 462 -> 0 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 704 -> 0 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 406 -> 0 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 586 -> 0 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 862 -> 0 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 862 -> 0 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 1674 -> 0 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 762 -> 0 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 1226 -> 0 bytes .../Icon-App-83.5x83.5@2x.png | Bin 1418 -> 0 bytes .../LaunchImage.imageset/Contents.json | 23 - .../LaunchImage.imageset/LaunchImage.png | Bin 68 -> 0 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 68 -> 0 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 68 -> 0 bytes .../LaunchImage.imageset/README.md | 5 - .../Runner/Base.lproj/LaunchScreen.storyboard | 37 - .../ios/Runner/Base.lproj/Main.storyboard | 26 - flutter_client/ios/Runner/Info.plist | 74 - .../ios/Runner/Runner-Bridging-Header.h | 1 - flutter_client/ios/Runner/SceneDelegate.swift | 6 - .../ios/RunnerTests/RunnerTests.swift | 12 - .../lib/admin/admin_landing_screen.dart | 67 - flutter_client/lib/admin/admin_providers.dart | 237 --- .../lib/admin/admin_quarantine_screen.dart | 67 - .../lib/admin/admin_requests_screen.dart | 81 -- .../lib/admin/admin_users_screen.dart | 201 --- .../admin/widgets/admin_quarantine_row.dart | 102 -- .../lib/admin/widgets/admin_request_row.dart | 75 - .../lib/admin/widgets/admin_section_card.dart | 53 - .../admin/widgets/admin_user_edit_sheet.dart | 167 --- .../lib/admin/widgets/admin_user_row.dart | 59 - .../lib/admin/widgets/invite_row.dart | 73 - .../admin/widgets/typed_confirm_sheet.dart | 124 -- flutter_client/lib/api/client.dart | 41 - .../lib/api/endpoints/admin_invites.dart | 32 - .../lib/api/endpoints/admin_quarantine.dart | 31 - .../lib/api/endpoints/admin_requests.dart | 26 - .../lib/api/endpoints/admin_users.dart | 47 - flutter_client/lib/api/endpoints/auth.dart | 33 - .../lib/api/endpoints/discover.dart | 73 - flutter_client/lib/api/endpoints/events.dart | 91 -- flutter_client/lib/api/endpoints/health.dart | 14 - flutter_client/lib/api/endpoints/library.dart | 121 -- .../lib/api/endpoints/library_lists.dart | 36 - flutter_client/lib/api/endpoints/likes.dart | 70 - flutter_client/lib/api/endpoints/me.dart | 49 - .../lib/api/endpoints/playlists.dart | 80 -- .../lib/api/endpoints/quarantine.dart | 25 - flutter_client/lib/api/endpoints/radio.dart | 27 - .../lib/api/endpoints/requests.dart | 31 - flutter_client/lib/api/endpoints/search.dart | 29 - .../lib/api/endpoints/settings.dart | 57 - flutter_client/lib/api/error_copy.dart | 22 - flutter_client/lib/api/errors.dart | 33 - flutter_client/lib/app.dart | 107 -- flutter_client/lib/auth/auth_provider.dart | 89 -- flutter_client/lib/auth/login_screen.dart | 95 -- .../lib/auth/server_url_screen.dart | 82 -- flutter_client/lib/cache/adapters.dart | 133 -- .../lib/cache/audio_cache_manager.dart | 296 ---- flutter_client/lib/cache/cache_filler.dart | 235 --- flutter_client/lib/cache/cache_first.dart | 115 -- .../lib/cache/cache_settings_provider.dart | 114 -- .../lib/cache/connectivity_provider.dart | 35 - flutter_client/lib/cache/db.dart | 343 ----- flutter_client/lib/cache/hydration_queue.dart | 139 -- .../lib/cache/metadata_prefetcher.dart | 67 - flutter_client/lib/cache/mutation_queue.dart | 311 ---- .../lib/cache/offline_provider.dart | 105 -- flutter_client/lib/cache/prefetcher.dart | 108 -- .../lib/cache/resume_controller.dart | 183 --- flutter_client/lib/cache/shuffle_source.dart | 104 -- flutter_client/lib/cache/sync_controller.dart | 360 ----- flutter_client/lib/cache/tile_providers.dart | 144 -- .../lib/discover/discover_screen.dart | 463 ------ .../lib/library/album_detail_screen.dart | 139 -- .../lib/library/artist_detail_screen.dart | 209 --- flutter_client/lib/library/home_screen.dart | 609 -------- .../lib/library/library_providers.dart | 407 ------ .../lib/library/library_screen.dart | 936 ------------ .../lib/library/widgets/album_card.dart | 112 -- .../lib/library/widgets/artist_card.dart | 101 -- .../lib/library/widgets/cached_indicator.dart | 30 - .../library/widgets/compact_track_card.dart | 84 -- .../widgets/horizontal_scroll_row.dart | 49 - .../library/widgets/play_circle_button.dart | 97 -- .../lib/library/widgets/track_row.dart | 100 -- flutter_client/lib/likes/like_button.dart | 37 - flutter_client/lib/likes/likes_provider.dart | 174 --- flutter_client/lib/main.dart | 30 - .../lib/models/admin_quarantine_item.dart | 92 -- flutter_client/lib/models/admin_request.dart | 77 - flutter_client/lib/models/admin_user.dart | 33 - flutter_client/lib/models/album.dart | 40 - flutter_client/lib/models/artist.dart | 30 - .../lib/models/artist_suggestion.dart | 51 - flutter_client/lib/models/history_event.dart | 44 - flutter_client/lib/models/home_data.dart | 46 - flutter_client/lib/models/home_index.dart | 41 - flutter_client/lib/models/invite.dart | 40 - flutter_client/lib/models/lidarr.dart | 47 - flutter_client/lib/models/my_profile.dart | 49 - flutter_client/lib/models/page.dart | 44 - flutter_client/lib/models/playlist.dart | 127 -- .../lib/models/quarantine_mine.dart | 46 - .../lib/models/search_response.dart | 38 - .../lib/models/system_playlists_status.dart | 24 - flutter_client/lib/models/track.dart | 58 - flutter_client/lib/models/user.dart | 24 - .../lib/player/album_color_extractor.dart | 92 -- .../lib/player/album_cover_cache.dart | 94 -- flutter_client/lib/player/audio_handler.dart | 1035 ------------- .../lib/player/now_playing_screen.dart | 655 --------- .../lib/player/play_events_reporter.dart | 277 ---- .../lib/player/playback_error_reporter.dart | 78 - flutter_client/lib/player/player_bar.dart | 346 ----- .../lib/player/player_provider.dart | 198 --- flutter_client/lib/player/queue_screen.dart | 119 -- .../lib/playlists/playlist_detail_screen.dart | 396 ----- .../lib/playlists/playlists_list_screen.dart | 137 -- .../lib/playlists/playlists_provider.dart | 403 ------ .../lib/playlists/widgets/playlist_card.dart | 249 ---- .../widgets/playlist_placeholder_card.dart | 87 -- .../lib/quarantine/quarantine_provider.dart | 184 --- .../lib/requests/requests_provider.dart | 65 - .../lib/requests/requests_screen.dart | 253 ---- .../lib/search/search_provider.dart | 37 - flutter_client/lib/search/search_screen.dart | 201 --- .../lib/settings/about_section.dart | 228 --- .../lib/settings/settings_screen.dart | 575 -------- .../lib/settings/storage_section.dart | 255 ---- .../lib/shared/delayed_loading.dart | 66 - .../lib/shared/live_events_dispatcher.dart | 104 -- .../lib/shared/live_events_provider.dart | 126 -- flutter_client/lib/shared/routing.dart | 174 --- .../widgets/connection_error_banner.dart | 31 - .../lib/shared/widgets/lucide_heart.dart | 43 - .../shared/widgets/main_app_bar_actions.dart | 64 - .../lib/shared/widgets/server_image.dart | 104 -- .../lib/shared/widgets/skeletons.dart | 199 --- .../track_actions/add_to_playlist_sheet.dart | 101 -- .../track_actions/hide_track_sheet.dart | 122 -- .../track_actions/track_actions_button.dart | 43 - .../track_actions/track_actions_sheet.dart | 304 ---- .../lib/shared/widgets/version_gate.dart | 265 ---- flutter_client/lib/theme/theme_data.dart | 58 - flutter_client/lib/theme/theme_extension.dart | 93 -- .../lib/theme/theme_mode_provider.dart | 40 - flutter_client/lib/theme/tokens.dart | 68 - .../lib/update/client_update_provider.dart | 143 -- flutter_client/lib/update/installer.dart | 46 - flutter_client/lib/update/update_banner.dart | 146 -- flutter_client/lib/update/update_info.dart | 21 - flutter_client/pubspec.lock | 1274 ----------------- flutter_client/pubspec.yaml | 70 - .../test/admin/admin_landing_screen_test.dart | 46 - .../admin/admin_quarantine_screen_test.dart | 94 -- .../admin/admin_requests_screen_test.dart | 94 -- .../test/admin/admin_users_screen_test.dart | 82 -- flutter_client/test/api/client_test.dart | 42 - .../test/api/endpoints/library_test.dart | 170 --- flutter_client/test/api/errors_test.dart | 57 - .../test/auth/auth_provider_test.dart | 52 - .../test/auth/login_screen_test.dart | 19 - flutter_client/test/cache/adapters_test.dart | 70 - .../test/cache/audio_cache_manager_test.dart | 159 -- .../test/cache/cache_first_test.dart | 82 -- .../cache/cache_settings_provider_test.dart | 78 - .../cache/connectivity_provider_test.dart | 14 - .../test/cache/prefetcher_test.dart | 12 - .../test/cache/sync_controller_test.dart | 142 -- .../library/album_detail_screen_test.dart | 28 - .../library/artist_detail_screen_test.dart | 24 - .../test/library/home_screen_test.dart | 108 -- .../widgets/compact_track_card_test.dart | 38 - .../test/library/widgets_smoke_test.dart | 68 - flutter_client/test/models/models_test.dart | 168 --- .../test/player/album_cover_cache_test.dart | 121 -- .../test/player/player_provider_test.dart | 42 - .../playlists/widgets/playlist_card_test.dart | 84 -- .../playlist_placeholder_card_test.dart | 49 - .../quarantine/quarantine_provider_test.dart | 153 -- .../test/requests/requests_screen_test.dart | 128 -- .../settings/appearance_section_test.dart | 82 -- .../test/settings/storage_section_test.dart | 73 - .../test/shared/delayed_loading_test.dart | 76 - .../shared/main_app_bar_actions_test.dart | 70 - .../add_to_playlist_sheet_test.dart | 114 -- .../track_actions/hide_track_sheet_test.dart | 86 -- .../track_actions_sheet_test.dart | 89 -- flutter_client/test/smoke_test.dart | 33 - .../test/theme/theme_extension_test.dart | 39 - .../test/theme/theme_mode_provider_test.dart | 76 - .../update/client_update_provider_test.dart | 75 - flutter_client/tool/gen_tokens.dart | 97 -- flutter_client/tool/sync_shared.sh | 13 - renovate.json | 3 +- shared/README.md | 16 + .../shared => shared}/fabledsword.tokens.json | 0 251 files changed, 29 insertions(+), 24630 deletions(-) delete mode 100644 flutter_client/.gitignore delete mode 100644 flutter_client/.metadata delete mode 100644 flutter_client/README.md delete mode 100644 flutter_client/analysis_options.yaml delete mode 100644 flutter_client/android/.gitignore delete mode 100644 flutter_client/android/app/build.gradle.kts delete mode 100644 flutter_client/android/app/src/debug/AndroidManifest.xml delete mode 100644 flutter_client/android/app/src/main/AndroidManifest.xml delete mode 100644 flutter_client/android/app/src/main/kotlin/com/fabledsword/minstrel/MainActivity.kt delete mode 100644 flutter_client/android/app/src/main/res/drawable-v21/launch_background.xml delete mode 100644 flutter_client/android/app/src/main/res/drawable/ic_stat_favorite.xml delete mode 100644 flutter_client/android/app/src/main/res/drawable/ic_stat_favorite_border.xml delete mode 100644 flutter_client/android/app/src/main/res/drawable/launch_background.xml delete mode 100644 flutter_client/android/app/src/main/res/mipmap-hdpi/ic_launcher.png delete mode 100644 flutter_client/android/app/src/main/res/mipmap-mdpi/ic_launcher.png delete mode 100644 flutter_client/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png delete mode 100644 flutter_client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png delete mode 100644 flutter_client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png delete mode 100644 flutter_client/android/app/src/main/res/values-night/styles.xml delete mode 100644 flutter_client/android/app/src/main/res/values/styles.xml delete mode 100644 flutter_client/android/app/src/main/res/xml/file_paths.xml delete mode 100644 flutter_client/android/app/src/main/res/xml/network_security_config.xml delete mode 100644 flutter_client/android/app/src/profile/AndroidManifest.xml delete mode 100644 flutter_client/android/build.gradle.kts delete mode 100644 flutter_client/android/gradle.properties delete mode 100644 flutter_client/android/gradle/wrapper/gradle-wrapper.properties delete mode 100644 flutter_client/android/settings.gradle.kts delete mode 100644 flutter_client/assets/error-copy.json delete mode 100644 flutter_client/assets/svg/album-fallback.svg delete mode 100644 flutter_client/ios/.gitignore delete mode 100644 flutter_client/ios/Flutter/AppFrameworkInfo.plist delete mode 100644 flutter_client/ios/Flutter/Debug.xcconfig delete mode 100644 flutter_client/ios/Flutter/Release.xcconfig delete mode 100644 flutter_client/ios/Runner.xcodeproj/project.pbxproj delete mode 100644 flutter_client/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata delete mode 100644 flutter_client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist delete mode 100644 flutter_client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings delete mode 100644 flutter_client/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme delete mode 100644 flutter_client/ios/Runner.xcworkspace/contents.xcworkspacedata delete mode 100644 flutter_client/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist delete mode 100644 flutter_client/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings delete mode 100644 flutter_client/ios/Runner/AppDelegate.swift delete mode 100644 flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json delete mode 100644 flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png delete mode 100644 flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png delete mode 100644 flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png delete mode 100644 flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png delete mode 100644 flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png delete mode 100644 flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png delete mode 100644 flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png delete mode 100644 flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png delete mode 100644 flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png delete mode 100644 flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png delete mode 100644 flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png delete mode 100644 flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png delete mode 100644 flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png delete mode 100644 flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png delete mode 100644 flutter_client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png delete mode 100644 flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json delete mode 100644 flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png delete mode 100644 flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png delete mode 100644 flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png delete mode 100644 flutter_client/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md delete mode 100644 flutter_client/ios/Runner/Base.lproj/LaunchScreen.storyboard delete mode 100644 flutter_client/ios/Runner/Base.lproj/Main.storyboard delete mode 100644 flutter_client/ios/Runner/Info.plist delete mode 100644 flutter_client/ios/Runner/Runner-Bridging-Header.h delete mode 100644 flutter_client/ios/Runner/SceneDelegate.swift delete mode 100644 flutter_client/ios/RunnerTests/RunnerTests.swift delete mode 100644 flutter_client/lib/admin/admin_landing_screen.dart delete mode 100644 flutter_client/lib/admin/admin_providers.dart delete mode 100644 flutter_client/lib/admin/admin_quarantine_screen.dart delete mode 100644 flutter_client/lib/admin/admin_requests_screen.dart delete mode 100644 flutter_client/lib/admin/admin_users_screen.dart delete mode 100644 flutter_client/lib/admin/widgets/admin_quarantine_row.dart delete mode 100644 flutter_client/lib/admin/widgets/admin_request_row.dart delete mode 100644 flutter_client/lib/admin/widgets/admin_section_card.dart delete mode 100644 flutter_client/lib/admin/widgets/admin_user_edit_sheet.dart delete mode 100644 flutter_client/lib/admin/widgets/admin_user_row.dart delete mode 100644 flutter_client/lib/admin/widgets/invite_row.dart delete mode 100644 flutter_client/lib/admin/widgets/typed_confirm_sheet.dart delete mode 100644 flutter_client/lib/api/client.dart delete mode 100644 flutter_client/lib/api/endpoints/admin_invites.dart delete mode 100644 flutter_client/lib/api/endpoints/admin_quarantine.dart delete mode 100644 flutter_client/lib/api/endpoints/admin_requests.dart delete mode 100644 flutter_client/lib/api/endpoints/admin_users.dart delete mode 100644 flutter_client/lib/api/endpoints/auth.dart delete mode 100644 flutter_client/lib/api/endpoints/discover.dart delete mode 100644 flutter_client/lib/api/endpoints/events.dart delete mode 100644 flutter_client/lib/api/endpoints/health.dart delete mode 100644 flutter_client/lib/api/endpoints/library.dart delete mode 100644 flutter_client/lib/api/endpoints/library_lists.dart delete mode 100644 flutter_client/lib/api/endpoints/likes.dart delete mode 100644 flutter_client/lib/api/endpoints/me.dart delete mode 100644 flutter_client/lib/api/endpoints/playlists.dart delete mode 100644 flutter_client/lib/api/endpoints/quarantine.dart delete mode 100644 flutter_client/lib/api/endpoints/radio.dart delete mode 100644 flutter_client/lib/api/endpoints/requests.dart delete mode 100644 flutter_client/lib/api/endpoints/search.dart delete mode 100644 flutter_client/lib/api/endpoints/settings.dart delete mode 100644 flutter_client/lib/api/error_copy.dart delete mode 100644 flutter_client/lib/api/errors.dart delete mode 100644 flutter_client/lib/app.dart delete mode 100644 flutter_client/lib/auth/auth_provider.dart delete mode 100644 flutter_client/lib/auth/login_screen.dart delete mode 100644 flutter_client/lib/auth/server_url_screen.dart delete mode 100644 flutter_client/lib/cache/adapters.dart delete mode 100644 flutter_client/lib/cache/audio_cache_manager.dart delete mode 100644 flutter_client/lib/cache/cache_filler.dart delete mode 100644 flutter_client/lib/cache/cache_first.dart delete mode 100644 flutter_client/lib/cache/cache_settings_provider.dart delete mode 100644 flutter_client/lib/cache/connectivity_provider.dart delete mode 100644 flutter_client/lib/cache/db.dart delete mode 100644 flutter_client/lib/cache/hydration_queue.dart delete mode 100644 flutter_client/lib/cache/metadata_prefetcher.dart delete mode 100644 flutter_client/lib/cache/mutation_queue.dart delete mode 100644 flutter_client/lib/cache/offline_provider.dart delete mode 100644 flutter_client/lib/cache/prefetcher.dart delete mode 100644 flutter_client/lib/cache/resume_controller.dart delete mode 100644 flutter_client/lib/cache/shuffle_source.dart delete mode 100644 flutter_client/lib/cache/sync_controller.dart delete mode 100644 flutter_client/lib/cache/tile_providers.dart delete mode 100644 flutter_client/lib/discover/discover_screen.dart delete mode 100644 flutter_client/lib/library/album_detail_screen.dart delete mode 100644 flutter_client/lib/library/artist_detail_screen.dart delete mode 100644 flutter_client/lib/library/home_screen.dart delete mode 100644 flutter_client/lib/library/library_providers.dart delete mode 100644 flutter_client/lib/library/library_screen.dart delete mode 100644 flutter_client/lib/library/widgets/album_card.dart delete mode 100644 flutter_client/lib/library/widgets/artist_card.dart delete mode 100644 flutter_client/lib/library/widgets/cached_indicator.dart delete mode 100644 flutter_client/lib/library/widgets/compact_track_card.dart delete mode 100644 flutter_client/lib/library/widgets/horizontal_scroll_row.dart delete mode 100644 flutter_client/lib/library/widgets/play_circle_button.dart delete mode 100644 flutter_client/lib/library/widgets/track_row.dart delete mode 100644 flutter_client/lib/likes/like_button.dart delete mode 100644 flutter_client/lib/likes/likes_provider.dart delete mode 100644 flutter_client/lib/main.dart delete mode 100644 flutter_client/lib/models/admin_quarantine_item.dart delete mode 100644 flutter_client/lib/models/admin_request.dart delete mode 100644 flutter_client/lib/models/admin_user.dart delete mode 100644 flutter_client/lib/models/album.dart delete mode 100644 flutter_client/lib/models/artist.dart delete mode 100644 flutter_client/lib/models/artist_suggestion.dart delete mode 100644 flutter_client/lib/models/history_event.dart delete mode 100644 flutter_client/lib/models/home_data.dart delete mode 100644 flutter_client/lib/models/home_index.dart delete mode 100644 flutter_client/lib/models/invite.dart delete mode 100644 flutter_client/lib/models/lidarr.dart delete mode 100644 flutter_client/lib/models/my_profile.dart delete mode 100644 flutter_client/lib/models/page.dart delete mode 100644 flutter_client/lib/models/playlist.dart delete mode 100644 flutter_client/lib/models/quarantine_mine.dart delete mode 100644 flutter_client/lib/models/search_response.dart delete mode 100644 flutter_client/lib/models/system_playlists_status.dart delete mode 100644 flutter_client/lib/models/track.dart delete mode 100644 flutter_client/lib/models/user.dart delete mode 100644 flutter_client/lib/player/album_color_extractor.dart delete mode 100644 flutter_client/lib/player/album_cover_cache.dart delete mode 100644 flutter_client/lib/player/audio_handler.dart delete mode 100644 flutter_client/lib/player/now_playing_screen.dart delete mode 100644 flutter_client/lib/player/play_events_reporter.dart delete mode 100644 flutter_client/lib/player/playback_error_reporter.dart delete mode 100644 flutter_client/lib/player/player_bar.dart delete mode 100644 flutter_client/lib/player/player_provider.dart delete mode 100644 flutter_client/lib/player/queue_screen.dart delete mode 100644 flutter_client/lib/playlists/playlist_detail_screen.dart delete mode 100644 flutter_client/lib/playlists/playlists_list_screen.dart delete mode 100644 flutter_client/lib/playlists/playlists_provider.dart delete mode 100644 flutter_client/lib/playlists/widgets/playlist_card.dart delete mode 100644 flutter_client/lib/playlists/widgets/playlist_placeholder_card.dart delete mode 100644 flutter_client/lib/quarantine/quarantine_provider.dart delete mode 100644 flutter_client/lib/requests/requests_provider.dart delete mode 100644 flutter_client/lib/requests/requests_screen.dart delete mode 100644 flutter_client/lib/search/search_provider.dart delete mode 100644 flutter_client/lib/search/search_screen.dart delete mode 100644 flutter_client/lib/settings/about_section.dart delete mode 100644 flutter_client/lib/settings/settings_screen.dart delete mode 100644 flutter_client/lib/settings/storage_section.dart delete mode 100644 flutter_client/lib/shared/delayed_loading.dart delete mode 100644 flutter_client/lib/shared/live_events_dispatcher.dart delete mode 100644 flutter_client/lib/shared/live_events_provider.dart delete mode 100644 flutter_client/lib/shared/routing.dart delete mode 100644 flutter_client/lib/shared/widgets/connection_error_banner.dart delete mode 100644 flutter_client/lib/shared/widgets/lucide_heart.dart delete mode 100644 flutter_client/lib/shared/widgets/main_app_bar_actions.dart delete mode 100644 flutter_client/lib/shared/widgets/server_image.dart delete mode 100644 flutter_client/lib/shared/widgets/skeletons.dart delete mode 100644 flutter_client/lib/shared/widgets/track_actions/add_to_playlist_sheet.dart delete mode 100644 flutter_client/lib/shared/widgets/track_actions/hide_track_sheet.dart delete mode 100644 flutter_client/lib/shared/widgets/track_actions/track_actions_button.dart delete mode 100644 flutter_client/lib/shared/widgets/track_actions/track_actions_sheet.dart delete mode 100644 flutter_client/lib/shared/widgets/version_gate.dart delete mode 100644 flutter_client/lib/theme/theme_data.dart delete mode 100644 flutter_client/lib/theme/theme_extension.dart delete mode 100644 flutter_client/lib/theme/theme_mode_provider.dart delete mode 100644 flutter_client/lib/theme/tokens.dart delete mode 100644 flutter_client/lib/update/client_update_provider.dart delete mode 100644 flutter_client/lib/update/installer.dart delete mode 100644 flutter_client/lib/update/update_banner.dart delete mode 100644 flutter_client/lib/update/update_info.dart delete mode 100644 flutter_client/pubspec.lock delete mode 100644 flutter_client/pubspec.yaml delete mode 100644 flutter_client/test/admin/admin_landing_screen_test.dart delete mode 100644 flutter_client/test/admin/admin_quarantine_screen_test.dart delete mode 100644 flutter_client/test/admin/admin_requests_screen_test.dart delete mode 100644 flutter_client/test/admin/admin_users_screen_test.dart delete mode 100644 flutter_client/test/api/client_test.dart delete mode 100644 flutter_client/test/api/endpoints/library_test.dart delete mode 100644 flutter_client/test/api/errors_test.dart delete mode 100644 flutter_client/test/auth/auth_provider_test.dart delete mode 100644 flutter_client/test/auth/login_screen_test.dart delete mode 100644 flutter_client/test/cache/adapters_test.dart delete mode 100644 flutter_client/test/cache/audio_cache_manager_test.dart delete mode 100644 flutter_client/test/cache/cache_first_test.dart delete mode 100644 flutter_client/test/cache/cache_settings_provider_test.dart delete mode 100644 flutter_client/test/cache/connectivity_provider_test.dart delete mode 100644 flutter_client/test/cache/prefetcher_test.dart delete mode 100644 flutter_client/test/cache/sync_controller_test.dart delete mode 100644 flutter_client/test/library/album_detail_screen_test.dart delete mode 100644 flutter_client/test/library/artist_detail_screen_test.dart delete mode 100644 flutter_client/test/library/home_screen_test.dart delete mode 100644 flutter_client/test/library/widgets/compact_track_card_test.dart delete mode 100644 flutter_client/test/library/widgets_smoke_test.dart delete mode 100644 flutter_client/test/models/models_test.dart delete mode 100644 flutter_client/test/player/album_cover_cache_test.dart delete mode 100644 flutter_client/test/player/player_provider_test.dart delete mode 100644 flutter_client/test/playlists/widgets/playlist_card_test.dart delete mode 100644 flutter_client/test/playlists/widgets/playlist_placeholder_card_test.dart delete mode 100644 flutter_client/test/quarantine/quarantine_provider_test.dart delete mode 100644 flutter_client/test/requests/requests_screen_test.dart delete mode 100644 flutter_client/test/settings/appearance_section_test.dart delete mode 100644 flutter_client/test/settings/storage_section_test.dart delete mode 100644 flutter_client/test/shared/delayed_loading_test.dart delete mode 100644 flutter_client/test/shared/main_app_bar_actions_test.dart delete mode 100644 flutter_client/test/shared/widgets/track_actions/add_to_playlist_sheet_test.dart delete mode 100644 flutter_client/test/shared/widgets/track_actions/hide_track_sheet_test.dart delete mode 100644 flutter_client/test/shared/widgets/track_actions/track_actions_sheet_test.dart delete mode 100644 flutter_client/test/smoke_test.dart delete mode 100644 flutter_client/test/theme/theme_extension_test.dart delete mode 100644 flutter_client/test/theme/theme_mode_provider_test.dart delete mode 100644 flutter_client/test/update/client_update_provider_test.dart delete mode 100644 flutter_client/tool/gen_tokens.dart delete mode 100755 flutter_client/tool/sync_shared.sh create mode 100644 shared/README.md rename {flutter_client/shared => shared}/fabledsword.tokens.json (100%) 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/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/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/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 db77bb4b7b0906d62b1847e87f15cdcacf6a4f29..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ 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 17987b79bb8a35cc66c3c1fd44f5a5526c1b78be..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ 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 d5f1c8d34e7a88e3f88bea192c3a370d44689c3c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof 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 4d6372eebdb28e45604e46eeda8dd24651419bc0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` 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 dc9ada4725e9b0ddb1deab583e5b5102493aa332..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10932 zcmeHN2~<R zh`|8`A_PQ1nSu(UMFx?8j8PC!!VDphaL#`F42fd#7Vlc`zIE4n%Y~eiz4y1j|NDpi z?<@|pSJ-HM`qifhf@m%MamgwK83`XpBA<+azdF#2QsT{X@z0A9Bq>~TVErigKH1~P zRX-!h-f0NJ4Mh++{D}J+K>~~rq}d%o%+4dogzXp7RxX4C>Km5XEI|PAFDmo;DFm6G zzjVoB`@qW98Yl0Kvc-9w09^PrsobmG*Eju^=3f?0o-t$U)TL1B3;sZ^!++3&bGZ!o-*6w?;oOhf z=A+Qb$scV5!RbG+&2S}BQ6YH!FKb0``VVX~T$dzzeSZ$&9=X$3)_7Z{SspSYJ!lGE z7yig_41zpQ)%5dr4ff0rh$@ky3-JLRk&DK)NEIHecf9c*?Z1bUB4%pZjQ7hD!A0r-@NF(^WKdr(LXj|=UE7?gBYGgGQV zidf2`ZT@pzXf7}!NH4q(0IMcxsUGDih(0{kRSez&z?CFA0RVXsVFw3^u=^KMtt95q z43q$b*6#uQDLoiCAF_{RFc{!H^moH_cmll#Fc^KXi{9GDl{>%+3qyfOE5;Zq|6#Hb zp^#1G+z^AXfRKaa9HK;%b3Ux~U@q?xg<2DXP%6k!3E)PA<#4$ui8eDy5|9hA5&{?v z(-;*1%(1~-NTQ`Is1_MGdQ{+i*ccd96ab$R$T3=% zw_KuNF@vI!A>>Y_2pl9L{9h1-C6H8<)J4gKI6{WzGBi<@u3P6hNsXG=bRq5c+z;Gc3VUCe;LIIFDmQAGy+=mRyF++u=drBWV8-^>0yE9N&*05XHZpPlE zxu@?8(ZNy7rm?|<+UNe0Vs6&o?l`Pt>P&WaL~M&#Eh%`rg@Mbb)J&@DA-wheQ>hRV z<(XhigZAT z>=M;URcdCaiO3d^?H<^EiEMDV+7HsTiOhoaMX%P65E<(5xMPJKxf!0u>U~uVqnPN7T!X!o@_gs3Ct1 zlZ_$5QXP4{Aj645wG_SNT&6m|O6~Tsl$q?nK*)(`{J4b=(yb^nOATtF1_aS978$x3 zx>Q@s4i3~IT*+l{@dx~Hst21fR*+5}S1@cf>&8*uLw-0^zK(+OpW?cS-YG1QBZ5q! zgTAgivzoF#`cSz&HL>Ti!!v#?36I1*l^mkrx7Y|K6L#n!-~5=d3;K<;Zqi|gpNUn_ z_^GaQDEQ*jfzh;`j&KXb66fWEk1K7vxQIMQ_#Wu_%3 z4Oeb7FJ`8I>Px;^S?)}2+4D_83gHEq>8qSQY0PVP?o)zAv3K~;R$fnwTmI-=ZLK`= zTm+0h*e+Yfr(IlH3i7gUclNH^!MU>id$Jw>O?2i0Cila#v|twub21@e{S2v}8Z13( zNDrTXZVgris|qYm<0NU(tAPouG!QF4ZNpZPkX~{tVf8xY690JqY1NVdiTtW+NqyRP zZ&;T0ikb8V{wxmFhlLTQ&?OP7 z;(z*<+?J2~z*6asSe7h`$8~Se(@t(#%?BGLVs$p``;CyvcT?7Y!{tIPva$LxCQ&4W z6v#F*);|RXvI%qnoOY&i4S*EL&h%hP3O zLsrFZhv&Hu5tF$Lx!8(hs&?!Kx5&L(fdu}UI5d*wn~A`nPUhG&Rv z2#ixiJdhSF-K2tpVL=)5UkXRuPAFrEW}7mW=uAmtVQ&pGE-&az6@#-(Te^n*lrH^m@X-ftVcwO_#7{WI)5v(?>uC9GG{lcGXYJ~Q8q zbMFl7;t+kV;|;KkBW2!P_o%Czhw&Q(nXlxK9ak&6r5t_KH8#1Mr-*0}2h8R9XNkr zto5-b7P_auqTJb(TJlmJ9xreA=6d=d)CVbYP-r4$hDn5|TIhB>SReMfh&OVLkMk-T zYf%$taLF0OqYF?V{+6Xkn>iX@TuqQ?&cN6UjC9YF&%q{Ut3zv{U2)~$>-3;Dp)*(? zg*$mu8^i=-e#acaj*T$pNowo{xiGEk$%DusaQiS!KjJH96XZ-hXv+jk%ard#fu=@Q z$AM)YWvE^{%tDfK%nD49=PI|wYu}lYVbB#a7wtN^Nml@CE@{Gv7+jo{_V?I*jkdLD zJE|jfdrmVbkfS>rN*+`#l%ZUi5_bMS<>=MBDNlpiSb_tAF|Zy`K7kcp@|d?yaTmB^ zo?(vg;B$vxS|SszusORgDg-*Uitzdi{dUV+glA~R8V(?`3GZIl^egW{a919!j#>f` znL1o_^-b`}xnU0+~KIFLQ)$Q6#ym%)(GYC`^XM*{g zv3AM5$+TtDRs%`2TyR^$(hqE7Y1b&`Jd6dS6B#hDVbJlUXcG3y*439D8MrK!2D~6gn>UD4Imctb z+IvAt0iaW73Iq$K?4}H`7wq6YkTMm`tcktXgK0lKPmh=>h+l}Y+pDtvHnG>uqBA)l zAH6BV4F}v$(o$8Gfo*PB>IuaY1*^*`OTx4|hM8jZ?B6HY;F6p4{`OcZZ(us-RVwDx zUzJrCQlp@mz1ZFiSZ*$yX3c_#h9J;yBE$2g%xjmGF4ca z&yL`nGVs!Zxsh^j6i%$a*I3ZD2SoNT`{D%mU=LKaEwbN(_J5%i-6Va?@*>=3(dQy` zOv%$_9lcy9+(t>qohkuU4r_P=R^6ME+wFu&LA9tw9RA?azGhjrVJKy&8=*qZT5Dr8g--d+S8zAyJ$1HlW3Olryt`yE zFIph~Z6oF&o64rw{>lgZISC6p^CBer9C5G6yq%?8tC+)7*d+ib^?fU!JRFxynRLEZ zj;?PwtS}Ao#9whV@KEmwQgM0TVP{hs>dg(1*DiMUOKHdQGIqa0`yZnHk9mtbPfoLx zo;^V6pKUJ!5#n`w2D&381#5#_t}AlTGEgDz$^;u;-vxDN?^#5!zN9ngytY@oTv!nc zp1Xn8uR$1Z;7vY`-<*?DfPHB;x|GUi_fI9@I9SVRv1)qETbNU_8{5U|(>Du84qP#7 z*l9Y$SgA&wGbj>R1YeT9vYjZuC@|{rajTL0f%N@>3$DFU=`lSPl=Iv;EjuGjBa$Gw zHD-;%YOE@<-!7-Mn`0WuO3oWuL6tB2cpPw~Nvuj|KM@))ixuDK`9;jGMe2d)7gHin zS<>k@!x;!TJEc#HdL#RF(`|4W+H88d4V%zlh(7#{q2d0OQX9*FW^`^_<3r$kabWAB z$9BONo5}*(%kx zOXi-yM_cmB3>inPpI~)duvZykJ@^^aWzQ=eQ&STUa}2uT@lV&WoRzkUoE`rR0)`=l zFT%f|LA9fCw>`enm$p7W^E@U7RNBtsh{_-7vVz3DtB*y#*~(L9+x9*wn8VjWw|Q~q zKFsj1Yl>;}%MG3=PY`$g$_mnyhuV&~O~u~)968$0b2!Jkd;2MtAP#ZDYw9hmK_+M$ zb3pxyYC&|CuAbtiG8HZjj?MZJBFbt`ryf+c1dXFuC z0*ZQhBzNBd*}s6K_G}(|Z_9NDV162#y%WSNe|FTDDhx)K!c(mMJh@h87@8(^YdK$&d*^WQe8Z53 z(|@MRJ$Lk-&ii74MPIs80WsOFZ(NX23oR-?As+*aq6b?~62@fSVmM-_*cb1RzZ)`5$agEiL`-E9s7{GM2?(KNPgK1(+c*|-FKoy}X(D_b#etO|YR z(BGZ)0Ntfv-7R4GHoXp?l5g#*={S1{u-QzxCGng*oWr~@X-5f~RA14b8~B+pLKvr4 zfgL|7I>jlak9>D4=(i(cqYf7#318!OSR=^`xxvI!bBlS??`xxWeg?+|>MxaIdH1U~#1tHu zB{QMR?EGRmQ_l4p6YXJ{o(hh-7Tdm>TAX380TZZZyVkqHNzjUn*_|cb?T? zt;d2s-?B#Mc>T-gvBmQZx(y_cfkXZO~{N zT6rP7SD6g~n9QJ)8F*8uHxTLCAZ{l1Y&?6v)BOJZ)=R-pY=Y=&1}jE7fQ>USS}xP#exo57uND0i*rEk@$;nLvRB@u~s^dwRf?G?_enN@$t* zbL%JO=rV(3Ju8#GqUpeE3l_Wu1lN9Y{D4uaUe`g>zlj$1ER$6S6@{m1!~V|bYkhZA z%CvrDRTkHuajMU8;&RZ&itnC~iYLW4DVkP<$}>#&(`UO>!n)Po;Mt(SY8Yb`AS9lt znbX^i?Oe9r_o=?})IHKHoQGKXsps_SE{hwrg?6dMI|^+$CeC&z@*LuF+P`7LfZ*yr+KN8B4{Nzv<`A(wyR@!|gw{zB6Ha ziwPAYh)oJ(nlqSknu(8g9N&1hu0$vFK$W#mp%>X~AU1ay+EKWcFdif{% z#4!4aoVVJ;ULmkQf!ke2}3hqxLK>eq|-d7Ly7-J9zMpT`?dxo6HdfJA|t)?qPEVBDv z{y_b?4^|YA4%WW0VZd8C(ZgQzRI5(I^)=Ub`Y#MHc@nv0w-DaJAqsbEHDWG8Ia6ju zo-iyr*sq((gEwCC&^TYBWt4_@|81?=B-?#P6NMff(*^re zYqvDuO`K@`mjm_Jd;mW_tP`3$cS?R$jR1ZN09$YO%_iBqh5ftzSpMQQtxKFU=FYmP zeY^jph+g<4>YO;U^O>-NFLn~-RqlHvnZl2yd2A{Yc1G@Ga$d+Q&(f^tnPf+Z7serIU};17+2DU_f4Z z@GaPFut27d?!YiD+QP@)T=77cR9~MK@bd~pY%X(h%L={{OIb8IQmf-!xmZkm8A0Ga zQSWONI17_ru5wpHg3jI@i9D+_Y|pCqVuHJNdHUauTD=R$JcD2K_liQisqG$(sm=k9;L* z!L?*4B~ql7uioSX$zWJ?;q-SWXRFhz2Jt4%fOHA=Bwf|RzhwqdXGr78y$J)LR7&3T zE1WWz*>GPWKZ0%|@%6=fyx)5rzUpI;bCj>3RKzNG_1w$fIFCZ&UR0(7S?g}`&Pg$M zf`SLsz8wK82Vyj7;RyKmY{a8G{2BHG%w!^T|Njr!h9TO2LaP^_f22Q1=l$QiU84ao zHe_#{S6;qrC6w~7{y(hs-?-j?lbOfgH^E=XcSgnwW*eEz{_Z<_xN#0001NP)t-s|Ns9~ z#rXRE|M&d=0au&!`~QyF`q}dRnBDt}*!qXo`c{v z{Djr|@Adh0(D_%#_&mM$D6{kE_x{oE{l@J5@%H*?%=t~i_`ufYOPkAEn!pfkr2$fs z652Tz0001XNklqeeKN4RM4i{jKqmiC$?+xN>3Apn^ z0QfuZLym_5b<*QdmkHjHlj811{If)dl(Z2K0A+ekGtrFJb?g|wt#k#pV-#A~bK=OT ts8>{%cPtyC${m|1#B1A6#u!Q;umknL1chzTM$P~L002ovPDHLkV1lTfnu!1a 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 797d452e458972bab9d994556c8305db4c827017..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 406 zcmV;H0crk;P))>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c 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 6ed2d933e1120817fe9182483a228007b18ab6ae..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 450 zcmV;z0X_bSP)iGWQ_5NJQ_~rNh*z)}eT%KUb z`7gNk0#AwF^#0T0?hIa^`~Ck;!}#m+_uT050aTR(J!bU#|IzRL%^UsMS#KsYnTF*!YeDOytlP4VhV?b} z%rz_<=#CPc)tU1MZTq~*2=8~iZ!lSa<{9b@2Jl;?IEV8)=fG217*|@)CCYgFze-x? zIFODUIA>nWKpE+bn~n7;-89sa>#DR>TSlqWk*!2hSN6D~Qb#VqbP~4Fk&m`@1$JGr zXPIdeRE&b2Thd#{MtDK$px*d3-Wx``>!oimf%|A-&-q*6KAH)e$3|6JV%HX{Hig)k suLT-RhftRq8b9;(V=235Wa|I=027H2wCDra;{X5v07*qoM6N<$f;9x^2LJ#7 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 4cd7b0099ca80c806f8fe495613e8d6c69460d76..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 282 zcmV+#0p(^bcu7P-R4C8Q z&e;xxFbF_Vrezo%_kH*OKhshZ6BFpG-Y1e10`QXJKbND7AMQ&cMj60B5TNObaZxYybcN07*qoM6N<$g3m;S%K!iX 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 fe730945a01f64a61e2235dbe3f45b08f7729182..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 462 zcmV;<0WtoGP)-}iV`2<;=$?g5M=KQbZ{F&YRNy7Nn@%_*5{gvDM0aKI4?ESmw z{NnZg)A0R`+4?NF_RZexyVB&^^ZvN!{I28tr{Vje;QNTz`dG&Jz0~Ek&f2;*Z7>B|cg}xYpxEFY+0YrKLF;^Q+-HreN0P{&i zK~zY`?b7ECf-n?@;d<&orQ*Q7KoR%4|C>{W^h6@&01>0SKS`dn{Q}GT%Qj_{PLZ_& zs`MFI#j-(>?bvdZ!8^xTwlY{qA)T4QLbY@j(!YJ7aXJervHy6HaG_2SB`6CC{He}f zHVw(fJWApwPq!6VY7r1w-Fs)@ox~N+q|w~e;JI~C4Vf^@d>Wvj=fl`^u9x9wd9 zR%3*Q+)t%S!MU_`id^@&Y{y7-r98lZX0?YrHlfmwb?#}^1b{8g&KzmkE(L>Z&)179 zp<)v6Y}pRl100G2FL_t(o!|l{-Q-VMg#&MKg7c{O0 z2wJImOS3Gy*Z2Qifdv~JYOp;v+U)a|nLoc7hNH;I$;lzDt$}rkaFw1mYK5_0Q(Sut zvbEloxON7$+HSOgC9Z8ltuC&0OSF!-mXv5caV>#bc3@hBPX@I$58-z}(ZZE!t-aOG zpjNkbau@>yEzH(5Yj4kZiMH32XI!4~gVXNnjAvRx;Sdg^`>2DpUEwoMhTs_st8pKG z(%SHyHdU&v%f36~uERh!bd`!T2dw;z6PrOTQ7Vt*#9F2uHlUVnb#ev_o^fh}Dzmq} zWtlk35}k=?xj28uO|5>>$yXadTUE@@IPpgH`gJ~Ro4>jd1IF|(+IX>8M4Ps{PNvmI zNj4D+XgN83gPt_Gm}`Ybv{;+&yu-C(Grdiahmo~BjG-l&mWM+{e5M1sm&=xduwgM9 z`8OEh`=F3r`^E{n_;%9weN{cf2%7=VzC@cYj+lg>+3|D|_1C@{hcU(DyQG_BvBWe? zvTv``=%b1zrol#=R`JB)>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c 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 502f463a9bc882b461c96aadf492d1729e49e725..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 586 zcmV-Q0=4~#P)+}#`wDE{8-2Mebf5<{{PqV{TgVcv*r8?UZ3{-|G?_}T*&y;@cqf{ z{Q*~+qr%%p!1pS*_Uicl#q9lc(D`!D`LN62sNwq{oYw(Wmhk)k<@f$!$@ng~_5)Ru z0Z)trIA5^j{DIW^c+vT2%lW+2<(RtE2wR;4O@)Tm`Xr*?A(qYoM}7i5Yxw>D(&6ou zxz!_Xr~yNF+waPe00049Nkl*;a!v6h%{rlvIH#gW3s8p;bFr=l}mRqpW2h zw=OA%hdyL~z+UHOzl0eKhEr$YYOL-c-%Y<)=j?(bzDweB7{b+%_ypvm_cG{SvM=DK zhv{K@m>#Bw>2W$eUI#iU)Wdgs8Y3U+A$Gd&{+j)d)BmGKx+43U_!tik_YlN)>$7G! zhkE!s;%oku3;IwG3U^2kw?z+HM)jB{@zFhK8P#KMSytSthr+4!c(5c%+^UBn`0X*2 zy3(k600_CSZj?O$Qu%&$;|TGUJrptR(HzyIx>5E(2r{eA(<6t3e3I0B)7d6s7?Z5J zZ!rtKvA{MiEBm&KFtoifx>5P^Z=vl)95XJn()aS5%ad(s?4-=Tkis9IGu{`Fy8r+H07*qoM6N<$f20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU 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 0ec303439225b78712f49115768196d8d76f6790..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 862 zcmV-k1EKthP)20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU 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 e9f5fea27c705180eb716271f41b582e76dcbd90..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1674 zcmV;526g#~P){YQnis^a@{&-nmRmq)<&%Mztj67_#M}W?l>kYSliK<%xAp;0j{!}J0!o7b zE>q9${Lb$D&h7k=+4=!ek^n+`0zq>LL1O?lVyea53S5x`Nqqo2YyeuIrQrJj9XjOp z{;T5qbj3}&1vg1VK~#9!?b~^C5-}JC@Pyrv-6dSEqJqT}#j9#dJ@GzT@B8}x zU&J@bBI>f6w6en+CeI)3^kC*U?}X%OD8$Fd$H&LV$H&LV$H&LV#|K5~mLYf|VqzOc zkc7qL~0sOYuM{tG`rYEDV{DWY`Z8&)kW*hc2VkBuY+^Yx&92j&StN}Wp=LD zxoGxXw6f&8sB^u})h@b@z0RBeD`K7RMR9deyL(ZJu#39Z>rT)^>v}Khq8U-IbIvT> z?4pV9qGj=2)TNH3d)=De<+^w;>S7m_eFKTvzeaBeir45xY!^m!FmxnljbSS_3o=g( z->^wC9%qkR{kbGnW8MfFew_o9h3(r55Is`L$8KI@d+*%{=Nx+FXJ98L0PjFIu;rGnnfY zn1R5Qnp<{Jq0M1vX=X&F8gtLmcWv$1*M@4ZfF^9``()#hGTeKeP`1!iED ztNE(TN}M5}3Bbc*d=FIv`DNv&@|C6yYj{sSqUj5oo$#*0$7pu|Dd2TLI>t5%I zIa4Dvr(iayb+5x=j*Vum9&irk)xV1`t509lnPO0%skL8_1c#Xbamh(2@f?4yUI zhhuT5<#8RJhGz4%b$`PJwKPAudsm|at?u;*hGgnA zU1;9gnxVBC)wA(BsB`AW54N{|qmikJR*%x0c`{LGsSfa|NK61pYH(r-UQ4_JXd!Rsz)=k zL{GMc5{h138)fF5CzHEDM>+FqY)$pdN3}Ml+riTgJOLN0F*Vh?{9ESR{SVVg>*>=# zix;VJHPtvFFCRY$Ks*F;VX~%*r9F)W`PmPE9F!(&s#x07n2<}?S{(ygpXgX-&B&OM zONY&BRQ(#%0%jeQs?oJ4P!p*R98>qCy5p8w>_gpuh39NcOlp)(wOoz0sY-Qz55eB~ z7OC-fKBaD1sE3$l-6QgBJO!n?QOTza`!S_YK z_v-lm^7{VO^8Q@M_^8F)09Ki6%=s?2_5eupee(w1FB%aqSweusQ-T+CH0Xt{` zFjMvW{@C&TB)k25()nh~_yJ9coBRL(0oO@HK~z}7?bm5j;y@69;bvlHb2tf!$ReA~x{22wTq550 z?f?Hnw(;m3ip30;QzdV~7pi!wyMYhDtXW#cO7T>|f=bdFhu+F!zMZ2UFj;GUKX7tI z;hv3{q~!*pMj75WP_c}>6)IWvg5_yyg<9Op()eD1hWC19M@?_9_MHec{Z8n3FaF{8 z;u`Mw0ly(uE>*CgQYv{be6ab2LWhlaH1^iLIM{olnag$78^Fd}%dR7;JECQ+hmk|o z!u2&!3MqPfP5ChDSkFSH8F2WVOEf0(E_M(JL17G}Y+fg0_IuW%WQ zG(mG&u?|->YSdk0;8rc{yw2@2Z&GA}z{Wb91Ooz9VhA{b2DYE7RmG zjL}?eq#iX%3#k;JWMx_{^2nNax`xPhByFiDX+a7uTGU|otOvIAUy|dEKkXOm-`aWS z27pUzD{a)Ct<6p{{3)+lq@i`t@%>-wT4r?*S}k)58e09WZYP0{{R3FC5Sl00039P)t-s|Ns9~ z#rP?<_5oL$Q^olD{r_0T`27C={r>*`|Nj71npVa5OTzc(_WfbW_({R{p56NV{r*M2 z_xt?)2V0#0NsfV0u>{42ctGP(8vQj-Btk1n|O0ZD=YLwd&R{Ko41Gr9H= zY@z@@bOAMB5Ltl$E>bJJ{>JP30ZxkmI%?eW{k`b?Wy<&gOo;dS`~CR$Vwb@XWtR|N zi~t=w02?-0&j0TD{>bb6sNwsK*!p?V`RMQUl(*DVjk-9Cx+-z1KXab|Ka2oXhX5f% z`$|e!000AhNklrxs)5QTeTVRiEmz~MKK1WAjCw(c-JK6eox;2O)?`? zTG`AHia671e^vgmp!llKp|=5sVHk#C7=~epA~VAf-~%aPC=%Qw01h8mnSZ|p?hz91 z7p83F3%LVu9;S$tSI$C^%^yud1dfTM_6p2|+5Ejp$bd`GDvbR|xit>i!ZD&F>@CJrPmu*UjD&?DfZs=$@e3FQA(vNiU+$A*%a} z?`XcG2jDxJ_ZQ#Md`H{4Lpf6QBDp81_KWZ6Tk#yCy1)32zO#3<7>b`eT7UyYH1eGz z;O(rH$=QR*L%%ZcBpc=eGua?N55nD^K(8<#gl2+pN_j~b2MHs4#mcLmv%DkspS-3< zpI1F=^9siI0s-;IN_IrA;5xm~3?3!StX}pUv0vkxMaqm+zxrg7X7(I&*N~&dEd0kD z-FRV|g=|QuUsuh>-xCI}vD2imzYIOIdcCVV=$Bz@*u0+Bs<|L^)32nN*=wu3n%Ynw z@1|eLG>!8ruU1pFXUfb`j>(=Gy~?Rn4QJ-c3%3T|(Frd!bI`9u&zAnyFYTqlG#&J7 zAkD(jpw|oZLNiA>;>hgp1KX7-wxC~31II47gc zHcehD6Uxlf%+M^^uN5Wc*G%^;>D5qT{>=uxUhX%WJu^Z*(_Wq9y}npFO{Hhb>s6<9 zNi0pHXWFaVZnb)1+RS&F)xOv6&aeILcI)`k#0YE+?e)5&#r7J#c`3Z7x!LpTc01dx zrdC3{Z;joZ^KN&))zB_i)I9fWedoN>Zl-6_Iz+^G&*ak2jpF07*qoM6N<$f;w%0(f|Me 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 0467bf12aa4d28f374bb26596605a46dcbb3e7c8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1418 zcmV;51$Fv~P)q zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+ zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq z^={4hPQv)y=I|4n+?>7Fim=dxt1 z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf` zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_> z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3 zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62( zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;? zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-< z{s<&cCV_1`^TD^ia9!*mQDq& zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H 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 9da19eacad3b03bb08bbddbbf4ac48dd78b3d838..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v 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 9da19eacad3b03bb08bbddbbf4ac48dd78b3d838..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v 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 9da19eacad3b03bb08bbddbbf4ac48dd78b3d838..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v 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/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 From 03a8d120795e145c93d6d7a39b6aae43634053ae Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 16 Aug 2026 22:40:58 -0400 Subject: [PATCH 13/23] =?UTF-8?q?docs:=20stop=20pointing=20at=20the=20dele?= =?UTF-8?q?ted=20Flutter=20tree=20=E2=80=94=20#2710?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every comment naming a path in flutter_client/ now resolves to nothing, which is the failure mode this project has already been bitten by twice -- drift #572 came from delete.go describing behaviour it no longer had, and that same docstring was still wrong when it was fixed last week. A pointer to a deleted directory is the same thing in slower motion: the reader follows it, finds nothing, and cannot tell whether the comment is stale or they are looking in the wrong place. Three treatments, per what each comment was actually doing: - Naming a concept ("mirrors db.dart's CachedTracks Drift table"): keep the concept, drop the path. The Drift table is why the entity looks as it does; the file it lived in is not. - Pure port bookkeeping ("Mirrors ." and nothing else): deleted. Git history records the port; the comment only restated it. - Substance introduced by a pointer (a lifecycle list, a 200 px/s threshold, an inverted control-row placement): keep the substance, drop the lead-in. The two Go comments were the valuable ones and got more than a trim. They stated a live contract -- "field names match the client's FromJson helpers exactly, or fields are silently dropped" -- against a client that no longer exists. They now name the real consumer, SyncResponseWire.kt, and say why the failure is silent there too: kotlinx.serialization skips unknown keys, so a renamed field arrives as a default value rather than an error. The ticket counted 64 files by grepping flutter_client/. A second tier turned up during the sweep: 15 more references naming bare Dart files (player_bar.dart, now_playing_screen.dart:464, auth_provider.dart) with no directory prefix. Same dead tree, same treatment, folded in here. Comments only -- verified no non-comment line is touched in the diff. --- .../minstrel/admin/data/AdminRequestsRepository.kt | 2 -- .../main/java/com/fabledsword/minstrel/api/ErrorCopy.kt | 3 +-- .../fabledsword/minstrel/api/endpoints/AdminInvitesApi.kt | 3 +-- .../minstrel/api/endpoints/AdminQuarantineApi.kt | 3 +-- .../minstrel/api/endpoints/AdminRequestsApi.kt | 3 +-- .../fabledsword/minstrel/api/endpoints/AdminUsersApi.kt | 3 +-- .../com/fabledsword/minstrel/api/endpoints/AuthApi.kt | 3 +-- .../com/fabledsword/minstrel/api/endpoints/DiscoverApi.kt | 1 - .../com/fabledsword/minstrel/api/endpoints/EventsApi.kt | 3 +-- .../com/fabledsword/minstrel/api/endpoints/HistoryApi.kt | 7 +++---- .../com/fabledsword/minstrel/api/endpoints/HomeApi.kt | 7 +++---- .../com/fabledsword/minstrel/api/endpoints/LibraryApi.kt | 1 - .../com/fabledsword/minstrel/api/endpoints/LikesApi.kt | 3 +-- .../java/com/fabledsword/minstrel/api/endpoints/MeApi.kt | 1 - .../fabledsword/minstrel/api/endpoints/PlaylistsApi.kt | 5 ++--- .../fabledsword/minstrel/api/endpoints/QuarantineApi.kt | 5 ++--- .../com/fabledsword/minstrel/api/endpoints/RadioApi.kt | 4 +--- .../com/fabledsword/minstrel/api/endpoints/RequestsApi.kt | 3 +-- .../com/fabledsword/minstrel/api/endpoints/SearchApi.kt | 3 +-- .../java/com/fabledsword/minstrel/auth/AuthController.kt | 3 +-- .../java/com/fabledsword/minstrel/cache/ShuffleSource.kt | 3 +-- .../fabledsword/minstrel/cache/audiocache/CacheConfig.kt | 2 +- .../minstrel/cache/audiocache/CacheSettings.kt | 3 +-- .../minstrel/cache/db/dao/CachedPlaylistDao.kt | 1 - .../minstrel/cache/db/entities/AudioCacheIndexEntity.kt | 2 +- .../minstrel/cache/db/entities/CachedAlbumEntity.kt | 2 +- .../minstrel/cache/db/entities/CachedArtistEntity.kt | 2 +- .../minstrel/cache/db/entities/CachedHomeIndexEntity.kt | 2 +- .../minstrel/cache/db/entities/CachedLikeEntity.kt | 2 +- .../minstrel/cache/db/entities/CachedMutationEntity.kt | 2 +- .../minstrel/cache/db/entities/CachedPlaylistEntity.kt | 2 +- .../cache/db/entities/CachedPlaylistTrackEntity.kt | 2 +- .../minstrel/cache/db/entities/CachedQuarantineEntity.kt | 2 +- .../minstrel/cache/db/entities/CachedResumeStateEntity.kt | 2 +- .../minstrel/cache/db/entities/CachedTrackEntity.kt | 2 +- .../java/com/fabledsword/minstrel/events/EventsStream.kt | 3 +-- .../java/com/fabledsword/minstrel/events/LiveEvent.kt | 2 +- .../fabledsword/minstrel/events/LiveEventsDispatcher.kt | 3 +-- .../com/fabledsword/minstrel/history/ui/HistoryTab.kt | 3 +-- .../java/com/fabledsword/minstrel/home/ui/HomeScreen.kt | 4 ++-- .../com/fabledsword/minstrel/library/ui/LibraryScreen.kt | 2 +- .../main/java/com/fabledsword/minstrel/models/AlbumRef.kt | 2 +- .../java/com/fabledsword/minstrel/models/ArtistRef.kt | 2 +- .../main/java/com/fabledsword/minstrel/models/Discover.kt | 2 +- .../main/java/com/fabledsword/minstrel/models/Invite.kt | 2 +- .../com/fabledsword/minstrel/models/ListenBrainzStatus.kt | 2 +- .../main/java/com/fabledsword/minstrel/models/Playlist.kt | 2 +- .../main/java/com/fabledsword/minstrel/models/Request.kt | 2 +- .../fabledsword/minstrel/models/SystemPlaylistsStatus.kt | 3 +-- .../main/java/com/fabledsword/minstrel/models/TrackRef.kt | 2 +- .../java/com/fabledsword/minstrel/models/UpdateInfo.kt | 2 +- .../com/fabledsword/minstrel/models/wire/AlbumWire.kt | 2 +- .../com/fabledsword/minstrel/models/wire/ArtistWire.kt | 2 +- .../com/fabledsword/minstrel/models/wire/DiscoverWire.kt | 2 +- .../com/fabledsword/minstrel/models/wire/EventsWire.kt | 3 +-- .../com/fabledsword/minstrel/models/wire/HomeIndexWire.kt | 5 ++--- .../com/fabledsword/minstrel/models/wire/MyProfileWire.kt | 3 +-- .../minstrel/models/wire/QuarantineMineWire.kt | 2 +- .../com/fabledsword/minstrel/models/wire/RequestWire.kt | 2 +- .../com/fabledsword/minstrel/models/wire/TrackWire.kt | 2 +- .../com/fabledsword/minstrel/player/AudioPrefetcher.kt | 2 +- .../fabledsword/minstrel/player/PlaybackErrorReporter.kt | 2 +- .../java/com/fabledsword/minstrel/player/PlayerUiState.kt | 3 +-- .../com/fabledsword/minstrel/player/ResumeController.kt | 3 +-- .../com/fabledsword/minstrel/player/ui/DominantColor.kt | 4 ++-- .../java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt | 7 ++++--- .../fabledsword/minstrel/player/ui/NowPlayingScreen.kt | 4 +--- .../minstrel/playlists/data/PlaylistsRepository.kt | 5 ++--- .../minstrel/playlists/widgets/PlaylistCard.kt | 1 - .../minstrel/playlists/widgets/PlaylistPlaceholderCard.kt | 1 - .../fabledsword/minstrel/requests/ui/RequestsScreen.kt | 2 +- .../minstrel/shared/widgets/HorizontalScrollRow.kt | 3 --- .../minstrel/shared/widgets/PlayCircleButton.kt | 5 +---- .../fabledsword/minstrel/shared/widgets/ShellScaffold.kt | 2 -- .../java/com/fabledsword/minstrel/theme/MinstrelTheme.kt | 2 +- internal/api/library_sync_views.go | 8 +++++--- internal/api/library_sync_views_test.go | 8 +++++--- 77 files changed, 89 insertions(+), 131 deletions(-) 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/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 51156f25..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 @@ -13,7 +13,6 @@ 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 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..d28cc2cb 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 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/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..3cf501b9 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,7 +6,7 @@ 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. */ @Entity(tableName = "cached_tracks") 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 83d66e9f..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 @@ -1172,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. @@ -1374,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/ui/LibraryScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt index 47e4ab5b..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 @@ -55,7 +55,7 @@ import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile * Library tab. Seven-tab TabBar (Artists / Albums / Genres / Years / * History / Liked / Hidden), matching the web client's library tab bar. * Genres and Years arrived with #2467; the rest predate it and mirrored - * `flutter_client/lib/library/library_screen.dart`. + * the Flutter client. * * Artists + Albums are wired against the existing LibraryViewModel * (cache-first reads of cached_artists / cached_albums). The other 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 7e918400..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. 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..e32dafbd 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 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/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/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/TrackWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/TrackWire.kt index dba0ab70..3b9630af 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.). * 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/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/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/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/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 b62ada1a..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)) } 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 3a55e608..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 @@ -231,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/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 5e8b6881..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 @@ -54,8 +54,6 @@ val ShellContentWindowInsets: WindowInsets = WindowInsets(0, 0, 0, 0) * 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) 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/internal/api/library_sync_views.go b/internal/api/library_sync_views.go index d33dbb9e..0454149c 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" diff --git a/internal/api/library_sync_views_test.go b/internal/api/library_sync_views_test.go index 3691ed69..146ad665 100644 --- a/internal/api/library_sync_views_test.go +++ b/internal/api/library_sync_views_test.go @@ -11,9 +11,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. From bab9b1683105275401065822a910ade27c88218c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 16 Aug 2026 23:53:21 -0400 Subject: [PATCH 14/23] =?UTF-8?q?feat(library):=20a=20missing=20file=20ask?= =?UTF-8?q?s=20Lidarr=20for=20itself,=20on=20a=20backoff=20=E2=80=94=20#25?= =?UTF-8?q?27?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers the open fork on #2527's last slice: automatic, not a button. Until now missing_since was a dead end -- reconcile marks it, every selection path skips it, the admin surface lists it, and there it sits. Two decisions carry most of the safety, both at the design level rather than as rate limits bolted on afterwards. The unit 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 means the loss that produced #2523 -- three reorganised albums, ~40 missing files -- becomes three requests instead of forty. The flood problem mostly dissolves. And nothing is requested until a file has been missing longer than the grace window (24h default). A filesystem lies transiently: an unmounted volume, a container that started before its media mount attached, a NAS mid-reboot. Every one of those resolves itself well inside a day at no cost. missing_since is never re-stamped (#2523), so it is a true "gone since" clock to measure against, not "when we last noticed". This is the difference between automatic and trigger-happy. Then the backoff proper: 6h -> 12h -> 24h -> 48h per album, clamped to a week, three attempts before giving up, and a per-pass ceiling so a genuinely large loss trickles instead of dumping hundreds of rows into the queue. Giving up is stamped as a timestamp rather than inferred from attempts >= max, so the verdict survives an operator later raising the maximum and the surface can say when. A sweeper, not a hook inside reconcile. 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. Recovered albums have their state deleted rather than reset -- a future loss is a new problem, not a continuation. Requests are attributed to the oldest admin: lidarr_requests.user_id is NOT NULL and a re-acquisition has no requesting human, so this keeps the row auditable and in the same queue as everything else without inventing a synthetic principal the schema would have to understand. Auto-approve defaults ON. Requests are created pending and nothing reaches Lidarr until approval, so with it off this would be a notification rather than an attempt. Lidarr disabled leaves the request pending rather than counting a failure -- the record of intent is still right and becomes actionable the moment Lidarr is configured. Albums with no MBID are counted, not silently skipped: nothing can be asked of Lidarr for a release MusicBrainz cannot name, and quietly doing nothing would read as the feature being broken. Settings are DB-backed per rule #25 with CHECK-guarded ranges, validated in Go as well so the API answers 400 rather than surfacing a constraint violation. The admin card and the state on the missing-files page are next; this is the engine. --- cmd/minstrel/main.go | 23 ++ internal/db/dbq/models.go | 21 ++ internal/db/dbq/reacquisition.sql.go | 310 ++++++++++++++++++ internal/db/dbq/users.sql.go | 37 +++ .../0056_missing_reacquisition.down.sql | 2 + .../0056_missing_reacquisition.up.sql | 98 ++++++ internal/db/queries/reacquisition.sql | 119 +++++++ internal/db/queries/users.sql | 13 + internal/reacquisition/settings.go | 166 ++++++++++ internal/reacquisition/settings_test.go | 126 +++++++ internal/reacquisition/sweeper.go | 228 +++++++++++++ 11 files changed, 1143 insertions(+) create mode 100644 internal/db/dbq/reacquisition.sql.go create mode 100644 internal/db/migrations/0056_missing_reacquisition.down.sql create mode 100644 internal/db/migrations/0056_missing_reacquisition.up.sql create mode 100644 internal/db/queries/reacquisition.sql create mode 100644 internal/reacquisition/settings.go create mode 100644 internal/reacquisition/settings_test.go create mode 100644 internal/reacquisition/sweeper.go 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/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/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/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/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/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/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, + ) +} From 30a5ac56ce159b7334c6c87546e13e0ec7565eaf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 17 Aug 2026 00:02:41 -0400 Subject: [PATCH 15/23] =?UTF-8?q?feat(api):=20admin=20endpoints=20for=20th?= =?UTF-8?q?e=20re-acquisition=20policy=20=E2=80=94=20#2527?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET/PUT /api/admin/library/reacquisition, so every knob the sweeper reads is editable without a restart (rule #25). Routed under /library beside the missing-files list it governs rather than under /lidarr: Lidarr is the mechanism, but missing files are the problem the operator came to solve, and that is the surface they meet it on. The payload carries one thing the settings table doesn't: the count of albums with missing files that can never be auto-requested, because neither they nor their artist has an MBID. Nothing can be asked of Lidarr for a release MusicBrainz cannot name, and a feature that silently does nothing for part of its input reads as broken -- so the card states the number instead of leaving it to be inferred. Counted best-effort: the settings are the point of the endpoint, and failing the whole card because a count query hiccuped would be the wrong trade. Range errors come back as 400 naming the field. The Go-side validation mirrors migration 0056's CHECKs precisely so the operator reads "grace_hours must be 1-720" rather than a constraint-violation string surfacing as a 500. --- internal/api/admin_reacquisition.go | 88 +++++++++++++++++++++++++++++ internal/api/api.go | 15 ++++- internal/server/server.go | 11 +++- 3 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 internal/api/admin_reacquisition.go 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 5c04caa7..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,6 +197,13 @@ 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 @@ -279,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/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 From c2862e97bd5556c01f5b38ad13fad1b20affdd14 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 17 Aug 2026 00:07:10 -0400 Subject: [PATCH 16/23] =?UTF-8?q?test(api):=20cover=20the=20missing-file?= =?UTF-8?q?=20admin=20routes=20in=20the=20Mount=20test=20=E2=80=94=20#2527?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit go vet caught the Mount signature change: library_test.go calls it from inside the package, so the earlier grep for "api.Mount(" missed it. Rather than only appending the argument, the route table now includes both admin surfaces from this arc. That test exists to prove every route is actually registered — a 404 there means the route is missing — and the two paths added today had no such coverage. Both are in the admin group, so reaching the 401 is what proves they are wired. The new service is passed as nil, matching the other optional services in this call: the test asserts routing, never executes an admin handler, and constructing a settings service would need a pool round-trip for nothing. --- internal/api/library_test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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) From 952132714e36eb73a78f7452f8a0a8f751c2fa97 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 17 Aug 2026 00:13:20 -0400 Subject: [PATCH 17/23] =?UTF-8?q?feat(web):=20re-acquisition=20settings=20?= =?UTF-8?q?card=20on=20the=20missing-files=20page=20=E2=80=94=20#2527?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule #27: the sweeper has been running since bab9b168 with no way to see or change what it does. This is the half that makes it a feature. Placed above the list it governs rather than under Integrations. An operator looking at missing files is exactly the person deciding what should happen to them; Lidarr is the mechanism, not the subject, and separating the policy from the problem would mean finding one to understand the other. The card states the retry schedule the numbers add up to -- "6h -> 12h -> 24h" -- because the fields are meaningless individually. "First retry gap: 6" tells you nothing until you know it doubles and where it stops, and an operator should not have to simulate the algorithm to predict it. It recomputes as they type, including the clamp. It also states the unnameable-album count with its reason. Those albums will never produce a request no matter how long they sit in the list below, because Lidarr cannot be asked for a release MusicBrainz cannot name. Watching rows never move with no explanation is how a working feature gets reported as broken. Save errors surface the server's own message. The Go layer validates the same ranges the CHECKs enforce and names the field, so the operator reads "grace_hours must be 1-720" rather than a generic failure. The dirty check compares only the stored fields: unnameable_albums is server-computed, and including it would make the form look edited whenever the library changed underneath. Nine tests, including the schedule clamp, the disabled-until-dirty Save, the surfaced validation message, and a failed load offering a retry instead of an empty card. The existing missing-files page suite gains a stub for the card's own settings fetch -- it mocks the whole admin API module, so the card's imports would otherwise be undefined at mount. --- web/src/lib/api/admin.ts | 26 ++ .../ReacquisitionSettingsCard.svelte | 236 ++++++++++++++++++ .../ReacquisitionSettingsCard.test.ts | 120 +++++++++ .../routes/admin/missing-files/+page.svelte | 6 + .../admin/missing-files/missing-files.test.ts | 16 +- 5 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 web/src/lib/components/ReacquisitionSettingsCard.svelte create mode 100644 web/src/lib/components/ReacquisitionSettingsCard.test.ts diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index c4cdd519..2428d79f 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -696,3 +696,29 @@ export function createMissingFilesQuery(offset: number = 0, limit: number = 50) 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/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/routes/admin/missing-files/+page.svelte b/web/src/routes/admin/missing-files/+page.svelte index 9c552ef7..3e8a4172 100644 --- a/web/src/routes/admin/missing-files/+page.svelte +++ b/web/src/routes/admin/missing-files/+page.svelte @@ -4,6 +4,7 @@ import { createMissingFilesQuery } from '$lib/api/admin'; import { relativeTime } from '$lib/utils/relativeTime'; import { coverUrl } from '$lib/media/covers'; + import ReacquisitionSettingsCard from '$lib/components/ReacquisitionSettingsCard.svelte'; import type { AdminMissingGroup } from '$lib/api/types'; // Files the scan looked for and could not find. Read-only on purpose: @@ -57,6 +58,11 @@

+ + + {#if query.isPending}

Checking what's missing…

{:else if query.isError} diff --git a/web/src/routes/admin/missing-files/missing-files.test.ts b/web/src/routes/admin/missing-files/missing-files.test.ts index e1e5c515..2ba2970e 100644 --- a/web/src/routes/admin/missing-files/missing-files.test.ts +++ b/web/src/routes/admin/missing-files/missing-files.test.ts @@ -3,8 +3,22 @@ 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() + 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'; From 414dfb23b68cacdcd249d55076e472fdea01960e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 17 Aug 2026 00:19:48 -0400 Subject: [PATCH 18/23] =?UTF-8?q?feat:=20show=20what=20re-acquisition=20ha?= =?UTF-8?q?s=20done,=20per=20folder=20=E2=80=94=20#2527?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes milestone #290. The sweeper has been running and the settings have been editable, but the list itself said nothing about either, so the only way to tell "not tried yet" from "asked twice and nothing came back" was to go and read the Requests queue. Each folder now carries its album's attempt record: how many times, when last, when next -- or that it gave up, with the reassurance that a file coming back and going missing later starts the process over. Null when nothing has been attempted, which is the common case for a folder that just went missing and would be noise on every row. next_attempt_at is computed, not 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 -- and the card lets them do exactly that. Needed a forward-looking formatter. relativeTime deliberately collapses a future timestamp to "just now" (pinned by its own test) because that is the right answer for a clock-skewed past event; it is the wrong one for a scheduled future attempt, which would have rendered "next just now". timeUntil is its companion rather than a sign-aware rewrite: the two read differently in the same sentence -- "last tried 3d ago, next in 4h" -- and a test asserts they disagree about the future on purpose, so nobody later "fixes" the divergence. The state lookup is one batched query for the whole page and best-effort: this is context on a list whose real job is showing what is missing, so a failure leaves the groups bare rather than failing the page. The settings service is read with a nil guard falling back to the shipped defaults, since contexts that wire routing without services exist and a backoff projection is not worth a nil-pointer panic (rule #48). --- internal/api/admin_library_missing.go | 118 +++++++++++++++++- web/src/lib/api/types.ts | 13 ++ web/src/lib/utils/relativeTime.test.ts | 41 +++++- web/src/lib/utils/relativeTime.ts | 22 ++++ .../routes/admin/missing-files/+page.svelte | 32 ++++- .../admin/missing-files/missing-files.test.ts | 41 ++++++ 6 files changed, 264 insertions(+), 3 deletions(-) diff --git a/internal/api/admin_library_missing.go b/internal/api/admin_library_missing.go index 6725f8ef..abbafb0b 100644 --- a/internal/api/admin_library_missing.go +++ b/internal/api/admin_library_missing.go @@ -2,8 +2,12 @@ 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. @@ -26,6 +30,21 @@ type missingTrackView struct { 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 @@ -37,6 +56,10 @@ 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 @@ -79,11 +102,14 @@ func (h *handlers) handleListMissingTracks(w http.ResponseWriter, r *http.Reques return } + groups := groupMissingByDirectory(rows) + h.attachReacquisitionState(r, q, rows, groups) + out := adminMissingResponse{ Total: total, Limit: limit, Offset: offset, - Groups: groupMissingByDirectory(rows), + Groups: groups, } writeJSON(w, http.StatusOK, out) } @@ -132,3 +158,93 @@ func groupMissingByDirectory(rows []dbq.ListMissingTracksRow) []missingGroupView } 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/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index a954814c..51cab7fa 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -390,6 +390,18 @@ export type AdminMissingTrack = { 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. @@ -397,6 +409,7 @@ export type AdminMissingGroup = { directory: string; missing_since: string; tracks: AdminMissingTrack[]; + reacquisition: AdminReacquisitionState | null; }; export type AdminMissingResponse = { diff --git a/web/src/lib/utils/relativeTime.test.ts b/web/src/lib/utils/relativeTime.test.ts index 05fc20da..72d2184e 100644 --- a/web/src/lib/utils/relativeTime.test.ts +++ b/web/src/lib/utils/relativeTime.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; -import { relativeTime } from './relativeTime'; +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. @@ -43,3 +43,42 @@ describe('relativeTime', () => { 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); + }); + + // 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 index 7ab3398a..a70a8276 100644 --- a/web/src/lib/utils/relativeTime.ts +++ b/web/src/lib/utils/relativeTime.ts @@ -25,3 +25,25 @@ export function relativeTime(iso: string): string { 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 <= 0) return 'any moment'; + const days = Math.floor(ms / (24 * 3_600_000)); + if (days >= 1) return `in ${days}d`; + const hours = Math.floor(ms / 3_600_000); + if (hours >= 1) return `in ${hours}h`; + const minutes = Math.floor(ms / 60_000); + if (minutes >= 1) return `in ${minutes}m`; + return 'any moment'; +} diff --git a/web/src/routes/admin/missing-files/+page.svelte b/web/src/routes/admin/missing-files/+page.svelte index 3e8a4172..c2c91e68 100644 --- a/web/src/routes/admin/missing-files/+page.svelte +++ b/web/src/routes/admin/missing-files/+page.svelte @@ -2,7 +2,7 @@ import { pageTitle } from '$lib/branding'; import { FolderX, Music2 } from 'lucide-svelte'; import { createMissingFilesQuery } from '$lib/api/admin'; - import { relativeTime } from '$lib/utils/relativeTime'; + import { relativeTime, timeUntil } from '$lib/utils/relativeTime'; import { coverUrl } from '$lib/media/covers'; import ReacquisitionSettingsCard from '$lib/components/ReacquisitionSettingsCard.svelte'; import type { AdminMissingGroup } from '$lib/api/types'; @@ -26,6 +26,14 @@ const shown = $derived(groups.reduce((n, g) => n + g.tracks.length, 0)); const hasMore = $derived(offset + shown < total); + // "once" / "twice" reads far better than "1 times" in the sentence these + // land in, and the count is almost always small. + function attemptLabel(n: number): string { + if (n === 1) return 'once'; + if (n === 2) return 'twice'; + return `${n} times`; + } + function trackCountLabel(n: number): string { return n === 1 ? '1 track' : `${n} tracks`; } @@ -95,6 +103,28 @@ + + {#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)}
  • diff --git a/web/src/routes/admin/missing-files/missing-files.test.ts b/web/src/routes/admin/missing-files/missing-files.test.ts index 2ba2970e..03cbea44 100644 --- a/web/src/routes/admin/missing-files/missing-files.test.ts +++ b/web/src/routes/admin/missing-files/missing-files.test.ts @@ -49,6 +49,7 @@ const response: AdminMissingResponse = { { 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') @@ -57,6 +58,7 @@ const response: AdminMissingResponse = { { directory: '/music/Boards of Canada/Geogaddi', missing_since: new Date(Date.now() - 9 * DAY).toISOString(), + reacquisition: null, tracks: [track('1969')] } ] @@ -116,6 +118,45 @@ describe('admin missing files', () => { 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); + const line = screen.getByTestId('reacquisition-state'); + expect(line.textContent).toMatch(/asked lidarr twice/i); + expect(line.textContent).toMatch(/last 2d ago/i); + // Forward-looking, not relativeTime — which would say "just now" for a + // future timestamp and read as nonsense. + expect(line.textContent).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 = screen.getByTestId('reacquisition-state'); + expect(line.textContent).toMatch(/gave up/i); + expect(line.textContent).toMatch(/after 3 times/i); + expect(line.textContent).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', () => { From 6d729d15120ba17e67e68b438584ebce90aae660 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 17 Aug 2026 00:25:40 -0400 Subject: [PATCH 19/23] =?UTF-8?q?fix(web):=20timeUntil=20rounds,=20so=20a?= =?UTF-8?q?=204h=20wait=20doesn't=20read=20as=203h=20=E2=80=94=20#2527?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught a real bug, not just a brittle test. The page rendered an attempt four hours away as "in 3h", because timeUntil floored the way relativeTime does. Flooring an elapsed time is honest: "3h ago" means at least three hours have passed. Flooring a countdown is not -- 3h59m away became "in 3h", so the operator comes back an hour early and finds nothing has happened. It rounds now, with the boundary cases pinned: sub-minute is "any moment", 59.6m is "in 1h", 24h is "in 1d". That divergence is now the fourth documented difference between the two formatters, all deliberate, all in snippet #2699 with a test asserting they disagree so nobody unifies them later. The test assertions were also genuinely wrong: they read raw textContent from a template that wraps mid-sentence, so "last 2d ago" arrived as "last\n 2d ago". Added a whitespace-normalising helper — asserting on raw textContent makes a test fail when the markup reflows, which says nothing about the behaviour. --- web/src/lib/utils/relativeTime.test.ts | 9 +++++++ web/src/lib/utils/relativeTime.ts | 19 ++++++++------ .../admin/missing-files/missing-files.test.ts | 25 +++++++++++++------ 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/web/src/lib/utils/relativeTime.test.ts b/web/src/lib/utils/relativeTime.test.ts index 72d2184e..3e3c3f63 100644 --- a/web/src/lib/utils/relativeTime.test.ts +++ b/web/src/lib/utils/relativeTime.test.ts @@ -57,6 +57,15 @@ describe('timeUntil', () => { 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', () => { diff --git a/web/src/lib/utils/relativeTime.ts b/web/src/lib/utils/relativeTime.ts index a70a8276..cc7eb5be 100644 --- a/web/src/lib/utils/relativeTime.ts +++ b/web/src/lib/utils/relativeTime.ts @@ -38,12 +38,15 @@ export function relativeTime(iso: string): string { */ export function timeUntil(iso: string): string { const ms = new Date(iso).getTime() - Date.now(); - if (ms <= 0) return 'any moment'; - const days = Math.floor(ms / (24 * 3_600_000)); - if (days >= 1) return `in ${days}d`; - const hours = Math.floor(ms / 3_600_000); - if (hours >= 1) return `in ${hours}h`; - const minutes = Math.floor(ms / 60_000); - if (minutes >= 1) return `in ${minutes}m`; - return 'any moment'; + 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/missing-files.test.ts b/web/src/routes/admin/missing-files/missing-files.test.ts index 03cbea44..7e069762 100644 --- a/web/src/routes/admin/missing-files/missing-files.test.ts +++ b/web/src/routes/admin/missing-files/missing-files.test.ts @@ -66,6 +66,13 @@ const response: AdminMissingResponse = { 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 @@ -134,12 +141,14 @@ describe('admin missing files', () => { gave_up_at: null }; renderWith(withState); - const line = screen.getByTestId('reacquisition-state'); - expect(line.textContent).toMatch(/asked lidarr twice/i); - expect(line.textContent).toMatch(/last 2d ago/i); + // 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.textContent).toMatch(/next in 4h/i); + expect(line).toMatch(/next in 4h/i); }); test('a given-up album says so and says it can come back', () => { @@ -151,10 +160,10 @@ describe('admin missing files', () => { gave_up_at: new Date(Date.now() - 5 * DAY).toISOString() }; renderWith(withState); - const line = screen.getByTestId('reacquisition-state'); - expect(line.textContent).toMatch(/gave up/i); - expect(line.textContent).toMatch(/after 3 times/i); - expect(line.textContent).toMatch(/tried again/i); + 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 From 366692a1fcf0da011a0d4bc944cd9eceb253ac79 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 17 Aug 2026 12:56:31 -0400 Subject: [PATCH 20/23] =?UTF-8?q?fix:=20stop=20the=20sync=20feed=20hiding?= =?UTF-8?q?=20missing=20files=20from=20clients=20=E2=80=94=20#2704?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2523 filtered missing tracks out of every path that CHOOSES music, but the client sync feed was never touched: GetTracksByIDs has no filter and the wire had no field for it. So every Android client held a cached library containing tracks whose files are gone, with no way to tell, and could queue them from any cache-first path -- the exact failure #2523 existed to prevent, reached by a different route. Ships the state rather than filtering the feed, of the two options the ticket weighed. A missing file is expected to come back: the scanner clears the mark, and adopts the row if it returns renamed (#2528). Withholding the row would mean a delete-and-recreate on every client for what is usually a transient unmount, churning caches and throwing away the identity #2528 works to preserve. Room goes to v8. No hand-written migration: the pre-v1 destructive fallback rebuilds from sync, which repopulates every row with the new column -- exactly the case that policy exists for. The interesting part was working out what "missing" means to a client, and it is NOT "unplayable". Two findings shaped the fix: Server search and album detail never filtered missing tracks either, and that turns out to be right rather than an oversight. The consistent rule the codebase already follows is that Minstrel never PICKS a missing track for you -- recommendation, discover, mixes and browse all exclude them -- but it does not hide one you went looking for by name or opened an album to find. Hiding track 4 makes an album look wrong. So the fix is to mark and to keep it out of queues, not to hide it. And a track whose server file is missing still plays perfectly if its audio is already in the device cache. ShuffleSource's offline pools filter to exactly those residents, so it now clears the mark on the way out: the bytes are local and the server's loss is irrelevant. Without that, the queue filter below would have thrown away tracks that work, turning a fix into an offline regression. The queue protection is one choke point rather than five call sites. setQueue is where playlists, album play-all, search, radio and cold-boot resume all converge. dropUnavailable is pure so the index arithmetic is pinned by tests -- removing entries ahead of the requested position would otherwise start playback on the wrong track, and asking to start on a missing track now starts the next playable one, which is the "gets skipped" behaviour the operator asked for. An entirely missing queue returns empty and the caller leaves the player alone rather than replacing what is playing with silence. --- .../minstrel/cache/ShuffleSource.kt | 10 +- .../minstrel/cache/db/AppDatabase.kt | 8 +- .../cache/db/entities/CachedTrackEntity.kt | 5 + .../minstrel/cache/sync/SyncController.kt | 1 + .../minstrel/library/data/LibraryMappers.kt | 2 + .../fabledsword/minstrel/models/TrackRef.kt | 10 ++ .../minstrel/models/wire/SyncResponseWire.kt | 9 ++ .../minstrel/models/wire/TrackWire.kt | 5 + .../minstrel/player/PlayerController.kt | 48 +++++++++- .../minstrel/player/DropUnavailableTest.kt | 96 +++++++++++++++++++ internal/api/convert.go | 1 + internal/api/library_sync_views.go | 14 +++ internal/api/library_sync_views_test.go | 18 ++++ internal/api/types.go | 10 ++ 14 files changed, 231 insertions(+), 6 deletions(-) create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/player/DropUnavailableTest.kt 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 d28cc2cb..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 @@ -57,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/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/entities/CachedTrackEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/CachedTrackEntity.kt index 3cf501b9..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 @@ -8,6 +8,10 @@ import kotlinx.datetime.Instant /** * 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/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/models/TrackRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/TrackRef.kt index e32dafbd..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 @@ -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/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 3b9630af..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 @@ -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/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt index 28a8481c..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 @@ -260,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 @@ -270,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() } @@ -874,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/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..7fa27eeb --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/DropUnavailableTest.kt @@ -0,0 +1,96 @@ +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, 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/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_sync_views.go b/internal/api/library_sync_views.go index 0454149c..1e8520f9 100644 --- a/internal/api/library_sync_views.go +++ b/internal/api/library_sync_views.go @@ -79,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 { @@ -93,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 146ad665..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" @@ -75,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/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 From 7ba673ed836bfed0e2a00d0e6ce39b212d809b16 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 17 Aug 2026 13:04:14 -0400 Subject: [PATCH 21/23] =?UTF-8?q?fix(library):=20tell=20clients=20when=20a?= =?UTF-8?q?=20file=20goes=20missing=20or=20comes=20back=20=E2=80=94=20#270?= =?UTF-8?q?4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire field shipped in 366692a1 was inert. MarkTracksMissing and ClearTracksMissing are plain UPDATEs, and /api/library/sync is a change-log feed: a row that never produces a change row is never re-sent. Clients would have kept their stale copy until an unrelated edit touched the track or the cursor fell out of the retention window and forced a full resync -- so the flag existed and nothing ever told anyone to read it. Found by checking the consumer set rather than the code: the field was threaded end to end and every test passed, because none of them asked the question "how does this reach a client?". Logged BEFORE the mutation, which is the opposite of the scanner's log-after-success pattern, and deliberately so. The failure modes are not symmetric. Log-then-fail-to-mark makes clients re-read a track that has not changed: one wasted fetch. Mark-then-fail-to-log leaves the mark 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 much the cheaper mistake. Restoring logs too. A file coming back that nobody is told about stays greyed out on every device until something unrelated touches it, which would be a worse bug than the one being fixed. Op is upsert, not delete: the track still exists and keeps its history. Delete would tell clients to drop the row, which is precisely the design #2704 rejected when it chose to ship state instead of filtering the feed. Adds sync.LogChanges alongside LogChange, backed by an unnest batch insert. Every existing caller mutates one entity, so per-row was right for them; reconcile can mark a quarter of a library in one sweep, where a loop would be thousands of round-trips inside an already-slow scan. --- internal/db/dbq/library_changes.sql.go | 23 ++++++++ internal/db/queries/library_changes.sql | 11 ++++ internal/library/reconcile.go | 43 ++++++++++++++ internal/library/reconcile_test.go | 74 +++++++++++++++++++++++++ internal/sync/changes.go | 23 ++++++++ 5 files changed, 174 insertions(+) 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/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/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/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 { From b96285d6d99efefb1df99e7d795e5b8e09dbf4cc Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 17 Aug 2026 13:09:06 -0400 Subject: [PATCH 22/23] =?UTF-8?q?test(android):=20TrackRef=20needs=20album?= =?UTF-8?q?Id=20and=20artistId=20=E2=80=94=20#2704?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The queue-filter test built TrackRefs without them; they have no defaults, so compileDebugUnitTestKotlin failed. Caught by CI on the Android lane while I was reading the Go one. --- .../fabledsword/minstrel/player/DropUnavailableTest.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 index 7fa27eeb..fd332b5a 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/player/DropUnavailableTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/player/DropUnavailableTest.kt @@ -13,7 +13,13 @@ import kotlin.test.assertTrue class DropUnavailableTest { private fun track(id: String, unavailable: Boolean = false) = - TrackRef(id = id, title = id, unavailable = unavailable) + 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) } From 955a61194e15a5e57e445106ebe5be52344a0cec Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 17 Aug 2026 13:38:08 -0400 Subject: [PATCH 23/23] =?UTF-8?q?fix(library):=20a=20fully-missing=20album?= =?UTF-8?q?=20leaves=20the=20year=20axis=20too=20=E2=80=94=20#2702?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filed as a product decision, but the code had already made it: the genre queries filter tracks.missing_since inside their EXISTS, so an album whose every file had gone was ALREADY absent from genre while still listed under its year — where opening it found nothing playable. The two browse axes disagreed, and whichever answer won, one of them had to change. Hiding is the answer. Browsing is how you go looking for something to play, and the rule for that case is to take it out of view; the admin missing-files surface is where absence gets reported, with far more detail than a silent gap in a grid. It also means changing the axis that was inconsistent rather than the one that was already right. All three year queries move together — index, list and count. That is the invariant #367 needed care for at the genre level: if the index groups differently from the filter, a year leads to an empty page, and if the count disagrees with the list then "Load more" promises rows that never arrive. The predicate is "has at least one playable track", which also excludes an album carrying no tracks at all. Same answer for the same reason — nothing to play, nothing to browse to — and it is what genre has always done, since an album with no tracks contributes no genres either. That last part changed two existing tests, which had been seeding trackless albums as a convenience. Their intent (undated albums never appear in a range) is untouched; they now seed a track each, which is what a real album looks like anyway. Two new tests pin the actual behaviour: a fully-missing album leaves the axis while a half-missing one stays, and the count agrees with the filtered list. --- internal/api/library_browse_test.go | 118 ++++++++++++++++++++++++++-- internal/db/dbq/browse.sql.go | 35 ++++++++- internal/db/queries/browse.sql | 37 +++++++-- 3 files changed, 174 insertions(+), 16 deletions(-) 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/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/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