feat(console): give one recording the room to be several things - #148
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
~/utils/recordingTabsholds the list, the order and the whole argument in prose, andrecordingTabs.spec.tspins 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/uiTabsdrops the query parameter for the first tab, so/recordings/4711stays the canonical address rather than becoming a synonym for?tab=meeting.Three arrivals were weighed:
meetingis 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,channelsandstored_bytessit 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).
nullnever renders as0. Every track written before migration 0013 has all three null.trackFileFactsreturns three slots in one fixed order withnullin the ones nobody measured, anduseSayturns 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, and0 kBnever appears where nothing was weighed.12 440 000renders as12.4 MBand, in German, as12,4 MBwith 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
uiTabsreads the default from the first tab, could change which panel a bare address opens depending on who is looking.detailsalready 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
PUTreplacesNew capability, so it is designed rather than ported.
RecordingNamematchesRecordingTags' 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}/nameis a replace: a body without adescriptionmember storesnull, 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/sessionNamingis arranged so that no half body can be built:nameBodyFromtakes the whole draft and returns both members, always.sessionNaming.spec.tschecks the shape;recordingName.spec.tsmounts 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\nfolded — 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
blocksarray 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 whatpending_tracksis on the endpoint for, and~/utils/transcriptis where it becomes a sentence: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_availablecounts jobs whose audio is not deleted, so a session with no tracks answersfalseas well — the same answer to the endpoint, entirely different news to a reader.transcriptAudioErasedtakes 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.transcriptAudioErasedis 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
UiTabsdefers 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 intoRecordingTrackListwith them.The heading now takes the title when there is one and demotes the channel to a subordinate line, the way
channelNamingdemotes 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 underadmin/. No Python.recordings/index.vueuntouched.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=nonsensefalling 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 as48 kHz · 2 channels · 12.4 MBand as48 kHz · 2 Kanäle · 12,4 MB.Every colour is a role token, so
palette.spec.tsguards it in both themes; no<style>block anywhere; the skeleton kept itsmotion-reduce:.Checks
vitest run1578 passed, 51 files (+111 tests, 6 new files) ·lint·typecheck·build— all clean. Baseline onmainis 1467 in 45.