gen_tokens.dart now produces FabledSwordDarkTokens (dark surface),
FabledSwordLightTokens (light surface), and FabledSwordFlatTokens
(colors that don't change + radii + fonts). The original
FabledSwordTokens class stays as a back-compat alias re-exporting
the dark surface + flat values; any code that reads
FabledSwordTokens.obsidian etc. continues to work during migration.
Source: shared/fabledsword.tokens.json (already had dark/light/flat
splits — this was the generator catching up).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds optional actions: bool = true param. When true (default), renders
the 3-dot menu trigger at the end of the row. Album detail / Search /
History / Liked tabs all consume TrackRow so they pick up the menu
transitively.
Pair with c8baaa5 which wired the button into the other 3 surfaces;
this commit completes the wire-up by including TrackRow itself (its
write was missed in the previous commit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- TrackRow gains an actions: bool = true param; renders the button
after duration. Album detail + Search + History/Liked tabs all
consume TrackRow so they pick up the menu transitively.
- CompactTrackCard (home Most-played) gets the button at the end of
its inner row.
- _PlaylistTrackRow in playlist_detail_screen gets the button next
to duration; skipped for unavailable rows (no track id).
- NowPlayingScreen renders the button next to the title with
hideQueueActions: true (Play next / Add to queue suppressed since
the menu's track IS the playing one). artistId is left empty since
it's not stashed in MediaItem.extras — Go-to-artist will route to
/artists/ which is benign for v1; a follow-up could stash it.
Closes Tier 1 follow-up #2 (track-level actions menu).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Modal bottom sheet listing the caller's user-created playlists (system
playlists like For-You are filtered out — they're not user-mutable).
Tap a row to pop with the playlist id; consumer (TrackActionsSheet)
then calls PlaylistsApi.appendTracks.
Empty-state copy: "You haven't created any playlists yet."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Modal bottom sheet with five reason chips (mirroring server vocabulary
bad_rip / wrong_file / wrong_tags / duplicate / other) plus an optional
notes field. Returns (reason, notes) on Hide, null on Cancel. Default
selection is bad_rip.
Used by TrackActionsSheet's Hide action.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 3-dot trigger and the modal-bottom-sheet menu it opens. 7 actions:
Play next, Add to queue, Like/Unlike, Add to playlist, Go to album,
Go to artist, Hide/Unhide. hideQueueActions: true suppresses the
first two for the Now Playing surface.
Sub-sheets (HideTrackSheet, AddToPlaylistSheet) land in subsequent
commits — this commit imports them speculatively, so CI between this
and the next two commits will fail. Resolved by Tasks 5 + 6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small data-layer additions for the upcoming track-actions menu:
- PlaylistsApi.appendTracks(playlistId, trackIds) wraps
POST /api/playlists/{id}/tracks for the "Add to playlist" action.
- audio_handler gains playNext (insertAudioSource at currentIndex+1)
and enqueue (addAudioSource) — both also push the audio_service
queue notifier so the queue-screen UI stays in sync.
- The AudioSource construction was extracted into a private
_buildAudioSource helper so setQueueFromTracks / playNext / enqueue
share one source-building path.
- PlayerActions exposes playNext / enqueue for menu use.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AsyncNotifier wrapping /api/quarantine/mine with optimistic flag /
unflag mutations and an isHidden(trackId) convenience for menu state.
Mirrors the LikedIdsController pattern: optimistic update, rollback
on server error.
Used by the next slice's TrackActionsSheet to power Hide / Unhide.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dio client for /api/quarantine — flag a track with a reason + optional
notes, unflag by track id. Mirrors the server's user-scoped quarantine
endpoints (separate from /admin/quarantine).
Used by the track-actions menu's Hide/Unhide action in the next slice
of commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audio handler accepts an AlbumCoverCache via configure() and uses it
to populate MediaItem.artUri with a file:// URI to a locally cached
album cover. Lock screen, Bluetooth car displays, Wear OS, and CarPlay
now show the album cover for the currently playing track instead of
the system's generic music icon.
Flow:
- _toMediaItem stashes album_id in MediaItem.extras
- After setQueueFromTracks pushes the queue + initial mediaItem, fires
_loadArtForCurrentItem async (doesn't block playback)
- Subscribes to _player.currentIndexStream so track advances trigger
the same loader for the new current item
- _loadArtForCurrentItem early-returns if cache is null, no current
item, no album_id, or artUri already set; otherwise calls
cache.getOrFetch and pushes an updated MediaItem with artUri set
- Race guard: if the user skipped to another track while the fetch was
in flight, the result is discarded
Closes the visible "generic music icon on lock screen" gap operator
flagged when first-testing on a real device.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the Riverpod provider (constructed with dioFactory pulling from
the authenticated dioProvider) and extends PlayerActions.playTracks
to forward the cache to audio_handler.configure(). The handler
signature change lands in the next commit.
Intentional intermediate-broken state: audio_handler.configure
doesn't yet accept coverCache. Resolved by the next task in the same
push.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fetches album cover bytes via the authenticated dio and writes to
<applicationCacheDirectory>/album_covers/<albumId>.jpg. Returns the
local path so MediaItem.artUri can point at a file:// URI — Android's
MediaSession framework fetches artUri itself without our Bearer
header, so a pre-fetched local file is the workaround.
Concurrent callers for the same albumId dedupe to one fetch via an
in-flight Future map. Any failure (network / 4xx / 5xx / disk full /
empty id) returns null without throwing — playback continues with
the system's generic music icon, same UX as today.
No eviction — covers are tiny, OS clears cache when space is tight.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Already a transitive dep via flutter_secure_storage and flutter_cache_manager.
Promoting to direct so we can import it from album_cover_cache (next
commit) for the lock-screen artwork cache directory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three info-level lints from CI:
- playlist_card.dart doc comment: "/playlists/<id>" → "/playlists/{id}"
in backticks, since `<id>` was flagged as unintended HTML.
- compact_track_card_test.dart: home: Scaffold(...) is now const, which
propagates const to CompactTrackCard and makes the inner [_track]
implicitly const.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Expands the existing single test into four cases:
- 4 placeholder cards in Playlists row when no system playlists exist
- Empty-state copy for each section verbatim against the strings
in HomeScreen
- Real For-You card renders with the system badge when present
- Existing rollups (Recently added / Rediscover) still render correctly
alongside the new providers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewrites HomeScreen body to match the web home layout:
- Playlists row: For-You + 3 Songs-like + user playlists, with
PlaylistPlaceholderCard for empty slots driven by
systemPlaylistsStatusProvider
- Recently added: 50 albums in 2 rows of 25 sharing a scroll controller
- Rediscover: separate scrollers for albums (square) and artists
(circular) — same condition as web's two-scroller branch
- Most played: 75 tracks in 3 rows of 25 using new CompactTrackCard
- Last played: existing single row layout preserved
Empty states use design-system understated-mythic copy mirrored
verbatim from web. Loading state uses DelayedLoading so brief network
blips don't flash a spinner.
Closes the visible parity gap the operator hit when first opening the
Flutter app on a real device.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets multiple stacked HorizontalScrollRow instances render under a
single visible heading by passing title: "" to the second-row-onward
instances. Combined with the existing shared-controller support, this
gives us multi-row scrollers without a new widget.
Used in the next commit's home rewrite for Recently added (2 rows)
and Most played (3 rows).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renders nothing for the first [delay] (default 200ms) of sustained
loading, then renders the [whileDelayed] child until loading completes.
Mirrors the web useDelayed hook so a fast network response doesn't
flash a skeleton.
Used in the next commit's home screen rewrite; otherwise unused this
slice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the web equivalents at the same ~176dp width so the home
Playlists row keeps visual rhythm whether the slot is filled with a
real playlist or a placeholder.
PlaylistPlaceholderCard variants:
- building: spinner + "Building…"
- failed: warning + "Couldn't generate"
- seed-needed: muted icon + "Like more music"
- pending: muted icon + "Coming soon"
Used in the next commit's home Playlists row; otherwise unused this
slice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the web CompactTrackCard — small horizontal cell with cover
art (48dp), title, artist. Tap plays from this track in the section.
Width 176dp matches web's compact density so 25 tracks per row scroll
naturally on phone widths.
Cover URL is derived from track.albumId (/api/albums/<id>/cover) since
TrackRef itself doesn't carry a cover field — same pattern as web's
coverUrl() helper.
Used in the next commit's home Most-played section (3 rows × 25);
otherwise unused this slice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Riverpod FutureProvider that reads the caller's system-playlists
build state. Consumed by the home Playlists row to decide between
real and placeholder cards.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the model + MeApi.systemPlaylistsStatus() for the home Playlists
row's placeholder-variant logic (building / failed / pending /
seed-needed). Mirrors GET /api/me/system-playlists-status which
returns the caller's most recent system_playlist_runs row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The server has always returned an enveloped response from GET
/api/playlists; the existing client decoded it as a flat List which
fails at runtime against the actual Map shape. Existing playlists
list screen would have been broken on any production instance.
list() now returns PlaylistsList { owned, public }. The existing
list screen consumes lists.all (owned + public flattened) — same
visible output as the previous flat-list assumption.
This unblocks the home parity slice which needs the same endpoint
for the new Playlists row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audio playback failed against the prod HTTPS server with
"Cleartext HTTP traffic to 127.0.0.1 not permitted". Diagnostic
logging confirmed _baseUrl was correctly set to the prod URL — the
127.0.0.1 wasn't coming from the server or our URL construction.
Root cause: just_audio + audio_service spin up a local HTTP proxy on
loopback to inject the Authorization header, because Android's
MediaSession API can't pass headers down to ExoPlayer directly.
ExoPlayer connects to http://127.0.0.1:<random-port>; the proxy adds
the Bearer header and forwards to the real upstream HTTPS URL.
Loopback traffic doesn't leave the device.
This is the documented just_audio happy path — see
https://pub.dev/packages/just_audio#a-note-on-android-cleartext-traffic
— not a workaround for a plugin bug.
Adds network_security_config.xml that scopes cleartext permission to
127.0.0.1 ONLY. The base config keeps cleartextTrafficPermitted=false
so any actual remote endpoint must still be HTTPS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues from on-device testing against prod HTTPS:
1. ServerImage was resolving cover URLs correctly
(https://minstrel.fabledsword.com/api/albums/.../cover) but the server
returned 401 because Image.network doesn't carry the session token
automatically the way the browser sends cookies. Forwards the stored
session token as an Authorization: Bearer header. No-op when no token
is present.
2. The "Cleartext HTTP traffic to 127.0.0.1" audio error reproduced even
after the previous defensive check landed, which means the URL handed
to ExoPlayer has a valid scheme+host (just the wrong host). The check
only catches scheme-less URLs, so it didn't fire. Added debugPrint
logging at configure() and setQueueFromTracks() time to show the
actual baseUrl + per-track resolved URL on the next reproduction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refactors the new describe block to use the existing setupFresh()
helper pattern (mirrors setup() but with empty profile/folder lists
so we can verify the test response is what populates the dropdowns),
the lidarrSection() scoping helper (the cover-providers and SMTP
panels also have "Save changes" buttons that match unscoped queries),
and the correct mockQuery({ data: ... }) shape used elsewhere in the
file. Drops the duplicate afterEach (the file already has a global
one at line 131).
No behavior change in the tests themselves — same three cases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new cases:
- Save on a fresh form calls test first, fills auto-defaults from the
response, then put-config with non-zero defaults
- Save aborts when test fails and surfaces the error code
- An operator-picked default value survives the test → save sequence
(auto-default $effect only fills when current value is 0/'')
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Click sequence on a fresh Lidarr config: enter URL + API key → click
Save. The page now calls testLidarrConnection first, stashes the
returned profiles/folders into local state, lets the existing
auto-default $effects pick first-of-each, then sends the put-config
request with non-zero defaults. If the test fails, the save aborts
and the error surfaces in saveError.
The explicit Test connection button keeps working and additionally
populates the dropdowns from the same response so an operator can
inspect/adjust before saving.
Each dropdown reads from its test-state array when present, falling
back to the existing query data (which only works after the first
successful save). list_errors entries surface as inline notes under
their respective dropdowns.
Closes the chicken-and-egg where saving required defaults that could
only be fetched after saving.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the server-side extension of POST /api/admin/lidarr/test. The
integrations page consumes these in the next commit to pre-fill the
quality/metadata/root-folder dropdowns on first-time Lidarr setup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Updates the happy-path test stub to handle the three list endpoints
Lidarr exposes (qualityprofile / metadataprofile / rootfolder) and
asserts profiles + folders make it through to the response.
- Adds a partial-failure case where one list endpoint 5xxs; verifies
ok=true, the failing list is omitted, and list_errors carries its
bucket code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends POST /api/admin/lidarr/test response to include quality_profiles,
metadata_profiles, root_folders, and an optional list_errors map when the
Lidarr connection succeeds. The three list fetches run in parallel after
the ping; any per-list failure goes into list_errors so the response can
still report ok=true with whatever data did come back. Failed-ping
response shape is unchanged.
This lets /admin/integrations populate its dropdowns on a single
round-trip during first-time setup, fixing the chicken-and-egg where
the dropdowns previously gated on cfg.Enabled (which can't be true
until the first save, which itself needs non-zero defaults).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cover-art Image.network calls were passing server-relative URLs
(/api/albums/<id>/cover) straight to NetworkImage, which interprets
"no scheme" as file:/// and crashes with "No host specified in URI".
Same root cause regardless of HTTPS or HTTP server.
ServerImage wraps Image.network with a Riverpod read of
serverUrlProvider and prefixes the configured base URL. Absolute URLs
(e.g. discover screen's Lidarr image_urls) pass through unchanged.
Three call sites updated: album_card, artist_card, playlists_list_screen.
discover_screen left as-is — its row.imageUrl is already absolute (Lidarr
returns full URLs from MusicBrainz / Spotify metadata) and it has a
meaningful errorBuilder that ServerImage doesn't expose.
Also adds a defensive check in audio_handler.setQueueFromTracks: if
the constructed stream URL ends up scheme-less, throw a StateError
naming baseUrl + track.streamUrl + track.id instead of letting it
fall through to ExoPlayer which surfaces a confusing "Cleartext HTTP
traffic to 127.0.0.1 not permitted" error (Android's URL parser
defaults a scheme-less URI to localhost). User reported this exact
confusing error against an HTTPS prod server; the better message
will pinpoint where the empty baseUrl comes from on next reproduction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related Android setup gaps caught when running on a clean Pixel 6
emulator:
1. MainActivity extended FlutterActivity, but the audio_service
plugin requires AudioServiceActivity. AudioService.init() threw
PlatformException("The Activity class declared in your
AndroidManifest.xml is wrong"), and the partial-engine-reinit that
followed cascaded into a flutter_cache_manager SQLite EXCLUSIVE
lock crash. Both errors clear with the correct base class.
2. The main manifest was missing two production permissions:
- POST_NOTIFICATIONS — Android 13+ runtime requirement for the
media notification (now-playing tile in the system tray).
audio_service auto-prompts at init() time once declared.
- ACCESS_NETWORK_STATE — used by ConnectionErrorBanner +
flutter_cache_manager for connectivity awareness.
Note for future contributors: src/main/AndroidManifest.xml IS the
production manifest. The src/debug and src/profile variants only add
dev-only entries (e.g. INTERNET re-declared in debug for hot-reload).
There is no separate "release" manifest in Flutter's convention.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bootstrap-admin-from-env-vars flow was a holdover from before
self-registration could create the first admin. Now that handleRegister
+ CreateUserFirstAdminRace promotes the first user to register on an
empty users table (verified shipped in v2026.05.08.2 / #376), the
bootstrap path is just a second source of truth that confuses operators
(and leaves an "admin" account in the DB that nobody asked for).
Removes:
- internal/auth/bootstrap.go + its test
- The auth.Bootstrap call from cmd/minstrel/main.go
- AuthConfig + AdminBootstrapConfig structs from config
- MINSTREL_AUTH_ADMIN_USERNAME / _PASSWORD env reads + their test
- The auth: block from config.example.yaml
- Bootstrap-related comments in docker-compose.yml + README.md
The README quickstart now points operators at /register on first start
instead of "watch the logs for a one-time password."
Existing instances keep their bootstrap-admin row in the DB; operators
who want it gone can register a new admin via /register, promote them
in /admin/users, then delete the old bootstrap user (last-admin guard
will require the new admin to be promoted first). No migration needed.
Recovery story for forgotten admin passwords now hinges on Fable #321
(admin password reset CLI) — currently the only path back in if no
other admin exists.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI caught six issues against the new admin slice:
- AsyncValue<T> in this Riverpod 3.3.1 codebase exposes `.value`
(returns T?), not `.valueOrNull`. Switched the three admin readers
to match the established convention (player_bar, queue_screen,
now_playing_screen all use `.value`).
- home_screen.dart still has ctx.push calls in _albumsRow / _artistsRow
(carousel tap handlers) — restored the go_router import that
Task 2 over-eagerly removed.
- admin_user_edit_sheet.dart had a single-line `if (!await ...) return;`
that violated curly_braces_in_flow_control_structures. Wrapped in
braces.
- admin_quarantine_item.dart doc comment had `<top>` placeholders that
the analyzer flagged as unintended HTML. Rephrased without angle
brackets.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single-scroll layout with two _SectionHeader-anchored blocks. Users
rows tap into AdminUserEditSheet. Invites row shows token (monospace)
with copy button, optional note, and a redeemed badge if applicable.
"Generate invite" opens a small dialog with an optional note field
(server hardcodes 24h TTL — admins can't customise expiry). On
success the new token is shown in a copy-once result dialog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AdminUserRow shows username + admin/auto-approve badges; tap opens
AdminUserEditSheet bottom sheet.
Edit sheet: SwitchListTile pair for is_admin and auto_approve_requests
(both surface server errors via SnackBar, including the last-admin
guard). Reset password takes a typed-input dialog (>=8 chars enforced
client-side; admin supplies the new password). Delete routes through
TypedConfirmSheet ("DELETE") and pops the sheet on success.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends AdminUsersController (build-only since slice 2) with setAdmin,
setAutoApprove, delete, resetPassword. is_admin/auto_approve toggles
optimistically swap the row and roll back on server error (including
the last-admin guard 4xx). resetPassword is admin-supplied; no
auto-generation mode exists server-side.
Adds AdminInvitesController + adminInvitesProvider with create
(prepends new invite to the list, returns it for the copy-once
dialog) and revoke (optimistic remove + rollback).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ExpansionTile per aggregated quarantine row — collapsed shows track,
artist · album, and "{N} reports: {top reason}". Expanded reveals the
nested per-user reports with their reasons + optional notes.
Three-dot menu offers Resolve / Delete file / Delete via Lidarr; both
deletes route through TypedConfirmSheet which requires the user to
type "DELETE" before the action button enables.
Adds AdminQuarantineController with optimistic-removal + rollback,
mirroring AdminRequestsController's pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AdminRequestsScreen renders rows from adminRequestsProvider, joining
requester user_id → username via adminUsersProvider for display
(falls back to UUID prefix when the users list hasn't loaded yet).
Approve fires immediately; Reject confirms via dialog. Both are
optimistic with rollback in the controller.
Adds AdminUsersController build-only (the read side) so the row join
works; mutation methods land in Slice 3 alongside the Users screen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AsyncNotifier removes the row optimistically on approve/reject; rolls
back to the pre-mutation list on server error. Invalidates
adminCountsProvider so the landing badge stays accurate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Settings gains an _AdminSection that renders an "Admin" tile (Shield
icon → /admin) only when the current profile has is_admin = true.
Non-admins see the section as SizedBox.shrink() so the const ListView
children stay const.
Provides a discoverable second entry to the admin landing alongside
the kebab overflow shortcut.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ListView of three AdminSectionCard tiles (Requests / Quarantine /
Users) showing live counts from adminCountsProvider. Pull-to-refresh
re-fans-out to the three list endpoints. Admin/Quarantine/Users
sub-routes will land in subsequent commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single landing-screen provider fans out to requests/quarantine/users
list endpoints in parallel and returns counts for the section-card
badges. Per-section AsyncNotifier controllers land alongside their
screens in subsequent commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four small Dio-backed API classes mirroring the server's actual
contract (verified against internal/api/admin_*.go):
- Requests/Quarantine return flat lists; Users/Invites are enveloped
({"users": [...]}, {"invites": [...]}) and unwrap here
- setAutoApprove sends {auto_approve: bool} — different from the
response field name auto_approve_requests
- resetPassword takes admin-supplied {password: string}, returns 204
- Invite create takes optional {note: string}; expiry is server-fixed
at 24h
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hand-rolled to match the server's actual JSON shapes:
- AdminRequest carries user_id (UUID, not username); kind switches
which title field is the display name
- AdminQuarantineItem aggregates reports per track with reason_counts
and a nested reports[] list, mirroring adminQueueRowView
- AdminUser includes display_name + auto_approve_requests
- Invite uses invited_by UUID + note (server hardcodes 24h TTL)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds /admin, /admin/requests, /admin/quarantine, /admin/users routes to
the ShellRoute (so PlayerBar persists). Redirect closure refuses any
/admin path when user.isAdmin is false. Server-side RequireAdmin
middleware remains the actual authority — this is UX.
Screens land in subsequent commits; this intermediate state won't build
until task 8/9 lands the AdminLandingScreen and the missing siblings
are stubbed out by task 11/13/16.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Search keeps its conditional clear-text button and gains the shared
nav widget after it. Discover/Playlists/Settings get an actions row
they didn't have before. The kebab is now reachable from every
top-level screen, which is the prerequisite for the Admin entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HomeScreen drops its 5 ad-hoc IconButton actions in favour of the shared
widget. LibraryScreen gains an actions row it didn't have before. Both
now consistent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shared AppBar actions for top-level screens — three primary icons
(Home/Library/Search, current screen suppressed) plus a kebab containing
Playlists/Discover/Settings and (only when isAdmin) Admin. Replaces the
ad-hoc per-screen IconButton lists; centralises admin-gating logic in
one place so /admin entry can't accidentally leak to non-admins.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>