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

@@ -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 | | 👀 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 | | 🔔 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 | | ⏰ 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» | | 🔗 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 | | 🌿 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 | | 🚨 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 |

View File

@@ -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 - **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. 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 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 - **É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 (é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 cualquier ticket de primer nivel del que cuelgan otros (`epics(projectId)` da su progreso: terminados = hechos o

View File

@@ -301,14 +301,14 @@ visualización de la plataforma:
## 4.6 Rutas ## 4.6 Rutas
| Ruta | Página | | Ruta | Página |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/login` | Inicio de sesión (solo sin sesión) | | `/login` | Inicio de sesión (solo sin sesión) |
| `/auth/callback` | Retorno de Pireon (PKCE) | | `/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» | | `/` | 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 | | `/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) | | `/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/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/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/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 | | `/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 |

View File

@@ -57,7 +57,7 @@ interface UploadedPart {
* always downloaded: a file someone uploaded must never run in this origin. * always downloaded: a file someone uploaded must never run in this origin.
*/ */
const SHOWN_INLINE = 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. */ /** REST adapter for a ticket's files: uploaded here, listed on the ticket. */
@ApiTags('Issues') @ApiTags('Issues')

View File

@@ -75,6 +75,7 @@ import {
typeLook, typeLook,
} from '../ui/copy'; } from '../ui/copy';
import { AttachmentItem } from '../ui/attachments'; import { AttachmentItem } from '../ui/attachments';
import { MediaRecorderComponent } from '../ui/media-recorder';
import { BlockedComponent } from '../ui/blocked'; import { BlockedComponent } from '../ui/blocked';
import { ReactionsComponent } from '../ui/reactions'; import { ReactionsComponent } from '../ui/reactions';
import { import {
@@ -103,6 +104,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
RouterLink, RouterLink,
FormField, FormField,
AttachmentItem, AttachmentItem,
MediaRecorderComponent,
PiActionButtonComponent, PiActionButtonComponent,
PiCheckboxComponent, PiCheckboxComponent,
PiDateFieldComponent, PiDateFieldComponent,
@@ -1266,6 +1268,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</button> </button>
</div> </div>
} }
<ts-media-recorder (recorded)="addRecording($event)" />
<pi-file-upload <pi-file-upload
label="Adjuntar al comentario" label="Adjuntar al comentario"
[multiple]="true" [multiple]="true"
@@ -1278,7 +1281,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<pi-action-button <pi-action-button
color="primary" color="primary"
text="Comentar" text="Comentar"
[disabled]="!commentBody().trim()" [disabled]="!commentBody().trim() && !commentFiles().length"
(execute)="addComment()" (execute)="addComment()"
/> />
</div> </div>
@@ -2266,7 +2269,15 @@ export class IssuePage {
} }
protected addComment(): void { 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; if (!body) return;
const options = this.pollOptions() const options = this.pollOptions()
.map((o) => o.trim()) .map((o) => o.trim())
@@ -2275,12 +2286,7 @@ export class IssuePage {
this.toast.error('Una encuesta necesita al menos dos opciones'); this.toast.error('Una encuesta necesita al menos dos opciones');
return; return;
} }
this.facade.addComment( this.facade.addComment(body, files, null, this.pollOn() ? options : null);
body,
this.commentFiles(),
null,
this.pollOn() ? options : null,
);
this.commentBody.set(''); this.commentBody.set('');
this.commentFiles.set([]); this.commentFiles.set([]);
this.pollOn.set(false); this.pollOn.set(false);
@@ -2410,6 +2416,19 @@ export class IssuePage {
this.pendingFiles.set([]); 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 { protected rejected(rejections: PiFileRejection[]): void {
for (const { file, reason } of rejections) { for (const { file, reason } of rejections) {
this.toast.error( 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. */ /** Types the browser shows in a tab of its own; anything else is saved. */
const OPENS_IN_TAB = 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)$/; 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 { export function readableSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; 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 * with the session's token (a plain link could not send it) and shown from a
* local object URL, released when the item goes. * local object URL, released when the item goes.
*/ */
@@ -33,18 +36,28 @@ export function readableSize(bytes: number): string {
selector: 'ts-attachment', selector: 'ts-attachment',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiIconComponent, PiIconButtonComponent], imports: [PiIconComponent, PiIconButtonComponent],
host: { '[class.is-playing]': '!!playing()' },
template: ` template: `
<button <button
type="button" type="button"
class="ts-attachment__open" 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()" (click)="open()"
> >
@if (thumbnail(); as src) { @if (thumbnail(); as src) {
<img class="ts-attachment__thumb" [src]="src" alt="" /> <img class="ts-attachment__thumb" [src]="src" alt="" />
} @else { } @else {
<span class="ts-attachment__icon" aria-hidden="true"> <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>
} }
<span class="ts-attachment__text"> <span class="ts-attachment__text">
@@ -64,10 +77,29 @@ export function readableSize(bytes: number): string {
(execute)="remove()" (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: ` styles: `
:host { :host {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
min-width: 0; min-width: 0;
@@ -112,6 +144,19 @@ export function readableSize(bytes: number): string {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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 { .ts-attachment__meta {
font-size: 0.8125rem; font-size: 0.8125rem;
opacity: 0.7; opacity: 0.7;
@@ -130,6 +175,14 @@ export class AttachmentItem implements OnInit {
protected readonly isImage = computed(() => protected readonly isImage = computed(() =>
PREVIEWED.test(this.attachment().mimeType), 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(() => protected readonly size = computed(() =>
readableSize(this.attachment().size), readableSize(this.attachment().size),
); );
@@ -146,6 +199,11 @@ export class AttachmentItem implements OnInit {
} }
protected open(): void { 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) => { this.withUrl((url) => {
const { fileName, mimeType } = this.attachment(); const { fileName, mimeType } = this.attachment();
if (OPENS_IN_TAB.test(mimeType)) { 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);
}
}