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 |
| 🔔 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 |

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
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

View File

@@ -300,26 +300,26 @@ 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/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 |
| `/p/:key/dashboard` | Arriba, la salud del proyecto (`ts-project-health`): un semáforo (bien, atención, en riesgo) con cada causa y sus tickets, calculado en la web (`projectHealth`) con los mismos datos: atascados, vencidos, bloqueados, bugs críticos o altos, lo que devuelve QA, releases que no llegan y columnas sobre su WIP. Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
| `/p/:key/activity` | Actividad en vivo |
| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» |
| `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» |
| `/notes/:token` | Notas de una release para sus clientes, sin cuenta: las escritas o las de sus tickets terminados |
| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos |
| 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. «🎥 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 |
| `/p/:key/dashboard` | Arriba, la salud del proyecto (`ts-project-health`): un semáforo (bien, atención, en riesgo) con cada causa y sus tickets, calculado en la web (`projectHealth`) con los mismos datos: atascados, vencidos, bloqueados, bugs críticos o altos, lo que devuelve QA, releases que no llegan y columnas sobre su WIP. Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
| `/p/:key/activity` | Actividad en vivo |
| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» |
| `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» |
| `/notes/:token` | Notas de una release para sus clientes, sin cuenta: las escritas o las de sus tickets terminados |
| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos |
Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien
no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de

View File

@@ -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')

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);
}
}