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:
@@ -57,7 +57,7 @@ interface UploadedPart {
|
||||
* always downloaded: a file someone uploaded must never run in this origin.
|
||||
*/
|
||||
const SHOWN_INLINE =
|
||||
/^(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)$/;
|
||||
|
||||
/** REST adapter for a ticket's files: uploaded here, listed on the ticket. */
|
||||
@ApiTags('Issues')
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
256
libs/web/tracker/presenters/src/lib/ui/media-recorder.ts
Normal file
256
libs/web/tracker/presenters/src/lib/ui/media-recorder.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user