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:
@@ -35,7 +35,7 @@
|
||||
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien lo abre, lo tiene asignado o comenta, lo sigue solo |
|
||||
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo |
|
||||
| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence |
|
||||
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
|
||||
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes; grabar la pantalla o la voz desde el comentario y verlo ahí mismo |
|
||||
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA); lo que espera a otro ticket abierto sale marcado como «Bloqueado» |
|
||||
| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha |
|
||||
| 🚨 Alertas → bugs | Sentry, Uptime Kuma, Grafana o cualquier herramienta abren bugs por una URL secreta; el mismo problema repetido va al bug abierto, y «resuelta» se dice en él |
|
||||
|
||||
@@ -269,7 +269,9 @@ stateDiagram-v2
|
||||
- **Adjuntos**: los suben los miembros (no los lectores), hasta `ATTACHMENTS_MAX_MB` (25 MB) por archivo y 50 por
|
||||
ticket; pueden ir en el ticket o bajo uno de sus comentarios (`commentId`). Quita uno quien lo subió o un admin.
|
||||
Los bytes se guardan primero y el evento después, así que nunca queda un adjunto apuntando a un archivo que no
|
||||
existe; si el evento no se guarda, el archivo se borra. Al borrar un ticket se borran sus archivos.
|
||||
existe; si el evento no se guarda, el archivo se borra. Al borrar un ticket se borran sus archivos. Las imágenes,
|
||||
los PDF, el texto y el vídeo o audio que reproduce el navegador (mp4, webm, mp3, ogg, wav) se sirven para verse en
|
||||
la página; el resto, como descarga.
|
||||
- **Épicas y subtareas**: un ticket puede colgar de otro del mismo proyecto (`parent`), hasta tres niveles
|
||||
(épica → ticket → subtarea) y sin bucles; se comprueba sobre el event store, no sobre el read model. Una «épica» es
|
||||
cualquier ticket de primer nivel del que cuelgan otros (`epics(projectId)` da su progreso: terminados = hechos o
|
||||
|
||||
@@ -301,14 +301,14 @@ visualización de la plataforma:
|
||||
## 4.6 Rutas
|
||||
|
||||
| Ruta | Página |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
||||
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), «Mi día» (`ts-my-day`: los tickets que elijo para hoy, tachados al terminar, mis abiertos para añadir y lo que quedó del último día), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
|
||||
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos. Cada proyecto lleva su semáforo de salud (`portfolioHealth`), con las causas al pasar por encima |
|
||||
| `/explore` | Proyectos públicos (sin sesión) |
|
||||
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar. Con el teclado: Tab entra en las tarjetas, las flechas se mueven entre ellas, Shift+←/→ pasa la tarjeta de columna (mismas reglas que arrastrar), `p`/`e`/`a` abren su prioridad, estimación o persona y Enter la abre |
|
||||
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas). «Recordármelo» pone un aviso solo para mí (dentro de 1 hora, esta tarde, mañana o el lunes a las 9, en una semana o cuando diga), con nota opcional. «☀ Mi día» lo añade a los de hoy. «Pedir revisión» a alguien del equipo: lo ve en «Mi trabajo» («Te piden revisión») y responde «Todo bien» o «Pedir cambios» desde el recuadro Revisiones. Un comentario puede llevar una encuesta («📊 Añadir una encuesta»): se vota pulsando una opción y, al cerrarla, la ganadora queda como decisión |
|
||||
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas). «Recordármelo» pone un aviso solo para mí (dentro de 1 hora, esta tarde, mañana o el lunes a las 9, en una semana o cuando diga), con nota opcional. «☀ Mi día» lo añade a los de hoy. «Pedir revisión» a alguien del equipo: lo ve en «Mi trabajo» («Te piden revisión») y responde «Todo bien» o «Pedir cambios» desde el recuadro Revisiones. Un comentario puede llevar una encuesta («📊 Añadir una encuesta»): se vota pulsando una opción y, al cerrarla, la ganadora queda como decisión. «🎥 Grabar pantalla» (con la voz, si se da permiso al micrófono) y «🎙 Grabar audio» graban en el navegador hasta 3 minutos y lo adjuntan al comentario, que puede ir sin texto; los vídeos y audios adjuntos se reproducen ahí mismo |
|
||||
| `/p/:key/releases` | Releases. «Página pública» (quien gestiona): un enlace secreto con las notas para clientes, que se copia, renueva o retira |
|
||||
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
||||
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea. Los bloqueos con tickets de otros proyectos (en los dos sentidos, `externalDependencies`) salen en «De otros proyectos» con barra discontinua, sus flechas y el mismo aviso si las fechas no encajan; los que no tienen fechas o no se pueden leer se nombran debajo |
|
||||
|
||||
@@ -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