feat(web): record the screen or the voice from a comment, and play it there

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 06:58:18 +02:00
parent c27aa4b1d7
commit 909edc9f4d
7 changed files with 370 additions and 35 deletions

View File

@@ -75,6 +75,7 @@ import {
typeLook,
} from '../ui/copy';
import { AttachmentItem } from '../ui/attachments';
import { MediaRecorderComponent } from '../ui/media-recorder';
import { BlockedComponent } from '../ui/blocked';
import { ReactionsComponent } from '../ui/reactions';
import {
@@ -103,6 +104,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
RouterLink,
FormField,
AttachmentItem,
MediaRecorderComponent,
PiActionButtonComponent,
PiCheckboxComponent,
PiDateFieldComponent,
@@ -1266,6 +1268,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</button>
</div>
}
<ts-media-recorder (recorded)="addRecording($event)" />
<pi-file-upload
label="Adjuntar al comentario"
[multiple]="true"
@@ -1278,7 +1281,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<pi-action-button
color="primary"
text="Comentar"
[disabled]="!commentBody().trim()"
[disabled]="!commentBody().trim() && !commentFiles().length"
(execute)="addComment()"
/>
</div>
@@ -2266,7 +2269,15 @@ export class IssuePage {
}
protected addComment(): void {
const body = this.commentBody().trim();
const files = this.commentFiles();
// Only files (a recording, say) need no words: their names say it.
const body =
this.commentBody().trim() ||
(files.length === 1
? `📎 ${files[0].name}`
: files.length
? `📎 ${files.length} archivos`
: '');
if (!body) return;
const options = this.pollOptions()
.map((o) => o.trim())
@@ -2275,12 +2286,7 @@ export class IssuePage {
this.toast.error('Una encuesta necesita al menos dos opciones');
return;
}
this.facade.addComment(
body,
this.commentFiles(),
null,
this.pollOn() ? options : null,
);
this.facade.addComment(body, files, null, this.pollOn() ? options : null);
this.commentBody.set('');
this.commentFiles.set([]);
this.pollOn.set(false);
@@ -2410,6 +2416,19 @@ export class IssuePage {
this.pendingFiles.set([]);
}
/** A screen or voice recording, attached like any other file. */
protected addRecording(file: File): void {
if (file.size > MAX_FILE_BYTES) {
this.toast.error('La grabación pasa de 25 MB: prueba con una más corta');
return;
}
if (this.commentFiles().length >= 10) {
this.toast.error('Como mucho 10 archivos a la vez');
return;
}
this.commentFiles.update((files) => [...files, file]);
}
protected rejected(rejections: PiFileRejection[]): void {
for (const { file, reason } of rejections) {
this.toast.error(

View File

@@ -15,8 +15,10 @@ import { timeAgo } from './copy';
/** Types the browser shows in a tab of its own; anything else is saved. */
const OPENS_IN_TAB =
/^(image\/(png|jpe?g|gif|webp|avif)|video\/(mp4|webm)|audio\/(mpeg|ogg|wav)|application\/pdf|text\/plain)$/;
/^(image\/(png|jpe?g|gif|webp|avif)|video\/(mp4|webm)|audio\/(mpeg|ogg|wav|webm|mp4)|application\/pdf|text\/plain)$/;
const PREVIEWED = /^image\/(png|jpe?g|gif|webp|avif)$/;
/** Played right here, under the file, instead of in another tab. */
const PLAYED = /^(video|audio)\/(mp4|webm|mpeg|ogg|wav)$/;
export function readableSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@@ -25,7 +27,8 @@ export function readableSize(bytes: number): string {
}
/**
* One file: a thumbnail for images, an icon otherwise. The bytes are fetched
* One file: a thumbnail for images, a player for video and audio, an icon
* otherwise. The bytes are fetched
* with the session's token (a plain link could not send it) and shown from a
* local object URL, released when the item goes.
*/
@@ -33,18 +36,28 @@ export function readableSize(bytes: number): string {
selector: 'ts-attachment',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiIconComponent, PiIconButtonComponent],
host: { '[class.is-playing]': '!!playing()' },
template: `
<button
type="button"
class="ts-attachment__open"
[attr.aria-label]="'Abrir ' + attachment().fileName"
[attr.aria-label]="
(playable() ? (playing() ? 'Ocultar ' : 'Reproducir ') : 'Abrir ') +
attachment().fileName
"
[attr.aria-expanded]="playable() ? !!playing() : null"
(click)="open()"
>
@if (thumbnail(); as src) {
<img class="ts-attachment__thumb" [src]="src" alt="" />
} @else {
<span class="ts-attachment__icon" aria-hidden="true">
<pi-icon [icon]="isImage() ? 'image' : 'document'" size="small" />
<pi-icon
[icon]="
isImage() ? 'image' : playable() ? 'play-arrow' : 'document'
"
size="small"
/>
</span>
}
<span class="ts-attachment__text">
@@ -64,10 +77,29 @@ export function readableSize(bytes: number): string {
(execute)="remove()"
/>
}
@if (playing(); as src) {
@if (isVideo()) {
<!-- A recording of the author's own screen: no captions to offer. -->
<video
class="ts-attachment__player"
[src]="src"
controls
autoplay
></video>
} @else {
<audio
class="ts-attachment__player"
[src]="src"
controls
autoplay
></audio>
}
}
`,
styles: `
:host {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
min-width: 0;
@@ -112,6 +144,19 @@ export function readableSize(bytes: number): string {
text-overflow: ellipsis;
white-space: nowrap;
}
/* A video wants the whole row of the grid it sits in. */
:host(.is-playing) {
grid-column: 1 / -1;
}
.ts-attachment__player {
flex-basis: 100%;
max-width: 100%;
border-radius: 0.5rem;
}
video.ts-attachment__player {
max-height: 24rem;
background: #000;
}
.ts-attachment__meta {
font-size: 0.8125rem;
opacity: 0.7;
@@ -130,6 +175,14 @@ export class AttachmentItem implements OnInit {
protected readonly isImage = computed(() =>
PREVIEWED.test(this.attachment().mimeType),
);
protected readonly playable = computed(() =>
PLAYED.test(this.attachment().mimeType),
);
protected readonly isVideo = computed(() =>
this.attachment().mimeType.startsWith('video/'),
);
/** The player's source, while it is open. */
protected readonly playing = signal<string | null>(null);
protected readonly size = computed(() =>
readableSize(this.attachment().size),
);
@@ -146,6 +199,11 @@ export class AttachmentItem implements OnInit {
}
protected open(): void {
if (this.playable()) {
if (this.playing()) this.playing.set(null);
else this.withUrl((url) => this.playing.set(url));
return;
}
this.withUrl((url) => {
const { fileName, mimeType } = this.attachment();
if (OPENS_IN_TAB.test(mimeType)) {

View File

@@ -0,0 +1,256 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
output,
signal,
} from '@angular/core';
import { ToastService } from '@task-stream/web/shared/ui';
/** Long enough to show a bug, short enough to stay under the upload limit. */
export const MAX_RECORDING_SECONDS = 180;
/** ~0.9 Mb/s of video: three minutes stay around 20 MB. */
const VIDEO_BITS_PER_SECOND = 900_000;
type Kind = 'screen' | 'voice';
/** The first of these the browser records, or '' to let it choose. */
function mimeFor(kind: Kind): string {
const wanted =
kind === 'screen'
? [
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8,opus',
'video/webm',
'video/mp4',
]
: ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg', 'audio/mp4'];
return (
wanted.find(
(type) =>
typeof MediaRecorder !== 'undefined' &&
MediaRecorder.isTypeSupported(type),
) ?? ''
);
}
/**
* Records the screen (with the voice, if the microphone is allowed) or only
* the voice, in the browser, and hands the file over to attach it.
*/
@Component({
selector: 'ts-media-recorder',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (recording(); as kind) {
<span class="ts-recorder__live" role="status">
<span class="ts-recorder__dot" aria-hidden="true"></span>
Grabando {{ kind === 'screen' ? 'la pantalla' : 'tu voz' }} ·
{{ clock(elapsed()) }} / {{ clock(max) }}
</span>
<button type="button" class="ts-recorder__button" (click)="stop()">
■ Parar
</button>
} @else {
@if (canScreen) {
<button
type="button"
class="ts-recorder__button"
title="Graba la pantalla (y tu voz, si das permiso al micrófono) y lo adjunta al comentario"
(click)="start('screen')"
>
🎥 Grabar pantalla
</button>
}
@if (canVoice) {
<button
type="button"
class="ts-recorder__button"
title="Graba un audio y lo adjunta al comentario"
(click)="start('voice')"
>
🎙 Grabar audio
</button>
}
}
`,
styles: `
:host {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 1rem;
}
/* Small text buttons, not @pi/ui's sized ones. */
.ts-recorder__button {
height: auto !important;
min-width: 0 !important;
padding: 0 !important;
border: 0;
background: none !important;
color: var(--ts-brand);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
&:hover,
&:focus-visible {
text-decoration: underline;
}
}
.ts-recorder__live {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
}
.ts-recorder__dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
background: var(--ts-c-red);
animation: ts-recording 1s ease-in-out infinite alternate;
}
@keyframes ts-recording {
to {
opacity: 0.3;
}
}
@media (prefers-reduced-motion: reduce) {
.ts-recorder__dot {
animation: none;
}
}
`,
})
export class MediaRecorderComponent {
/** The finished recording, ready to attach. */
readonly recorded = output<File>();
private readonly toast = inject(ToastService);
protected readonly max = MAX_RECORDING_SECONDS;
protected readonly recording = signal<Kind | null>(null);
protected readonly elapsed = signal(0);
protected readonly canVoice =
typeof MediaRecorder !== 'undefined' &&
!!navigator.mediaDevices?.getUserMedia;
/** Phones cannot share their screen from a web page. */
protected readonly canScreen =
this.canVoice && !!navigator.mediaDevices?.getDisplayMedia;
private recorder: MediaRecorder | null = null;
private streams: MediaStream[] = [];
private timer: ReturnType<typeof setInterval> | null = null;
constructor() {
inject(DestroyRef).onDestroy(() => this.release());
}
protected async start(kind: Kind): Promise<void> {
try {
const tracks: MediaStreamTrack[] = [];
if (kind === 'screen') {
const screen = await navigator.mediaDevices.getDisplayMedia({
video: { frameRate: 15 },
audio: true,
});
this.streams.push(screen);
tracks.push(...screen.getTracks());
// Ending the sharing from the browser's own bar stops too.
screen
.getVideoTracks()[0]
?.addEventListener('ended', () => this.stop());
// The voice too, if allowed: without it, a silent video.
const voice = await navigator.mediaDevices
.getUserMedia({ audio: true })
.catch(() => null);
if (voice) {
this.streams.push(voice);
tracks.push(...voice.getAudioTracks());
}
} else {
const voice = await navigator.mediaDevices.getUserMedia({
audio: true,
});
this.streams.push(voice);
tracks.push(...voice.getAudioTracks());
}
const mimeType = mimeFor(kind);
const recorder = new MediaRecorder(new MediaStream(tracks), {
...(mimeType ? { mimeType } : {}),
...(kind === 'screen'
? { videoBitsPerSecond: VIDEO_BITS_PER_SECOND }
: {}),
});
const chunks: Blob[] = [];
recorder.ondataavailable = (event) => {
if (event.data.size) chunks.push(event.data);
};
recorder.onstop = () => {
const type =
recorder.mimeType ||
mimeType ||
(kind === 'screen' ? 'video/webm' : 'audio/webm');
const extension = type.includes('mp4')
? 'mp4'
: type.includes('ogg')
? 'ogg'
: 'webm';
// Local time, as the author remembers it: 2026-10-21-14-05.
const now = new Date();
const stamp = [
now.getFullYear(),
now.getMonth() + 1,
now.getDate(),
now.getHours(),
now.getMinutes(),
]
.map((n) => String(n).padStart(2, '0'))
.join('-');
if (chunks.length)
this.recorded.emit(
new File(
chunks,
`${kind === 'screen' ? 'grabacion-pantalla' : 'audio'}-${stamp}.${extension}`,
{ type: type.split(';')[0] },
),
);
this.release();
};
this.recorder = recorder;
recorder.start(1000);
this.elapsed.set(0);
this.recording.set(kind);
this.timer = setInterval(() => {
this.elapsed.update((s) => s + 1);
if (this.elapsed() >= MAX_RECORDING_SECONDS) this.stop();
}, 1000);
} catch (error) {
this.release();
// Cancelling the browser's dialog is no error worth telling.
if ((error as DOMException)?.name !== 'NotAllowedError')
this.toast.error('No se pudo empezar a grabar');
}
}
protected stop(): void {
if (this.recorder?.state === 'recording') this.recorder.stop();
else this.release();
}
protected clock(seconds: number): string {
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
}
private release(): void {
if (this.timer) clearInterval(this.timer);
this.timer = null;
for (const stream of this.streams)
stream.getTracks().forEach((track) => track.stop());
this.streams = [];
this.recorder = null;
this.recording.set(null);
}
}