Skip to content

Player redesign 8b-1: household members - #100

Merged
KodeStar merged 61 commits into
mainfrom
feat/player-8b-1
Oct 14, 2026
Merged

KodeStar merged 61 commits into
mainfrom
feat/player-8b-1

Conversation

@KodeStar

@KodeStar KodeStar commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

Player redesign Phase 8b-1 (★): household members in the player. Pairs with KodeStar/audiosilo-server#127 (merge first), KodeStar/audiosilo-docs#130 and KodeStar/audiosilo#54. No release.

What it does

  • Wire (src/api, capability household_members): HouseholdMember(s), AddMemberBody, UpdateMemberBody, MemberKind, MemberAccess; User.managed_by/member_kind/display_name; MyDevice.member_sign_ins; client methods and hooks for the eight member routes (mutations reject with CapabilityError without the flag); useRevokeMyDevice({ id, alsoMembers }); ApiError.code + apiErrorKind (the app never shows a server message).
  • The Household page: the phone You hub's sixth segment and the pushed /household page elsewhere (/settings?section=household redirects); On this device (people, PINs, kids mode, Sign in again), Your household (members, Add someone to your household or the reason it isn't offered), Listening in the house.
  • Add someone to your household: name, Kids switch, What can they listen to (decision J defaults), "Create 's books" made on save; a kid is signed in here (addMemberHere) with a PIN offer; a grown-up gets a pairing QR and "They'll also use this phone".
  • Member card: access (Change; switching to Everything stops sharing your collections), Kids switch, their devices (This device), Set up their own device, Sign in on this device, Rename, Delete (twice confirmed), Remove from this device.
  • Connect: addMemberHere/renewMemberHere/signInMemberHere with setSession(..., { makeDefault: false }) and placeConnection(id, { newProfile, keepActive, name }); never switchProfile.
  • Kids: a kid account starts in kids mode on any device, even with no grown-up there; its shelf is the collections its owner shares with it (else the one library); 1x by default; kids mode never runs the connect flow (connect/_layout.tsx is the gate: a link bounces with "Ask a grown-up to open this link", the token unspent); a dead sign-in says "Ask a grown-up to sign in again."; the car's kids snapshot is the shelf only (max 50).
  • Other: My devices "Also sign out on that device"; the share sheet's member note; a kid's Account page (no password, pairing, API keys or house sharing); the owner named by their profile name on this device (managerName).
  • Old servers: no flag, no member UI and no also_members; everything Phase 8 shipped works as before.

Playback internals (decision 7), each with its own regression test

  • playBook reads its default rate from listenerSettings() (1x in kids mode).
  • The download hot-swap plays on from intent (switchCurrentBookToLocal: playing or wantsPlayback). Pre-existing bug the device pass found: a short story's automatic download landed while its start was still loading, and iOS's load pauses, so a kid's first tap loaded and stopped on Play. Reproduced on demand in the iOS Simulator before and after.

Gates

tsc, lint, prettier, npm test (400 suites / 4677 tests), expo export -p web; Android and iOS Release device builds.

Passes

  • Real-browser pass (Playwright, 400/834/1440, both themes, German) and a re-check: 19 findings fixed.
  • Device pass and re-check (Pixel 6a Android 16, iPhone Air iOS 26.6.2, Release builds): 12 findings fixed, including a kid's dead sign-in opening the grown-up connect flow (pre-existing; a kid could forget a grown-up's server from there). The final iPhone check of the hot-swap fix passed: 4 of 4 first taps into a new kid played, saved places resumed, downloads landing mid-play kept playing and paused books stayed paused.
  • /simplify (4 reviewers; one real bug found on the way: the wide Settings link passed the press event as navigation state).
  • /code-review max --fix: 15 confirmed findings + 10 more confirmed beyond the cap, all fixed with tests.

Deliberately not fixed (follow-ups)

  • A kid on Everything gets the first library, as the plan specifies (preferring a "Kids" library, or asking on the switch, would read better).
  • Re-pairing through a link leaves the device's old session live on the server (pre-existing).
  • The launch-time Who's listening? ignored the top safe area once on the iPhone; Home's sync label is stale right after pairing; Android Auto's warning triangle for a coverless story (pre-existing).
  • Unifying memberConnection with priorConnection (the latter matches a legacy connection, which could point Remove from this device at the owner).
  • /code-review ultra not run (optional).

Mirror the household_members routes in src/api: HouseholdMember and
friends, managed_by/member_kind on User, member_sign_ins on MyDevice,
members_signed_out on revoke. Client methods for the eight member routes
and revokeMyDevice(id, { alsoMembers }) sending ?also_members=1 only when
asked. Hooks gated on household_members (skipToken queries,
CapabilityError mutations) with connection-scoped keys; useRevokeMyDevice
also takes { id, alsoMembers }, sent only to a server with the flag.
…s accounts

setSession(..., { makeDefault: false }) leaves the default connection where
it is (it still becomes the default when there is none, or the default was
retired). placeInDoc/placeConnection take keepActive and a name: a new
profile is added without changing the person listening. mayTurnOnKids
allows kids mode with no grown-up on the device when every account of the
profile is a kid's (user.member_kind === 'kid', isKidsAccount); the last
grown-up rule still holds for every other account.
…gn-in

addMemberHere(session, { name, kids }) signs a household member in beside
the owner: stored on the owner's address and server name, the addresses
learnt, the default connection unchanged, a profile of their own named as
typed, the owner still active (never switchProfile), kids mode on for a
kid. Refuses with AddMemberHereError ('already_here') when the device holds
that user on that server (memberSignedInHere, to ask before minting).
finishConnect turns kids mode on at a kid account's first placement on a
device (its own phone paired by QR included), never for a profile that
also holds a grown-up's account. Locale keys connect.member* and
kids.stories.askGrownUp in all six languages.
A household member's kids mode shows the collections their owner
(user.managed_by) shares with them, in the list's order: one run as the
whole shelf, several under their names, stories in progress first; empty
collections ask a grown-up to add stories. No such collection, an older
server, or an account nobody manages keeps the one-library view. Offline,
the downloads of the kid's account.
The car for a kids profile lists the kids shelf as the Library tab (Your
stories), plus Continue listening for its stories in progress; no Up next,
no Downloads, nothing of anyone else's. Built without React from the same
reads (collections, else one library, else the account's downloads), and
rebuilt at once when kids mode turns on or off.
usePairingCode takes any mint (a member's own code reuses it), PairingCode
is the QR, countdown and copy or share view PairDeviceCard now renders,
CheckRow moves out of the collection dialogs for the household pickers and
the My devices tick, and RenameDialog takes an onSave for a member's name
on the server.
A device whose member_sign_ins is non-empty offers "Also sign out Evie and
Ellis on that device", ticked by default (decision B), and passes
alsoMembers; the toast counts the members signed out. Names are joined by
hand (Hermes has no Intl.ListFormat). This device and devices with no
member sign-ins are unchanged.
When a picked share target is the listener's own household member on
"Only collections I share", a line says "Evie can listen to everything in
this collection." The members list is asked for only while the dialog is
open. Uses the shared CheckRow.
…rs UI

The Household pane leaves Settings and becomes a page (decision 4): the
phone's sixth Me segment between Settings and Account, and /household, a
page of the array group pushed by openHousehold from the profile menu,
Who's listening? > Manage household, Settings, Home's house row and the
palette. /settings?section=household replaces itself with the page.

The page holds the people on this device (Add someone is now "Sign in
someone who already has an account"), the household members of each
server with household_members, and house sharing. Members get a card each
(access with collection links and Change, the Kids switch with its
confirmation, devices with Sign out, Set up their own device, Sign in on
this device, Rename, Delete their account confirmed twice, apart from
Remove from this device), and "Add someone to your household" opens the
add dialog (name, Kids, What can they listen to? defaulting per decision
J; a kid is signed in here through addMemberHere and the owner offered a
PIN, a grown-up is shown their pairing code with "They'll also use this
phone"). Each refusal reason has its line. Strings under household.* in
all six languages.
…, and a member device sign-out refreshes whatever the answer
…ies, and pairing never shows the server's words

writeError reads ApiError.code (member_limit, username_taken, invalid_name,
invalid_collections, feature_off, not_allowed, member_cannot_manage,
kid_account, demo_account) with the status as the fallback; 429 is
"Too many tries. Wait a few minutes and try again." on every member route,
the member's pairing code, Pair another device and the link exchange. The
locales for this round (all six), including de/fr "Also sign out ... on
that device".
…g, no gaps from closed dialogs

Remove from this device for a household member names who can sign them in
again instead of the new-invite warning, and reads the owner's household
again (member devices, the list, my devices) on that server. A member's kids
mode lines name who manages them, never the server's admin. Settings links
to Household by the page's own name.
…ber's manager, and the QR and access dialogs

- Create <Name>'s books is a ticked new collection made on submit, before
  addMember or the change (its id in collection_ids), deleted again when the
  write is refused; Cancel makes nothing.
- Renaming a member renames their profile on this device too.
- A member's Household says who manages them.
- The access dialog asks its question once; the QR step has Done in the
  dialog footer and the link on one line.
- Dialogs sit outside the gapped columns.
…gns them in again

A kid whose sign-in ended sees "Ask a grown-up to sign <Name> in again." (no
Reconnect button), the stories say the sign-in ended instead of "isn't
answering", and no bare Try again is left. /connect is no longer a kids-mode
route: a link into the flow (audiosilo://connect, /web/connect?token=) goes
back to /kids with a toast, and the connect layout runs none of the flow, so
the link's token stays unspent. Only a grown-up's Add someone on a device with
no grown-up gets through (openAddSomeone, kidsConnectAllowed).

The Household page's On this device card says when a person's sign-in ended
and signs them in again: a member the person listening manages with a session
the owner mints (renewMemberHere), anyone else through the sign-in step.

A kid's account's first sign-in goes straight to kids mode, a second tap on a
story still starting is the same start, the kids player shows the spinner while
a story loads in, the kids library row moves under its label on a phone and is
hidden while the shelf is the owner's shared collections, and Who's listening?
says the device is shared only once it is.
The default speed is a grown-up's device-wide setting and kids mode has no
speed control, so a kid inherited 1.4x. listenerDefaultRate() is 1 while a
kids profile listens; playBook (one line, with its regression test) and the
car's native start specs read it. A speed saved for the kid on that story
still wins.
…books

The password card says only what is true of an account without a password
(neutral, or that its household owner set it up), and offers a kid's account no
password at all (the server refuses one). "Create <Name>'s books" becomes
"Share <Name>'s books" when the owner already has a collection of that name,
ticking it instead of making a second one.
… after Sign in again

useKidsSharedShelf now says where the shelf comes from ('shared', 'library',
'unknown'): the library row is hidden while the kid's sign-in ended or the
member's shared collections couldn't be read. The card reads a connection's
queries again once its token changes, in an effect so the queries already hold
the new client. Sign in again says too many tries on a 429, and Remove from this
device is labelled with the action, not the confirm dialog's question.
The wide Settings nav item and the narrow Household card wired
onPress={openHousehold}, so the press event arrived as the nav state and
focusedRoute threw on it. openHousehold now reads the nav state itself, so it
can be any onPress as it is.
…errors branch on it

The known refusal codes are a typed union; household wording (writeError,
writeErrorText) maps the kinds, and isTooMany and the raw 404/409/429
checks in the 8b-1 code read the kind instead.
…le, the screens don't

useCapabilityMutation's one-caller failed hook becomes settled (whatever
the answer). A refused add or delete reads the members list again in the
hook; a write that shares collections reads them again in the hook; one
invalidateHousehold for the three household keys. A member change reads
the list and devices again only when they become a kid (the answer is
stored), a delete no longer reads again the list it just filtered.
RevokeMyDeviceVars is an object only; memberDevices drops the wrapper shape
the server never sends.
…erver says kids mode

The card, "They'll also use this phone" and a kid just added all go through
signInMemberHere (the pre-mint check, the session, addMemberHere, one
classification). AddMemberHereError carries a code (already_here,
not_ready, choose_adult) that hereFailure says in words, so no translated
message travels in an error. addMemberHere turns kids mode on through
kidsOnForKidAccount (the server's member_kind) instead of a caller's kids
flag, and stores the session the same way as renewMemberHere
(storeMemberSession). The kid step reuses hereFailure; household.here.failed
no longer repeats failedCard.
…ed_by and member_kind

kidsManager moves to the kids model beside kidsConnectionId.
…and the car

kidsShelfMode and kidsOfflineScope (pure, in the kids model) decide where
the shelf comes from and which downloads stand in offline; one hook
(useKidsShelfSource) feeds useKidsStories and useKidsSharedShelf, and the
car's readKidsShelf calls the same rules. A signed-out kid is part of the
stories (ready.signedOut, or the signed-out state) instead of a flag beside
them. collectionsQuery and collectionQuery factories; one newest-first
helper for the stories in progress; the sections' books memoised and
deduplicated by a Set; KidsSection titles always set (one run is shown
untitled by the list). The car reads the shelf alongside the progress
(libraries alongside the collections), rebuilds only for the kids
connection's collections and never for an answer read again unchanged;
retry leaves the libraries alone on a collections shelf; onPlay reads the
latest render through useLatest.
The kids screen's module-level de-dup (8b-1, D2) moves into
startBookInPlace, keyed by the book and its start options, so a second tap
anywhere joins the first start instead of starting the book twice.
…e flow

connect/_layout.tsx runs none of the flow for a kids profile without a
grown-up's pass, says why when the flow opened in kids mode (a link) and
goes back to /kids; the route guard leaves /connect to it. One connect
intent object (newPerson, grownUpFromKids) ended when the flow is left
(endConnectFlow). finishConnect takes a kids profile straight to /kids.
The guard's D1 tests run against the real connect layout now.
…n, not the Household card

ApiProvider notices a connection's new token in its effect (after every
screen's, so the queries hold the new client) and reads again what failed
and the server, libraries and collections lists of that connection only.
The card's useRefetchWhenSignedInAgain goes; its regression test moves to
provider.test.
… listens

src/playback/listener-settings.ts replaces default-rate.ts and the auto sleep
controller's effectiveAutoSleepConfig: a pure listenerSettingsFor, the
getter playBook, the car and the controller read, and useListenerDefaultRate
for screens (time left and a book's speed now follow kids mode's 1x too).
Playback internals only change where they read the input.
…de openSettings

DevicePeople moves to components/household/device-people.tsx and HouseSharing
to house-sharing.tsx (their test, which mounts the Household page, becomes
household-page.test.tsx); CheckRow, used by the share sheet and My devices
too, moves to components/ui; openHousehold joins openSettings in lib/open.
…ack, start join, connect gate, car scope

- member_disabled (and a gone member) read as such on sign in here, sign in
  again and the member QR; one list builds the known error codes.
- Member writes refresh collection details; sharing changes and collection
  deletes refresh the members list; refused session, pairing and changes
  re-read it.
- The access picker never adopts the collection its own save created; the
  new books collection is deleted again only after a definite 4xx.
- A start reuses a running one only if no other book started since.
- The kids connect gate waits for the profiles to be read.
- The car's kids shelf keeps its library for the offline scope and uses the
  session order; paused reads count as unreachable; member cards list API
  keys; the Change dialog uses the body cap.
…server (folded into removeConnection's hook)
…file here, else display_name, else username)

managerName takes the member's connection and this device's people (useDevicePeople,
devicePeople, useManagerName in the profiles store): when the owner's own sign-in to
that server is on this device, the copy uses the name of the profile holding it, the
name "Who's listening?" shows. Kids mode, the Household page, removing a person, the
password card and the sign-out warning all read it.
…403 kid_account)

The password card already says who signs a kid's account in on a new device. The page
also hands the password card its connection, so it names the owner as this device
knows them.
A person who leaves a household is signed out of what their old owner set up,
so their stored connection keeps the managed_by from before: the On this
device card then said "The stories <old owner> shares" on a profile no longer
in that household. kidsManager names no one while the kids connection needs a
reconnect; each card already reads its own profile's manager, which the new
page test pins across several profiles.
A short story's automatic download can finish before its stream reaches
playing (the snapshot still loading, or the engine just started with its
playing not yet in JS). The hot-swap to the local copy played on only from
playing, and iOS's load pauses the player, so a kid's single tap on a story
loaded it and stopped on Play with no error. The swap now plays on from the
listener's intent (wantsPlayback) too.
@KodeStar
KodeStar merged commit 3887987 into main Oct 14, 2026
1 check passed
@KodeStar
KodeStar deleted the feat/player-8b-1 branch October 14, 2026 08:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant