Skip to content

feat(console): give one recording the room to be several things - #148

Merged
TheMeinerLP merged 1 commit into
mainfrom
feat/console-recording-detail
Aug 23, 2026
Merged

feat(console): give one recording the room to be several things#148
TheMeinerLP merged 1 commit into
mainfrom
feat/console-recording-detail

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

Divides /recordings/[id] into four tabs, gives the recording a title and a description somebody can write, renders the transcript from #142, and puts each track's audio measurements next to the track they describe.


The design argument

The page was one long scroll: a metadata card, a tag editor, a transport for the whole meeting, an administrator's panel, and then one <audio> and one spectrogram per speaker. Nothing on it was wrong. The problem was that every part of it was worth having and no two of them answered the same question, so a reader who came to press play scrolled past a tag editor, and a reader who came to fix a typo in the tags scrolled past eight spectrograms.

Since #141 this is also the only place a recording can be heard. Everything the list stopped showing had to land here, and adding it to the scroll would have made the scroll worse.

So the division is by question asked, not by kind of thing:

  • The meetingwhat was this, and let me hear it. The multi-track transport, the protocol link, what was written about the meeting, and who was in the room including the people with no audio.
  • Trackswhat did one person say, and what is in this file. One player and one spectrogram per speaker, with the file's own measurements underneath.
  • Transcriptwhat was said.
  • Detailsthe things somebody writes down here. Title, description, the reader's own tags, and the re-queue panel.

~/utils/recordingTabs holds the list, the order and the whole argument in prose, and recordingTabs.spec.ts pins it — including that no tab is disabled and that the first one is the default, because both of those are load-bearing for links that already exist in other people's documents.

Which tab a bare address opens, and why

meeting. ~/utils/uiTabs drops the query parameter for the first tab, so /recordings/4711 stays the canonical address rather than becoming a synonym for ?tab=meeting.

Three arrivals were weighed:

  • From the recordings list. feat(console): make the recordings list a list again #141 took playback off that page on purpose. The row already showed the date, the channel, who was recorded, whether a protocol exists and the reader's tags. The one thing it withheld is the audio, and the click is somebody coming for it.
  • From a protocol link. They have read the words. What a document cannot give them is the sixty seconds it was written from.
  • From an announcement in Discord. They know nothing yet, and meeting is the tab that says what this was: the description, the protocol link, the roster.

Transcript is the real rival and loses twice. The words are already in the protocol document, which is where most people read them — the endpoint's own module docstring makes that point. And it is the one panel whose cost is paid on arrival rather than on a click, so making it the default would charge every listener for a document they came here not to read. It is one query parameter away, which is the entire point of a tab having an address.

Metadata is not a tab

The brief asked for audio-file metadata under a metadata tab. It is all there, in full, and it is not a tab — the substance was kept and the container was not.

Session facts went up, above the bar. Started, ended, length, recorded speakers. Every tab is about that meeting, and somebody reading the transcript should not have to leave it to find out when the meeting was. A tab would have hidden the page's own identity behind a click.

Audio measurements went down, into the track. sample_rate, channels and stored_bytes sit in the row of the track they describe, under its player and its spectrogram. A sample rate is only ever asked about one way — why does this one sound wrong — and that question is asked while listening to that track. Set beside the audio it explains, it is an answer; collected into a table three tabs away, it is a column of numbers with nothing to compare against.

That leaves a clean split the two tabs can be named for: the meeting tab measures the conversation (how much audio, how much of it was speech, how many segments, per speaker, beside the transport), the tracks tab measures the files (kHz, channels, size).

null never renders as 0. Every track written before migration 0013 has all three null. trackFileFacts returns three slots in one fixed order with null in the ones nobody measured, and useSay turns that into the em dash this console already uses for an absence — so a gap reads as a gap rather than as a shorter row somebody has to count, and 0 kB never appears where nothing was weighed. 12 440 000 renders as 12.4 MB and, in German, as 12,4 MB with the protected space DIN 5008 asks for.

Where the re-queue panel went, and why not a tab

Details. It renders nothing at all for anybody who does not administer the guild, so a tab for it would be an empty tab for almost every reader.

Worse than empty: it discovers its own audience from an HTTP status after a round trip. A tab for it would have to appear late — which moves every tab beside it, and, because uiTabs reads the default from the first tab, could change which panel a bare address opens depending on who is looking. details already means "the things you change about this recording", and re-running a transcription is the largest of them. Its self-hiding is untouched.

Editing a title and a description is one control, because PUT replaces

New capability, so it is designed rather than ported. RecordingName matches RecordingTags' interaction rules — a button disabled rather than removed while it works, a live region that is in the DOM before it has anything to say, the server's answer replacing what was typed — and does not borrow its optimism, because a title is already on screen in the box it was typed into and an optimistic heading would only flicker back on a refusal.

The rest is about the endpoint. PUT /api/sessions/{id}/name is a replace: a body without a description member stores null, correctly and deliberately. The failure that follows is a form which saves a title and silently deletes the paragraph underneath it — for everybody who was in the meeting, with no history to get it back from. It is the kind of bug that passes review, because the code that causes it reads like care: only send what changed.

So there is one Save for both fields, and ~/utils/sessionNaming is arranged so that no half body can be built: nameBodyFrom takes the whole draft and returns both members, always. sessionNaming.spec.ts checks the shape; recordingName.spec.ts mounts the component and reads the body that actually leaves, because a module cannot prove that a template used it.

Two more things the form says out loud, before the mistake rather than after it: saving writes both fields, and this is not private — a title is one per session and anybody who was in the meeting can change it, unlike the tags directly below. That asymmetry is sturnus.console.naming's, and a reader deciding what to type needs to know which of the two they are in.

The client mirrors the server's normalisation — NFC, a title collapsed to one line, a description keeping its paragraphs, \r\n folded — so that a length can be checked before a round trip and so Save is unavailable for text that is already stored. The server still decides, and its answer replaces the draft.

The three empty states, and the fourth that is not empty

An empty blocks array arrives for three entirely different reasons, and a tab that met all three with "nothing here" is a tab people report as broken. That is what pending_tracks is on the endpoint for, and ~/utils/transcript is where it becomes a sentence:

  • Still recording. "This meeting is still being recorded. Transcription starts when the session closes, so there is nothing to read yet." Its jobs are not enqueued until it closes.
  • Still decoding. "Still being transcribed. 2 speakers have not been through the transcriber yet. The words appear here once it has finished." A wait, and the count is a plural param so each language picks its own branch.
  • Nobody spoke. "Nothing was said that could be heard. Every recorded speaker has been through the transcriber, and none of it produced any words." A finished result, not a wait.

A transcript that has words and is still missing some says so too, above the blocks — it otherwise reads as finished, and somebody concluding from it that a colleague said nothing has been misled by an omission the page could have mentioned.

And audio_available: false, which is not an empty transcript at all. The retention window is about the recording and not about the minutes. It is stated once, above the tab bar, because it explains the state of two tabs and has to be legible from the third — and in the ordinary border, not the danger role, because a recording deleted when its window closed is the system keeping a promise. The meeting tab replaces the transport with "There is nothing left to play here" and a link to the transcript; the tracks tab does the same and keeps the list, with the measurements and without the players or the spectrograms. That last part is deliberate: the notice promises that what was written from a recording survives it, and a list that dropped the measurements along with the audio would make that sentence false on the one page it is displayed on.

A session nobody consented to is not an erasure. audio_available counts jobs whose audio is not deleted, so a session with no tracks answers false as well — the same answer to the endpoint, entirely different news to a reader. transcriptAudioErased takes the track count for that reason, and both tabs ask "nobody consented" first. Without it, the console would have told somebody their recording had been deleted when it had never been made.

transcriptAudioErased is equally strict the other way: a transcript that has not arrived, or whose request failed, is unknown and never "gone". A page that reported data loss because a request timed out would be saying the worst thing this page is capable of saying.

What the fetch does, and what stays lazy

Both the session and its transcript are fetched on arrival, in parallel, under one key. The transcript response is the only thing that says whether this session still has any audio, and two tabs have to know that before anybody clicks them — a player about to 404 has to say why rather than look broken.

What UiTabs defers is the expensive half, and it matters more here than anywhere else in the console: the tracks tab is one <audio> and one spectrogram request per speaker, so an eight-speaker meeting would otherwise fire sixteen requests at somebody who came for the protocol link. The transcript's hundreds of block nodes are deferred with it. A panel that has been opened stays mounted, so switching back keeps the scroll position and a half-typed description.

A transcript that fails to load is not a failed page: the session is still here, the audio still plays, the tags still save, and the transcript tab says what happened and offers a retry.

Everything that survives

The 404-is-"not yours" card and its reasoning; the loading skeleton from #119, reshaped to header / bar / panel; the back link; the unrecorded-participants sentence; the re-queue panel's self-hiding; the spectrogram's click-to-seek; MultiTrackPlayer's solo and mute. The per-track ref binders are still cached — the comment explaining why moved into RecordingTrackList with them.

The heading now takes the title when there is one and demotes the channel to a subordinate line, the way channelNaming demotes an unresolved id in a list row. Where a meeting happened is still a fact and is still shown; it just stops being the meeting's name once the meeting has one.

Out of scope

No export tab (a sibling PR is building guild_export_target). Nothing under admin/. No Python. recordings/index.vue untouched.

Verified against a running page

Rendered end-to-end against a stub API, in both languages, in every state this PR is about: the default tab, each of the three others by URL, ?tab=nonsense falling back to the meeting, audio erased, audio never recorded, a pending transcript, a silent one, a named session and an unnamed one. Only the selected panel is in the server-rendered HTML, which is the lazy mounting doing its job. bo's three unmeasured facts render as three em dashes; anna's render as 48 kHz · 2 channels · 12.4 MB and as 48 kHz · 2 Kanäle · 12,4 MB.

Every colour is a role token, so palette.spec.ts guards it in both themes; no <style> block anywhere; the skeleton kept its motion-reduce:.

Checks

vitest run 1578 passed, 51 files (+111 tests, 6 new files) · lint · typecheck · build — all clean. Baseline on main is 1467 in 45.

The recording page was one long scroll: a metadata card, a tag editor, a
transport, an administrator's panel, and then one player and one
spectrogram per speaker. Every part of it was worth having and no two of
them answered the same question, which is the shape a tab bar is for.

Four tabs. `meeting` is the transport, the protocol link, what was
written about the meeting and who was in the room; `tracks` is one player
and one spectrogram per speaker with the file's own measurements beside
the audio they describe; `transcript` is the words from the new endpoint;
`details` is the title, the description, the reader's tags and — for an
administrator — the re-queue panel.

There is no metadata tab. The session's own facts sit above the bar,
because every tab is about that meeting and somebody reading the
transcript should not have to leave it to find out when the meeting was.
The audio measurements went the other way, into the row of the track they
describe: a sample rate is the answer to "why does this one sound wrong",
and that is asked while listening to that track.

`meeting` is first, so a bare address opens it and `uiTabs` keeps the
plain URL plain. The transcript is one query parameter away, which is the
point of a tab having an address.

Editing a title and a description is one control for two fields, because
`PUT /sessions/{id}/name` replaces: a form with a Save beside the title
would delete the description whenever somebody fixed a typo in the name.
`~/utils/sessionNaming` is arranged so no half body can be built.

Three empty states are told apart rather than collapsed: audio erased by
retention with the transcript surviving it, a transcript still decoding,
and a meeting in which nobody said anything the engine could hear. A
session nobody consented to reports no available audio too, and says so
in its own words rather than claiming a deletion that never happened.
@TheMeinerLP
TheMeinerLP merged commit 02c9bd6 into main Aug 23, 2026
10 checks passed
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