Skip to content

Playlists (kind 30425): codec and store, and the playlist screens on both devices - #1

Draft
StateAntigen wants to merge 6 commits into
feat/napstr-cover-30427from
feat/napstr-playlist-codec-30425
Draft

StateAntigen wants to merge 6 commits into
feat/napstr-cover-30427from
feat/napstr-playlist-codec-30425

Conversation

@StateAntigen

Copy link
Copy Markdown
Owner

Stacked on lnbits#10, so this is a draft on purpose. The base is
feat/napstr-cover-30427 (94bdfe2), and these are the four commits on top of it.
Nothing here is for merge until that PR lands — at which point this can be retargeted
to main and the diff stays exactly as it is. It is stacked rather than opened
against main because the two are genuinely entangled: replaying these four commits
on main today conflicts in 19 files, one of them a modify/delete of
src-tauri/src/playback_bridge.rs, which only exists in lnbits#10.

What this is

Playlists, end to end: the kind 30425 codec and store, the control messages the phone
reads and writes them with, and the screens on both devices.

  • NIP-NAPSTR-PLAYLIST.md carries the spec these implement, and PROTOCOL.md the
    companion's side of it.
  • playlist.rs holds the validator, the publisher, the SQLite store
    (playlists / playlist_tracks, paged) and the withdrawal body. Playlists are
    keyed by coordinate — author and d together — so two authors may use one playlist
    id without replacing each other, and a request naming only the id resolves when the
    store holds exactly one row for it.
  • playlistsContaining answers which playlists hold a file, by coordinate, from an
    index on playlist_tracks.file_id rather than a page of members per playlist. It is
    a read, so a read-only pairing may ask it.
  • RemotePlaylistSummary.first_file_id is the one artwork hint a list row gets. The
    artist and album deliberately do not travel with it: carrying them put a full page
    of 100 names at 288 KB against a 256 KB control frame, which
    a_full_playlist_page_fits_in_one_control_frame refused, so a companion resolves
    them from the library instead and a row whose first file the computer does not hold
    keeps its placeholder.
  • The phone: clicking a playlist opens the playlist — a screen shaped the way the
    album sheet is, with a title bar and a tool row that pin as the artwork scrolls
    away — and changing it is four small tools (+ Add, Edit, Sort,
    Name & details) rather than what opening it does. The edit screen is a list you
    drag; Add is a full-screen sheet over the library, the phone's recent tracks and
    Liked Songs. Nothing is stored on the phone: every write goes to the computer, so
    the two can never hold two versions of one playlist.
  • The desktop window gets the same codec, and loses the private-playlist UI it should
    not have had.

Two of these are policy rather than code, and worth your eye:

  1. Search words belong to the author. content.tags is the whole answer whenever
    it says anything; suggested words are offered only when it says nothing, are never
    written back into it, and a wordless playlist is a valid answer that clients must
    keep declining for its author.
  2. A private playlist is local only. It is never published in any form, not even
    encrypted, because a relay sees the coordinate, the size and the edit time;
    playlist_event_builder refuses one rather than trusting its caller. The
    phone-side store that would hold them is not built yet.

And one that needs a decision rather than a review: kind 30425 is a new kind. What
is the process here — does the NIP want a review of its own before the implementation
is considered, and is the kind-commons question yours to settle or one I should raise
upstream?

Testing

  • tests/browser/napstrfy-playlists.spec.mjs and tests/browser/playlists.spec.mjs:
    the picker's toggles, dragging a row into order, adding and removing members and the
    minus that does it, publishing with and without words, deleting as withdraw-or-forget,
    the two ways a list row draws artwork, and on the phone the pinned title bar, the tool
    row and the play disc that stays in reach.
  • The store, codec and wire tests pass across remote-protocol, src-tauri and
    android/src-tauri, including the control-frame size bound above.
  • Whole suite: browser 93, unit 22, i18n 775 keys across all ten catalogues, and
    svelte-check clean for both apps.

The first commit in the stack is the playback handoff, which lnbits#10 does not carry yet. It
is here because this branch was cut from the cover work rather than because it belongs
to playlists — say the word and I will lift it out into its own PR.

Choosing a playback target only reassigned Napstrfy's own target: picking "this
computer" left the phone playing, and nothing carried the track, the queue or the
position across. A handoff is now one atomic command - the window reads the queue
and the position it will resume from, stops, and answers with that state - so the
receiving device picks up at the same second rather than at the start of the
track, and the sending device stops only once the other side has accepted.

- `PlaybackCommand::Handoff`, `PlayTrack{file_id, queue, position_ms}`, and a
  `track`/`queue` pair on the state answer, all sized to fit one control frame.
- The phone queues a handoff, pauses only on success, returns the target to its
  previous value on failure, and gives up after five minutes; a track the computer
  does not have is fetched first and the handoff completes when it arrives.
- Podcasts are refused, because their position belongs to a feed, not a track.
- Volume belongs to whoever is playing. The window no longer overwrites the
  native player's volume on the next track, forwards its own volume changes to
  the phone, and adopts the level the phone sets.

The playlist wire surface (`RemotePlaylist*`, the playlist requests and responses,
the by-id lookup) and its `PROTOCOL.md` sections land here as well, because
`remote-protocol` is one artefact; the codec, the store and the private-playlist
rule that use them follow in the next commit.
…ly rule

Playlists were a name in the catalogue and nothing behind it. This is the
groundwork: a codec validated against the spec's own example, a store the wires in
the previous commit can read from, and the rules the rest of the work will follow.

- `playlist.rs` holds the validator and the publisher for kind `30425`, the
  SQLite store (`playlists`/`playlist_tracks`, paged), and the withdrawal body.
- Playlists are keyed by coordinate - author and `d` together - so two authors may
  use one playlist id without replacing each other, and a request that names only
  the id resolves when the store holds exactly one row for it.
- Search words belong to the author: `content.tags` is the whole answer whenever
  it says anything, suggested words are offered only when it says nothing, they
  are never written back into it, and a wordless playlist is a valid answer that
  clients must keep declining for its author.
- Private playlists are local only. They are never published in any form, not even
  encrypted, because a relay sees the coordinate, the size and the edit time;
  `playlist_event_builder` refuses one rather than trusting its caller.
- `publish_playlist`, `withdraw_playlist` and `new_playlist_id` are the desktop's
  commands; the phone reads playlists over the existing Iroh control frame.
- `NIP-NAPSTR-PLAYLIST.md` carries the spec these implement, and `PROTOCOL.md`
  the companion's side of it.
A playlist could be stored but not really used. Clicking one jumped straight into an
editor, order could only be nudged one row at a time with arrows, and the track menu's
"Add to playlist" was a disabled row saying "Coming soon". This gives a playlist a face
on both devices, and the two things one actually does with one.

On the phone, clicking a playlist opens the playlist: a screen shaped the way the album
sheet is, with the artwork it opens with stretched and blurred behind it, a title row
whose round button plays it, and the members in order with the track menu on each row
this phone holds. Changing it is four small tools rather than what opening it does -
`+ Add`, `Edit`, `Sort`, `Name & details` - and the row's `...` menu is gone, with its
two entries split: editing is a tool, and withdraw-or-delete belongs on the screen that
carries the playlist's own description. A new playlist opens there too, because one with
no name cannot be saved at all.

The edit screen is a list you drag. It is titled "Edit playlist", keeps its Save in the
corner rather than at the end of the screen, and each row carries the track's artwork, a
round minus that takes it out, and a grip. Reordering is a pointer drag that moves the
member as it goes, so what is under the finger is the playlist; only the grip takes the
gesture, because a row that owned it would leave the list unscrollable by touch. The
inline "add a track" search is gone, since `+ Add` is the same thing without a draft
around it.

A track is added from where the track is: that menu's row opens a picker of the
playlists, each with a circle-plus that fills and ticks when the playlist already holds
it. Ticking appends to the end, unticking removes it and renumbers the rest, and both
are written through rather than drafted, because a toggle is one finished edit.

On the wire:

- `PlaylistsContaining { file_id }` answers which playlists hold a file, by coordinate.
  Membership is an indexed lookup on `playlist_tracks.file_id` rather than a page of
  members for every playlist in the list, which is what a picker can afford. It is a
  read, so a read-only pairing may ask it.
- `RemotePlaylistSummary.first_file_id` is what lets a list row draw the album the
  playlist opens with. Only the id travels: carrying the artist and album with it put a
  full page of 100 names at 288 KB against a 256 KB control frame, which
  `a_full_playlist_page_fits_in_one_control_frame` refused, so a companion resolves
  those off the library instead and a row whose first file the computer does not hold
  keeps its placeholder.
- A playlist is a queue now: playing one resolves the members this phone holds and
  plays them in the playlist's order.

The desktop window loses the private-playlist UI it never should have had. Private
playlists are meant to stay on the device, so the checkbox, the badge and the note about
them are gone; `private` still round-trips through the store and the wire and the
publisher still refuses one, but the phone-side store that would hold them is not built
yet.

`NIP-NAPSTR-PLAYLIST.md` follows: the artwork section (a file id and never a URL, not a
member, never carried by the `x` tag, and the order a client resolves a picture in), the
rule that naming no search words is a valid answer clients must keep declining for its
author, and the publisher rule relaxed from refusing to warning, since the choice of no
words is the author's to make.

Covered by `tests/browser/napstrfy-playlists.spec.mjs` (10) and
`tests/browser/playlists.spec.mjs` (9) - the picker's toggles, dragging a row into order,
the minus, playing a playlist, and the two ways a row draws artwork - alongside the
store, codec and wire tests, with the new messages in all ten catalogues.
The playlist screens were a page of their own, and it showed: a background that
was not the album's, a body that could be scrolled sideways, and a tool row that
pinned wherever sticky happened to stop. They are the album sheet's own overlay
now - the same glow, the same scrim, the same round arrow, the same scroller - so
a playlist and an album are one kind of screen rather than two that look alike.
`.playlist-page`, `.playlist-glow`, `.playlist-editor`, `.playlist-back` and the
rest of that page's rules are deleted rather than overridden.

That scroller's top padding is what holds the artwork clear of the floating
arrow, and a sticky row can never rise above the box it scrolls in: `top: 52px`
was pinning the tool row at 110px, with the page showing through the gap beneath
it. The padding moves onto the artwork, which is what it was there for, and the
row pins where it says it does.

The tool row pins to a bar of its own, since the album sheet's header is a
floating arrow with no bar at all. The bar fades in as the tools reach the top -
the playlist's name alone, centred, in bold - and its background reaches the very
top of the screen, because the sheet is edge to edge and stopping at the inset
left a band of rows showing under the status bar. The play disc the title row
carries is held with its middle on the bar's lower edge, half over the bar and
half over the tools, so the one control worth reaching for is never the one that
scrolled away; the in-flow button stays the one a keyboard and a screen reader
reach, and the pinned copy is out of the flow so neither bar loses height to it.

A row is read the way a playlist is: artwork, title, artist, then the album,
which took the place of the position number. The count and the size sit on the
meta line - `14 Tracks · 560 MB · Draft`, with a trailing `+` when a member
nothing here can resolve is not in the sum. A running time is still not offered,
because neither the catalogue nor the companion protocol carries a track's
duration. Long titles and albums are cut off rather than stretching the row and
giving the sheet a horizontal scrollbar, which is what the album line was doing.

The add screen is a sheet of its own, with the computer's library, the phone's
recent tracks and Liked Songs under three tabs, a page at a time, and the three
chrome rows are excluded from the flex shrinking that let a long list cover them.
Its toggle is inside the row's button - beside it the circle was dead space, and
the tap had to land to its left - and the circle-and-tick draws its own stroke,
which nothing else supplies in a full-screen list.

The smaller things that were wrong:

- The nav loses its hairline and its solid panel: its own colour fades in from
  nothing, with a taller wash behind the player bar. That wash has to be an
  element of its own, since the nav floats above the player's card and anything
  inside it would be painted on top of it.
- The WebView's translucent blue flash over whatever is tapped is a neutral wash,
  because the app draws its own press states.
- The catalogue keys the playlist screens had been using were never added at all:
  twelve strings, now in all ten catalogues.

`tests/browser/artwork.spec.mjs`'s queue test asserted that one album had never
been asked about, which the home screen's album shelves can ask about quite
legitimately - which albums ride in the first batch is timing, and it varied
between runs of one build. It asserts the tiles instead: the twelfth row draws a
cover and the twentieth keeps the placeholder, which is the same fact without the
confounder.

No host or protocol change: the desktop window and the companion wire are
untouched, so the built installer and an already-paired phone both stand.
The Playlists page listed only the rows this computer holds, so a public
playlist - including one this identity published from somewhere else - was
invisible, and Napstrfy inherited that same list. This adds the reading half
of the kind 30425 codec.

The reader (`network.rs::read_public_playlists`) asks two questions, and both
have to be answered before anything is stored or forgotten:

  {"kinds":[30425],"#t":["napstr-playlist"],"limit":500}
  {"kinds":[30425],"#t":["napstr-playlist"],"authors":[<self>],"limit":500}

The marker is the only supported discovery path and the answer is bounded the
way a catalogue browse is; this identity's own coordinates are asked for
separately, because a page limit must never be what decides that a playlist of
ours is missing. Every event goes through `playlist::playlist_event`, the same
validator the publishing half is checked against, and per coordinate the newest
valid event wins - so a withdrawal beats every older revision wherever a relay
still serves one, and an event that is not a playlist of ours cannot shadow one
that is.

What the look finds of other authors is stored here through
`playlist::store_from_relay`, with the name their profile carries; what it finds
of ours is deliberately not stored. Restoring one or withdrawing one is a
decision about what this computer holds, and two installations of one identity
with different local state would otherwise make it for each other. The relays'
answer for our own identity is held in memory and the page offers Restore and
Withdraw only after a look that actually happened - "nobody looked" is not "the
relays do not have this".

A withdrawal read from a relay is remembered against its coordinate, so a relay
that never received it cannot talk this computer back into the older revision,
and the same rule stops a stored revision from being replaced by an older one.
A withdrawal of our own is remembered but never deletes a row here: the
installation that signed it may not be this one, and a copy can hold edits
nobody has published.

Saving a revision of somebody else's coordinate now files a copy under a fresh
uuid (`playlist::file_revision`), for both windows and phones, so nothing ever
signs for a coordinate this identity does not own; and `withdraw_playlist`
refuses a playlist this computer does not hold.

The page lists two tiers, newest first inside each: ours (held locally, on the
relays only, only on this computer, or a draft) and everybody else's, read-only
and opened with a Save a copy. Members carry per-member availability from the
ordinary kind 30422 heartbeats and an unavailable member is drawn rather than
dropped, because its position is part of what the playlist means.

Eight new messages, translated in all ten catalogues.
A playlist is named by its author and its id, and an identity can only ever
sign its own coordinates. The phone had no way of knowing which rows were its
computer's, so a public playlist somebody else published looked exactly like a
draft: it opened into the edit menu, and saving it would have signed a revision
of a coordinate this identity does not own. The host files that as a copy rather
than writing to somebody else's coordinate, so nothing was ever published
wrongly - but the phone offered an edit that turned into something else,
silently, and at library scale the picker would have filled up with other
people's playlists long before it reached the handful a person may change.

The host now says who it is. `status` carries `pubkey`, the desktop's own Nostr
key, which is the only thing the phone can compare a row's author against - and
the phone reads an empty key as "nothing here is mine", because the action that
would be wrong to offer is the write. `playlists` answers an `ownOnly` question
for the same reason: the paired desktop's own rows, and the rows with no author
at all, which are the ones only that desktop has ever written down
(`playlist::list_owned_by`). It is a filter and not permission: whatever it
answers, the host still refuses to sign a coordinate it does not hold.

The phone draws two tiers, ours first and named as ours, then everybody else's
under the name their profile carries and marked Read-only. Somebody else's
playlist opens and plays like any other - the members are the members - and its
tool row is a single Save a copy: no editor, no details, no way to reach the
screens that would write to it. Saving the copy is explicit, and it is the copy
rule made visible: a fresh uuid, no author, a draft, filed under this computer
and listed as its own from then on. The picker a track's menu opens asks
`ownOnly`, so it offers the lists a track can go in rather than every list that
exists.

A playlist also has to survive a train. The list the phone was last shown is
kept, and the members of the playlists it has opened, so a playlist opens - and
plays - with the computer out of reach: the members are read from that copy, and
the queue is built from the files this phone actually holds. A member it does not
hold stays in the list, saying so, and is left out of the queue rather than
stopping it. The library's own pages are seeded per launch now
(`library.shuffleSeed`), so a browse is one order walked from beginning to end -
a different order on each phone and each launch, and the same order across the
pages of one browse - while a search and the add sheet still ask in the stored
order, where the point is to find something rather than to be shown everything.
The asking itself is untracked, so only the connection coming back can re-run
it: an effect that read the list it had just written re-ran until Svelte gave up
and the page stopped reacting altogether.

A member of an open playlist is drawn as the track it is - the same artwork tile
the music page uses, rather than a 44px thumb of it - because the editor is
where a member is moved and removed, not a different kind of object.

PROTOCOL.md documents `shuffleSeed`, `ownOnly` and `status.pubkey`, including
what a companion has to do when a desktop does not answer them.

Five new phone specs cover the tiers, the read-only sheet and the copy it makes,
the question the picker asks, a playlist opened and played with the host
unreachable, and the seeded browse. One new message, translated in all ten
catalogues.

Artifacts: `Napstr_0.1.0_x64-setup.exe` 24.76 MB and `Napstrfy-0.1.0-arm64.apk`
43.10 MB, the APK signed with the same debug certificate so it upgrades in place.
Host tests 108 (+2 ignored), protocol 19, phone 21, browser suite 102, unit 22,
`svelte-check` clean on both apps.

This branch has not been deployed

No deployments
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