Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 216 additions & 0 deletions console/app/components/RecordingName.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
<script setup lang="ts">
/**
* What this meeting is called, and what was written about it.
*
* **Everybody who was in it sees this.** That is the difference between
* this and the tag editor below it, and it is the reason the two are not
* one component with a longer form: a tag is a private remark about a
* conversation other people were also in, and a title is the meeting's
* name. Anybody who was in the room may correct it, there is no history,
* and the form says so — somebody about to empty a field is about to empty
* it for four colleagues, and finding that out afterwards is not a
* reasonable way to learn it.
*
* **One button for two fields, because the endpoint replaces.** `PUT` on
* `/sessions/{id}/name` stores the pair it is given, and a member left out
* of the body is a member cleared. An interface with a Save beside the
* title would therefore be an interface that deletes the description
* whenever somebody fixes a typo in the name — silently, and with no way
* back. So there is one control, it submits the whole draft, and
* `~/utils/sessionNaming` is arranged so that no other body can be built.
*
* The shape of the interaction is `RecordingTags`', deliberately: a
* disabled-not-removed button, a live region that is in the DOM before it
* has anything to say, and the server's answer replacing the local text.
* What is not borrowed is the optimism. A tag appears before its round
* trip because the alternative is a chip that lags a keystroke; a title is
* already on screen in the box somebody typed it into, so there is nothing
* to show early and an optimistic heading would only be a heading that
* flickers back on a refusal.
*/
import { ApiError } from '~/utils/apiError'
import type { Message } from '~/utils/message'
import {
NAME_MAX_DESCRIPTION_CHARS,
NAME_MAX_TITLE_CHARS,
nameBodyFrom,
nameDraftFrom,
nameIsDirty,
nameRefusal,
nameWriteFailed,
sessionNamePath,
type SessionName,
} from '~/utils/sessionNaming'

const props = defineProps<{
sessionId: string
/** The pair the session endpoint returned. */
name: SessionName
}>()

const emit = defineEmits<{ saved: [SessionName] }>()

const api = useApi()
const say = useSay()

/** What is stored, as far as this component knows. Replaced by whatever a
* write answers with, never by what was typed: normalisation may have
* changed the text, and a form showing its own input back would keep
* displaying a title the database does not have. */
const stored = ref<SessionName>({ title: props.name.title, description: props.name.description })
const draft = ref(nameDraftFrom(props.name))

// The session can be refetched under this component -- a navigation to
// another recording reuses it -- and without this the boxes would hold the
// previous meeting's name. Guarded on the draft being clean, because
// overwriting half-typed prose with a payload that arrived for unrelated
// reasons is the one thing worse than showing a stale title.
watch(
() => props.name,
(fresh) => {
if (nameIsDirty(stored.value, draft.value)) return
stored.value = { title: fresh.title, description: fresh.description }
draft.value = nameDraftFrom(fresh)
},
)

const saving = ref(false)
/** What the live region says next. A key and its values, decided in
* `~/utils/sessionNaming` and worded in the locale files. */
const message = ref<Message | null>(null)

const dirty = computed(() => nameIsDirty(stored.value, draft.value))

// Generated rather than fixed: two of these on one page would share an id,
// and a `<label for>` pointing at two elements points at whichever the
// browser finds first.
const titleId = useId()
const descriptionId = useId()
const noteId = `${titleId}-note`

async function save() {
if (saving.value) return
const refusal = nameRefusal(draft.value)
if (refusal !== null) {
message.value = refusal
return
}
saving.value = true
message.value = null
try {
// The whole draft, both members. See the module: there is no shape
// here that can carry one of them.
const answer = await api<SessionName>(sessionNamePath(props.sessionId), {
method: 'PUT',
body: nameBodyFrom(draft.value),
})
stored.value = { title: answer.title, description: answer.description }
draft.value = nameDraftFrom(answer)
message.value = { key: 'recordings.nameSaved' }
// So the heading above the tabs stops disagreeing with the box that
// was just saved. The page owns what it renders; this says what the
// server stored and lets it decide.
emit('saved', { title: answer.title, description: answer.description })
} catch (cause) {
message.value = nameWriteFailed(cause instanceof ApiError ? cause.status : 0)
} finally {
saving.value = false
}
}
</script>

<template>
<section
class="rounded-2xl border p-5"
:style="{ borderColor: 'var(--border)', background: 'var(--surface)' }"
>
<h2 class="text-base font-semibold">{{ $t('recordings.nameHeading') }}</h2>
<p class="mt-1 max-w-2xl text-sm" :style="{ color: 'var(--text-muted)' }">
{{ $t('recordings.nameNote') }}
</p>

<form class="mt-4 flex flex-col gap-4" @submit.prevent="save()">
<div>
<label class="text-sm font-medium" :for="titleId">
{{ $t('recordings.nameTitleLabel') }}
</label>
<input
:id="titleId"
v-model="draft.title"
type="text"
:maxlength="NAME_MAX_TITLE_CHARS"
:disabled="saving"
:aria-describedby="noteId"
:placeholder="$t('recordings.nameTitlePlaceholder')"
class="mt-1 w-full rounded-lg border px-3 py-1.5 text-sm"
:style="{
borderColor: 'var(--control-border)',
background: 'var(--surface-raised)',
color: 'var(--text)',
}"
>
</div>

<div>
<label class="text-sm font-medium" :for="descriptionId">
{{ $t('recordings.nameDescriptionLabel') }}
</label>
<!-- A textarea rather than a second input: a description keeps its
own line breaks, which is what makes an agenda an agenda. The
API keeps them too and collapses only the title. -->
<textarea
:id="descriptionId"
v-model="draft.description"
rows="6"
:maxlength="NAME_MAX_DESCRIPTION_CHARS"
:disabled="saving"
:aria-describedby="noteId"
:placeholder="$t('recordings.nameDescriptionPlaceholder')"
class="mt-1 w-full rounded-lg border px-3 py-2 text-sm"
:style="{
borderColor: 'var(--control-border)',
background: 'var(--surface-raised)',
color: 'var(--text)',
}"
/>
</div>

<!-- Said before the button rather than after the mistake. Both
halves are surprises: the write replaces, and the result is not
private. -->
<p :id="noteId" class="text-xs" :style="{ color: 'var(--text-muted)' }">
{{ $t('recordings.nameReplaceNote') }}
</p>

<div class="flex flex-wrap items-center gap-3">
<!-- Disabled while it works rather than replaced: a control that
unmounts itself when pressed drops the keyboard to the top of
the document. Disabled while nothing has changed, too, so that
an available Save means there is something to save. -->
<button
type="submit"
class="rounded-lg px-3 py-1.5 text-sm font-medium transition-colors hover:bg-[var(--surface-raised)] disabled:opacity-60"
:style="{ color: 'var(--action)' }"
:disabled="saving || !dirty"
>
{{ saving ? $t('recordings.nameSaving') : $t('recordings.nameSave') }}
</button>
<span v-if="dirty && !saving" class="text-xs" :style="{ color: 'var(--text-muted)' }">
{{ $t('recordings.nameUnsaved') }}
</span>
</div>
</form>

<!-- Always in the DOM, so that a screen reader is watching it before it
has anything to say. A live region added at the moment of the
announcement announces nothing. -->
<p
class="mt-2 min-h-5 text-xs"
:style="{ color: 'var(--text-muted)' }"
role="status"
aria-live="polite"
>
{{ message ? say(message) : '' }}
</p>
</section>
</template>
156 changes: 156 additions & 0 deletions console/app/components/RecordingTrackList.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<script setup lang="ts">
/**
* One speaker at a time: their audio, their spectrogram, and what their
* file is.
*
* **Why this is not the transport on the meeting tab.** That one plays the
* conversation — every speaker on one clock, which is the only way a
* discussion makes sense. This answers a different question: *what did
* this one person say*, which the shared transport cannot reach without
* muting everybody else first. Two questions, two tabs.
*
* **The file's own measurements live here rather than in a metadata
* table.** A sample rate, a channel count and a stored size are only ever
* asked about one way — "why does this track sound wrong" — and that is
* asked while listening to that track. Set beside the audio they describe
* they are an answer; collected into a table on a tab of their own they
* are a row of numbers with nothing to compare against.
*
* **A measurement that was never taken is an em dash, never a zero.** All
* three are null for every track written before migration 0013.
* `trackFileFacts` keeps the three slots and puts the absence in the one
* that is missing, so a gap reads as a gap rather than as `0 kB`.
*
* The speech measurements — how much audio, how much of it was speech, how
* many segments — are deliberately *not* here. They are on the meeting
* tab, beside the transport, because "who talked how much" is a fact about
* the conversation. This tab measures the files.
*/
import {
audioUrl,
trackFileFacts,
trackLabel,
type RecordedSession,
} from '~/utils/recordings'

const props = withDefaults(
defineProps<{
session: RecordedSession
/**
* Whether there is still audio behind these tracks.
*
* `false` once retention has swept the recordings, and then the
* players and the pictures go and the measurements stay. That is not
* a technicality: the notice above the tab bar 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. Mounting an `<audio>` for a file
* that is gone would give eight speakers eight failing players and no
* explanation.
*/
playable?: boolean
}>(),
{ playable: true },
)

const say = useSay()

// The public base, never the internal one: an `<audio>` element loads in a
// browser, and the cluster address a server-side render would use
// addresses nothing the listener can reach.
const base = useRuntimeConfig().public.apiBase

/** Playback position per track, so each spectrogram can show its own
* playhead without the tracks having to know about each other. */
const positions = ref<Record<string, number>>({})
const players = new Map<string, HTMLAudioElement>()

/**
* A ref callback per track, cached so its identity is stable.
*
* An inline arrow returns a fresh closure on every render, and the
* template calls it on every render. Vue treats a new ref function as a
* new binding, so a `timeupdate` — four a second, per playing track — tore
* down and re-seated *every* track's ref and rebuilt every `<audio>` on
* the page. `MultiTrackPlayer` caches its binders for exactly this reason.
*/
const binders = new Map<string, (el: unknown) => void>()

function bindPlayer(trackId: string) {
let existing = binders.get(trackId)
if (!existing) {
existing = (el: unknown) => {
// Duck-typed rather than `instanceof HTMLAudioElement`, so the check
// holds wherever this runs.
if (el && typeof (el as HTMLAudioElement).play === 'function') {
players.set(trackId, el as HTMLAudioElement)
} else {
players.delete(trackId)
}
}
binders.set(trackId, existing)
}
return existing
}

function onTime(trackId: string, event: Event) {
positions.value = {
...positions.value,
[trackId]: (event.target as HTMLAudioElement).currentTime,
}
}

/** Clicking a spectrogram moves that track's own player to that moment. */
function seek(trackId: string, seconds: number) {
const player = players.get(trackId)
if (!player) return
player.currentTime = seconds
positions.value = { ...positions.value, [trackId]: seconds }
}
</script>

<template>
<ul class="flex flex-col gap-4">
<li
v-for="track in props.session.tracks"
:key="track.discord_user_id"
class="rounded-xl p-4"
:style="{ background: 'var(--surface-raised)' }"
>
<h3 class="text-sm font-medium">{{ trackLabel(track) }}</h3>

<!-- Named, because eight identical "audio" controls in a row tell a
screen-reader user nothing about which speaker they are on.
`preload="none"`: nothing is fetched until somebody presses play
on this particular speaker. -->
<template v-if="props.playable">
<audio
:ref="bindPlayer(track.discord_user_id)"
:src="audioUrl(base, props.session.id, track.discord_user_id)"
class="mt-3 w-full"
controls
preload="none"
:aria-label="$t('recordings.trackAlone', { name: trackLabel(track) })"
@timeupdate="onTime(track.discord_user_id, $event)"
/>

<TrackSpectrogram
:session-id="props.session.id"
:discord-user-id="track.discord_user_id"
:position="positions[track.discord_user_id] ?? null"
@seek="(seconds) => seek(track.discord_user_id, seconds)"
/>
</template>

<!-- What the file is, under the thing it describes. Three slots
always, in one order, so a missing measurement is a gap in a row
rather than a shorter row somebody has to count. -->
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1 text-xs">
<div v-for="fact in trackFileFacts(track)" :key="fact.labelKey" class="flex gap-1.5">
<dt :style="{ color: 'var(--text-muted)' }">{{ $t(fact.labelKey) }}</dt>
<dd class="tabular-nums">{{ say(fact.value) }}</dd>
</div>
</dl>
</li>
</ul>
</template>
Loading
Loading