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 |
|
| 👀 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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
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