parent
65da577c05
commit
1e24c87043
@ -0,0 +1,75 @@
|
||||
# Juke-box: reproducción y configuración
|
||||
|
||||
Actualizado el 14 de septiembre de 2026.
|
||||
|
||||
## Pantallas
|
||||
|
||||
- **/juke-box:** hero de 100vh, con el botón dorado «Play JukeBox» y «Configura tu Jukebox» debajo del título. El pie del sitio aparece inmediatamente después del hero. No se muestra la colección ni el editor debajo.
|
||||
- **/juke-box/configurar:** colección «Mis jukebox», creación, edición y consulta de las programaciones. «Abrir reproductor» vuelve al hero con el jukebox elegido.
|
||||
- **/juke-box/nuevo** y **/juke-box/[id]/editar:** comparten el editor. Guardar y cancelar vuelven a configuración; guardar no inicia la reproducción.
|
||||
|
||||
«Play JukeBox» despliega todos los jukebox con su nombre y descripción. Elegir uno inicia su reproducción. Su nombre, estado y botón Detener se muestran en la misma fila de acciones en escritorio. Si todavía no hay ninguno, el hero indica que se debe crear desde configuración. El acceso anónimo utiliza el popup de cuenta.
|
||||
|
||||
La frase «Tu música, a su hora.» queda debajo de Juke-box; su tamaño tipográfico se adapta al ancho del título conservando el espaciado natural entre palabras.
|
||||
|
||||
## Uso del editor
|
||||
|
||||
Escribir el nombre y la descripción del jukebox en la cabecera del editor.
|
||||
|
||||
1. Crear una franja. Se identifica automáticamente como Franja 1, Franja 2, etc.; no hay campo de nombre ni selector de zona horaria.
|
||||
2. Elegir «Todo el día» o un horario de inicio y fin. Se rechazan solapamientos dentro del mismo jukebox.
|
||||
3. Elegir «Todo el catálogo» o «Listas y canciones». La selección preparada se conserva al alternar entre ambas opciones.
|
||||
4. Elegir reproducción secuencial o aleatoria para esa franja. Las canciones y listas seleccionadas se pueden ordenar y retirar.
|
||||
5. «Guardar franja» persiste la franja seleccionada, el nombre y la descripción y mantiene abierto el editor. No guarda los cambios pendientes de otras franjas. «Guardar jukebox» persiste todos los cambios y vuelve a configuración. Cada registro conserva su revisión independiente.
|
||||
|
||||
Cancelar un jukebox nuevo no crea un registro vacío. El historial del navegador conserva el borrador. Se pueden deshacer cambios y recuperar una franja eliminada. La eliminación de un jukebox requiere confirmación dentro del editor.
|
||||
|
||||
## Reproducción
|
||||
|
||||
Solo un jukebox controla el reproductor de la pestaña a la vez. Editar o guardar otro no lo detiene. Una nueva pulsación de Play aplica la revisión guardada seleccionada.
|
||||
|
||||
Las franjas se repiten diariamente. Pueden cruzar medianoche; el inicio está incluido y el final excluido. El contenido se repite hasta terminar la franja, cambia al comenzar la siguiente y queda en espera durante los huecos. Estas reglas se explican en el editor.
|
||||
|
||||
En secuencial se respeta el orden visible y el orden interno de las listas. En aleatoria se barajan todas las canciones de la franja, incluida la primera; al comenzar una nueva vuelta se evita repetir inmediatamente la última canción.
|
||||
|
||||
La programación y el audio permanecen activos al navegar. Su icono, nombre, estado y acciones se agrupan en la barra del reproductor, además del estado del hero. Si no hay franja activa, queda visible una barra compacta de espera.
|
||||
|
||||
Elegir una canción suspende temporalmente el jukebox: conserva pista, segundo, posición y orden de la cola. Al terminar la canción o pulsar su pausa en la web vuelve automáticamente; «Continuar» permite volver inmediatamente. Al reproducir un álbum o lista completos, el retorno automático espera al final de esa selección, salvo que se pulse pausa o «Continuar». Varias selecciones consecutivas conservan el punto de retorno original. La pausa del propio jukebox se mantiene hasta pulsar play; las pausas técnicas, como abrir un vídeo o pausar desde el sistema, no arrancan otro audio.
|
||||
|
||||
La vuelta recupera el punto guardado si sigue vigente la misma franja; si ha cambiado, empieza la actual. Sin franja queda en espera. Una franja del día siguiente no recupera un punto antiguo, y las franjas nocturnas se identifican en su zona horaria. Pausar el jukebox impide que el reloj vuelva a arrancarlo: Play valida la franja vigente al reanudar. Si estaba pausado antes de elegir una canción manual, su final o pausa lo devuelve todavía pausado; «Continuar» lo reanuda expresamente. «Detener» cancela el retorno sin cortar la canción manual. Cerrar sesión o perder el acceso detiene la programación y el audio.
|
||||
|
||||
«Detener» conserva el jukebox seleccionado y sus controles, con estado «Detenido» y botón «Reproducir». Desarma el reloj y vacía la escucha del jukebox, sin borrar su nombre, descripción ni franjas guardadas. Si suena una selección manual, la deja continuar y cancela el retorno automático. «Reproducir» inicia una nueva sesión según la franja vigente; «Eliminar Jukebox» sigue siendo una acción distinta dentro de configuración.
|
||||
|
||||
Configuración, creación y edición comparten una navegación compacta con «Volver al Juke-box». Las migas no acumulan su separación con otro relleno superior de la página.
|
||||
|
||||
La web debe permanecer abierta y el dispositivo activo. Recargar requiere pulsar Play de nuevo. La zona horaria se obtiene del dispositivo al crear y queda guardada. Las listas y el catálogo se resuelven al iniciar la sesión de reproducción.
|
||||
|
||||
## Persistencia y permisos
|
||||
|
||||
La migración 0037_jukeboxes_independientes.sql crea la tabla jukebox y migra las programaciones anteriores no vacías a un primer jukebox por cuenta, conservando horarios y contenidos. No elimina la tabla anterior.
|
||||
|
||||
Cada registro tiene ID, propietario, número, nombre, descripción, programación y revisión. Las migraciones 0038 y 0039 añaden nombre y descripción a los registros existentes. El servidor comprueba sesión, prestación Juke-box, pertenencia de listas, canciones válidas, origen de las peticiones y revisión. Una revisión antigua no sobrescribe un guardado posterior. El endpoint anterior devuelve 410 para impedir escrituras desde el editor antiguo.
|
||||
|
||||
## Verificación
|
||||
|
||||
La suite de navegador usa cuentas suscritas temporales que elimina al terminar. Comprueba creación de dos jukebox, guardados independientes, edición durante reproducción, cancelación, historial, aislamiento de cuentas, selección desde el hero, horario con audio real, solapamientos y eliminación. También verifica Continuar, el retorno tras un final real del audio, el cambio de franja durante la interrupción y el regreso desde configuración y edición.
|
||||
|
||||
La revisión visual comprueba el hero a 100vh, el pie inmediatamente después, ausencia de gestión bajo el hero, botones originales y adaptación móvil. Las pruebas de dominio y reproductor cubren horarios, medianoche, modos y cambios de franja.
|
||||
|
||||
Para repetir en desarrollo, con el servidor en http://127.0.0.1:4180 y el acceso de pruebas habilitado:
|
||||
|
||||
- npx vitest run src/lib/programacion-jukebox.spec.ts src/lib/player/player.svelte.spec.ts
|
||||
- npx playwright test --config playwright.jukebox.config.ts
|
||||
- npm run check:sitio
|
||||
|
||||
La suite utiliza la conexión de desarrollo configurada en .env; no introduce ejemplos en la cuenta del usuario.
|
||||
|
||||
Los botones principales de los formularios utilizan navy en el tema claro y celeste en el oscuro, mediante tokens globales. El Play del hero conserva el dorado.
|
||||
|
||||
Los controles exclusivos de horario, modo, origen y tipo de contenido utilizan GrupoToggle: una barra común con el segmento activo verde en ambos temas. Sus tokens y estilos residen en app.css. El contenido del hero reserva un margen inferior de 12vh, acotado con el espaciado global, más la altura del reproductor cuando está visible.
|
||||
|
||||
La barra utiliza los tokens del sitio: títulos de canción y jukebox de 16 px e información secundaria de 14 px. «Continuar» conserva el dorado del reproductor, con letra de 13 px, peso normal y altura compacta de 36 px. El icono para detener permanece junto a esta acción y al nombre del jukebox. Se elimina la etiqueta fija «Calidad de escucha», que no describía el audio real. En móvil, la canción y los controles de transporte comparten fila; el progreso y el módulo del jukebox ocupan las siguientes.
|
||||
|
||||
El editor reúne título, estado de guardado y acciones en su cabecera. El nombre y la descripción preceden a un panel común de franjas y edición, con horario, reproducción y contenido agrupados. La colección muestra la programación junto al jukebox seleccionado en escritorio y debajo en móvil.
|
||||
|
||||
Las tablas de canciones integran el control de reproducción sobre la portada, sin una columna independiente. Aparece al pasar el ratón por la fila, recibir foco de teclado o reproducir la canción; en pantallas táctiles permanece visible. Las pruebas comprueban reproducción, pausa, teclado, tamaño estable de la portada y ausencia de desbordamiento, además de generar capturas de la barra, la colección y el editor en claro, oscuro y móvil.
|
||||
@ -0,0 +1,54 @@
|
||||
# Reanálisis del Juke-box
|
||||
|
||||
Fecha: 14 de septiembre de 2026. Actualizado tras implementar la separación de pantallas y las correcciones del hero.
|
||||
|
||||
## Problemas identificados
|
||||
|
||||
La implementación anterior guardaba una sola programación por cuenta, mezclaba reproducción y edición, exigía nombres de franja y no representaba el catálogo completo ni el modo de reproducción por franja. Las pruebas de la programación única no acreditaban los requisitos de varios jukebox.
|
||||
|
||||
La primera separación mantuvo «Mis jukebox» debajo del hero. El usuario indicó que esa gestión debía quedar fuera de la pantalla de reproducción. Se trasladó a /juke-box/configurar. El pie del sitio se conserva debajo del hero; no es parte del bloque de gestión retirado.
|
||||
|
||||
## Modelo implementado
|
||||
|
||||
Cuenta → varios jukebox independientes → franjas numeradas → horario, contenido y modo de reproducción.
|
||||
|
||||
Cada jukebox tiene ID, propietario, número, nombre editable, descripción y revisión. Las franjas muestran Franja 1, Franja 2… sin campo de nombre. La referencia horaria se resuelve internamente. Cada franja permite catálogo completo o listas/canciones seleccionadas, con reproducción secuencial o aleatoria.
|
||||
|
||||
## Separación de tareas
|
||||
|
||||
| Ruta | Función |
|
||||
| --------------------------- | ------------------------------------------------------------------------------- |
|
||||
| /juke-box | Hero de reproducción a 100vh, botones Play y Configura, seguido del pie global. |
|
||||
| /juke-box/configurar | Colección de jukebox y acceso a sus programaciones, creación y edición. |
|
||||
| /juke-box/nuevo | Borrador nuevo que solo se persiste al guardar. |
|
||||
| /juke-box/[id]/editar | Editor del jukebox elegido. |
|
||||
| /juke-box/[id]/programacion | API con autorización por propietario y control de revisión. |
|
||||
|
||||
Play mantiene su botón dorado original. Configura utiliza el botón secundario común. Ambos quedan a la izquierda debajo del título. Se conservan las imágenes del hero y se retira «Piloto automático».
|
||||
|
||||
Play despliega todos los jukebox con su nombre y descripción. El estado y Detener quedan en la misma fila de acciones. La frase debajo del título adapta su tamaño tipográfico al ancho del título, sin justificar las palabras. El reproductor global mantiene la escucha al navegar; editar otro jukebox no modifica ni detiene el activo. Guardar y cancelar regresan a configuración.
|
||||
|
||||
## Migración y comportamiento
|
||||
|
||||
La migración 0037 crea los registros independientes y traslada las programaciones previas no vacías sin eliminar la tabla de origen. Se descartan los nombres de franja de la representación nueva. Los clientes antiguos no pueden sobrescribir esta estructura mediante el endpoint anterior.
|
||||
|
||||
Cada jukebox valida sus solapamientos de forma independiente. El modo aleatorio baraja todas las canciones de cada franja, incluida la primera. La programación espera en los huecos y cambia de contenido al llegar a la siguiente franja. El editor explica la repetición, el cambio de franja y la espera.
|
||||
|
||||
Los borradores sobreviven al historial. Los conflictos de revisión impiden sobrescribir cambios recientes. Las cuentas no pueden leer, editar o eliminar jukebox ajenos ni incorporar listas de otra cuenta.
|
||||
|
||||
## Criterios de aceptación
|
||||
|
||||
- Crear dos jukebox y recuperarlos con contenidos independientes.
|
||||
- Editar el segundo mientras suena el primero.
|
||||
- Reproducir desde el hero y gestionar desde configuración.
|
||||
- Conservar el pie inmediatamente después del hero, sin «Mis jukebox» intermedio.
|
||||
- Cancelar una creación sin persistir registros vacíos.
|
||||
- Usar catálogo completo y modos por franja.
|
||||
- Conservar borradores al volver atrás y rechazar revisiones antiguas.
|
||||
- Validar aislamiento de cuentas, solapamientos y eliminación individual.
|
||||
- Comprobar audio real y cambios horarios mientras se navega.
|
||||
- Verificar escritorio y móvil, hero, botones y etiquetas comunes.
|
||||
|
||||
La guía de uso y los comandos de verificación están en [jukebox-programacion-horaria.md](jukebox-programacion-horaria.md).
|
||||
|
||||
«Guardar franja» guarda la selección actual sin persistir cambios de otras franjas; «Guardar jukebox» guarda el conjunto. Nombre y descripción se incluyen en ambos guardados. Los formularios utilizan botones navy/celeste según el tema; el Play del hero conserva el dorado.
|
||||
@ -0,0 +1,9 @@
|
||||
CREATE TABLE "programacion_jukebox" (
|
||||
"usuario_id" text PRIMARY KEY REFERENCES "usuario"("id") ON DELETE CASCADE,
|
||||
"zona_horaria" text NOT NULL DEFAULT 'Europe/Madrid',
|
||||
"franjas" jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
"revision" integer NOT NULL DEFAULT 1,
|
||||
"actualizada_en" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "programacion_jukebox_franjas_array" CHECK (jsonb_typeof("franjas") = 'array'),
|
||||
CONSTRAINT "programacion_jukebox_revision_positiva" CHECK ("revision" > 0)
|
||||
);
|
||||
@ -0,0 +1,21 @@
|
||||
CREATE TABLE jukebox (
|
||||
id text PRIMARY KEY,
|
||||
usuario_id text NOT NULL REFERENCES usuario(id) ON DELETE CASCADE,
|
||||
numero integer NOT NULL,
|
||||
zona_horaria text NOT NULL,
|
||||
franjas jsonb NOT NULL,
|
||||
revision integer NOT NULL DEFAULT 1,
|
||||
actualizada_en timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT jukebox_franjas_array CHECK (jsonb_typeof(franjas) = 'array'),
|
||||
CONSTRAINT jukebox_revision_positiva CHECK (revision > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX jukebox_usuario_numero ON jukebox(usuario_id, numero);
|
||||
--> statement-breakpoint
|
||||
-- La programación antigua pasa a Jukebox 1; la tabla original se conserva.
|
||||
INSERT INTO jukebox(id, usuario_id, numero, zona_horaria, franjas, revision, actualizada_en)
|
||||
SELECT gen_random_uuid()::text, p.usuario_id, 1, p.zona_horaria,
|
||||
COALESCE((SELECT jsonb_agg((f.valor - 'nombre') || jsonb_build_object('orden', 'secuencial', 'catalogo', false) ORDER BY f.posicion)
|
||||
FROM jsonb_array_elements(p.franjas) WITH ORDINALITY AS f(valor, posicion)), '[]'::jsonb),
|
||||
p.revision, p.actualizada_en
|
||||
FROM programacion_jukebox p WHERE jsonb_array_length(p.franjas) > 0;
|
||||
@ -0,0 +1,3 @@
|
||||
ALTER TABLE jukebox ADD COLUMN nombre text NOT NULL DEFAULT '';
|
||||
--> statement-breakpoint
|
||||
UPDATE jukebox SET nombre = 'Jukebox ' || numero;
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE jukebox ADD COLUMN descripcion text NOT NULL DEFAULT '';
|
||||
@ -0,0 +1,87 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('las tablas reproducen sobre la miniatura al pasar por la fila y con teclado', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
for (const ruta of ['/albumes/cupido-sin-flechas', '/musica?vista=canciones']) {
|
||||
await page.goto(ruta);
|
||||
await page.locator('html[data-hidratado]').waitFor();
|
||||
if (ruta.startsWith('/musica'))
|
||||
await page.getByRole('button', { name: 'Tabla', exact: true }).click();
|
||||
const tabla = page.locator('table.tabla-canciones');
|
||||
const fila = tabla
|
||||
.locator('tbody tr')
|
||||
.filter({ has: page.locator('.mini-control button:enabled') })
|
||||
.first();
|
||||
const mini = fila.locator('.mini');
|
||||
const control = fila.locator('.mini-control');
|
||||
const play = control.getByRole('button');
|
||||
await mini.scrollIntoViewIfNeeded();
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(control).toHaveCSS('opacity', '0');
|
||||
const antes = await mini.boundingBox();
|
||||
await fila.locator('.titulo').hover();
|
||||
await expect(control).toHaveCSS('opacity', '1');
|
||||
const medidas = await mini.evaluate((el) => {
|
||||
const imagen = el.querySelector('img')!.getBoundingClientRect();
|
||||
const boton = el.querySelector('button')!.getBoundingClientRect();
|
||||
return {
|
||||
dx: Math.abs(imagen.x - boton.x),
|
||||
dy: Math.abs(imagen.y - boton.y),
|
||||
ancho: Math.abs(imagen.width - boton.width),
|
||||
alto: Math.abs(imagen.height - boton.height),
|
||||
celdasPrevias: el.closest('td')!.cellIndex
|
||||
};
|
||||
});
|
||||
expect(medidas).toEqual({ dx: 0, dy: 0, ancho: 0, alto: 0, celdasPrevias: 0 });
|
||||
expect(await mini.boundingBox()).toEqual(antes);
|
||||
await page.screenshot({
|
||||
path: `test-results-publica/tabla-play-${ruta.startsWith('/musica') ? 'catalogo' : 'album'}.png`
|
||||
});
|
||||
await page.mouse.move(0, 0);
|
||||
await play.focus();
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(play).toBeFocused();
|
||||
await expect(control).toHaveCSS('opacity', '1');
|
||||
await expect(play).toHaveAttribute('title', /Reproducir/);
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(play).toHaveAttribute('aria-label', 'Pausar');
|
||||
await play.evaluate((b: HTMLButtonElement) => b.blur());
|
||||
await expect(control).toHaveCSS('opacity', '1');
|
||||
await expect(page.getByRole('region', { name: 'Reproductor', exact: true })).toBeVisible();
|
||||
await play.click();
|
||||
await expect(play).toHaveAttribute('aria-label', /Reproducir/);
|
||||
}
|
||||
});
|
||||
|
||||
test('el control sobre la miniatura permanece visible en táctil sin necesitar hover', async ({
|
||||
browser
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await page.goto('http://127.0.0.1:4180/albumes/cupido-sin-flechas');
|
||||
await page.locator('html[data-hidratado]').waitFor();
|
||||
const tabla = page.locator('table.tabla-canciones');
|
||||
const control = tabla
|
||||
.locator('.mini-control')
|
||||
.filter({ has: page.locator('button:enabled') })
|
||||
.first();
|
||||
await control.scrollIntoViewIfNeeded();
|
||||
await expect(control).toHaveCSS('opacity', '1');
|
||||
await control.getByRole('button').tap();
|
||||
await expect(control.getByRole('button')).toHaveAttribute('aria-label', 'Pausar');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(
|
||||
true
|
||||
);
|
||||
await page.screenshot({ path: 'test-results-publica/tabla-play-tactil.png' });
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,743 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import pg from 'pg';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
// Solo contra desarrollo con el código de prueba habilitado y una cuenta efímera.
|
||||
test.describe('Programación autenticada del Juke-box', () => {
|
||||
test.skip(process.env.E2E_JUKEBOX !== '1', 'Requiere la base de desarrollo y ACCESO_PRUEBAS.');
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
const usuarioId = randomUUID();
|
||||
const otroId = randomUUID();
|
||||
const email = `jukebox-${usuarioId}@example.test`;
|
||||
const listaId = randomUUID();
|
||||
const listaAjena = randomUUID();
|
||||
let canciones: { id: string; slug: string; titulo: string }[];
|
||||
let db: pg.Client;
|
||||
let conectada = false;
|
||||
test.beforeAll(async () => {
|
||||
db = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||
await db.connect();
|
||||
conectada = true;
|
||||
canciones = (
|
||||
await db.query(
|
||||
"select id, slug, titulo from cancion where estado='publicado' and slug in ('prometiste','cuando-tus-manos-me-tocan') order by slug"
|
||||
)
|
||||
).rows;
|
||||
await db.query('insert into usuario (id,email) values ($1,$2), ($3,$4)', [
|
||||
usuarioId,
|
||||
email,
|
||||
otroId,
|
||||
`otro-${usuarioId}@example.test`
|
||||
]);
|
||||
await db.query(
|
||||
"insert into suscripcion (id,usuario_id,plan_id,estado) select $1,$2,id,'activa' from plan_suscripcion where codigo='friend' and acceso_jukebox=true",
|
||||
[randomUUID(), usuarioId]
|
||||
);
|
||||
await db.query(
|
||||
"insert into lista_reproduccion (id,usuario_id,nombre) values ($1,$2,'Mi selección de mañana'),($3,$4,'Lista ajena')",
|
||||
[listaId, usuarioId, listaAjena, otroId]
|
||||
);
|
||||
for (const [orden, c] of canciones.entries())
|
||||
await db.query(
|
||||
'insert into lista_reproduccion_cancion (lista_id,cancion_id,orden) values ($1,$2,$3)',
|
||||
[listaId, c.id, orden]
|
||||
);
|
||||
});
|
||||
test.afterAll(async () => {
|
||||
if (db && conectada) {
|
||||
await db.query('delete from usuario where id = any($1::text[])', [[usuarioId, otroId]]);
|
||||
await db.end();
|
||||
}
|
||||
});
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(`/entrar?paso=codigo&email=${encodeURIComponent(email)}&volverA=%2Fjuke-box`);
|
||||
await page.locator('html[data-hidratado]').waitFor();
|
||||
await page.getByLabel('Código', { exact: true }).fill('123456');
|
||||
await page.getByRole('button', { name: 'Entrar', exact: true }).click();
|
||||
await expect(page).toHaveURL('/juke-box');
|
||||
});
|
||||
|
||||
let primero = '';
|
||||
let segundo = '';
|
||||
test('separa reproducción y edición, crea dos jukebox y conserva catálogo, listas y modos', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await expect(page.locator('.programador')).toHaveCount(0);
|
||||
await page.getByRole('link', { name: 'Configura tu Jukebox', exact: true }).click();
|
||||
await expect(page).toHaveURL('/juke-box/configurar');
|
||||
await page.getByRole('link', { name: 'Crear jukebox', exact: true }).click();
|
||||
await expect(page).toHaveURL('/juke-box/nuevo');
|
||||
await page.getByLabel('Nombre del jukebox', { exact: true }).fill('Mañanas');
|
||||
await page.getByLabel('Descripción', { exact: true }).fill('Música para empezar el día.');
|
||||
await expect(page.getByLabel('Nombre de la franja')).toHaveCount(0);
|
||||
await expect(page.getByLabel('Zona horaria')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Crear primera franja' }).click();
|
||||
await page.getByRole('button', { name: 'Listas y canciones', exact: true }).click();
|
||||
await page.getByRole('button', { name: '+ Añadir contenido', exact: true }).click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Añadir lista Mi selección de mañana', exact: true })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Canciones', exact: true }).click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Añadir canción ' + canciones[1].titulo, exact: true })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Listo', exact: false }).click();
|
||||
await page.getByRole('button', { name: 'Subir ' + canciones[1].titulo, exact: true }).click();
|
||||
await expect(page.locator('.contenidos > .contenido').first()).toContainText(
|
||||
canciones[1].titulo
|
||||
);
|
||||
await page.getByRole('button', { name: 'Bajar ' + canciones[1].titulo, exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Todo el catálogo', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Listas y canciones', exact: true }).click();
|
||||
await expect(page.locator('.contenidos > .contenido')).toHaveCount(2);
|
||||
await page.getByRole('button', { name: 'Guardar jukebox', exact: true }).click();
|
||||
await expect(page).toHaveURL(/juke-box\/configurar\?jukebox=.+/);
|
||||
primero = new URL(page.url()).searchParams.get('jukebox')!;
|
||||
await expect(page.locator('.programador')).toHaveCount(0);
|
||||
await page.getByRole('link', { name: '+ Crear jukebox', exact: true }).click();
|
||||
await page.getByLabel('Nombre del jukebox', { exact: true }).fill('Tardes');
|
||||
await page
|
||||
.getByLabel('Descripción', { exact: true })
|
||||
.fill('Todo el catálogo al caer la tarde.');
|
||||
await page.getByRole('button', { name: 'Crear primera franja' }).click();
|
||||
await page.getByRole('button', { name: 'Aleatoria', exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Todo el catálogo', exact: true })
|
||||
).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.getByRole('button', { name: 'Guardar jukebox', exact: true }).click();
|
||||
await expect(page).toHaveURL(/juke-box\/configurar\?jukebox=.+/);
|
||||
segundo = new URL(page.url()).searchParams.get('jukebox')!;
|
||||
expect(segundo).not.toBe(primero);
|
||||
await page.reload();
|
||||
await page.locator('html[data-hidratado]').waitFor();
|
||||
await expect(page.locator('.coleccion .jukebox')).toHaveCount(2);
|
||||
await expect(page.locator('.coleccion')).toContainText('Música para empezar el día.');
|
||||
await expect(page.locator('.coleccion')).toContainText('Todo el catálogo al caer la tarde.');
|
||||
await expect(page.locator('.horarios')).toContainText('Todo el catálogo');
|
||||
await expect(page.locator('.horarios')).toContainText('Aleatoria');
|
||||
await page.getByRole('link', { name: 'Abrir reproductor de Mañanas', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Play JukeBox', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Reproducir Mañanas', exact: true }).click();
|
||||
await expect(page.locator('.estado-jukebox')).toContainText('Mañanas');
|
||||
const alturasAcciones = await page.locator('.acciones-heroe').evaluate((fila) =>
|
||||
Array.from(fila.querySelectorAll('button'))
|
||||
.filter((b) => b.offsetHeight)
|
||||
.map((b) => {
|
||||
const r = b.getBoundingClientRect();
|
||||
return r.y + r.height / 2;
|
||||
})
|
||||
);
|
||||
expect(Math.max(...alturasAcciones) - Math.min(...alturasAcciones)).toBeLessThanOrEqual(1);
|
||||
await expect(page.getByRole('region', { name: 'Reproductor', exact: true })).toContainText(
|
||||
canciones[0].titulo
|
||||
);
|
||||
await page.getByRole('link', { name: 'Configura tu Jukebox', exact: true }).click();
|
||||
await page.getByRole('link', { name: 'Editar Tardes', exact: true }).click();
|
||||
await expect(page).toHaveURL('/juke-box/' + segundo + '/editar');
|
||||
await page.getByRole('button', { name: 'Secuencial', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Guardar jukebox', exact: true }).click();
|
||||
await expect(page).toHaveURL(/juke-box\/configurar\?jukebox=.+/);
|
||||
await expect(page.locator('.control-jukebox')).toContainText('Mañanas');
|
||||
await expect(page.getByRole('region', { name: 'Reproductor', exact: true })).toContainText(
|
||||
canciones[0].titulo
|
||||
);
|
||||
await page.getByRole('link', { name: 'Abrir reproductor de Tardes', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Play JukeBox', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Reproducir Tardes', exact: true }).click();
|
||||
await expect(page.locator('.estado-jukebox')).toContainText('Tardes');
|
||||
await page.getByRole('button', { name: 'Detener Jukebox', exact: true }).click();
|
||||
await expect(page.locator('.estado-jukebox')).toContainText('Tardes');
|
||||
await expect(page.locator('.estado-jukebox')).toContainText('Detenido');
|
||||
expect((await page.request.get('/juke-box/' + segundo + '/programacion')).status()).toBe(200);
|
||||
await page.goto('/juke-box');
|
||||
await page.locator('html[data-hidratado]').waitFor();
|
||||
await page.getByRole('button', { name: 'Play JukeBox', exact: true }).click();
|
||||
await expect(page.getByRole('list', { name: 'Tus jukebox' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Reproducir Mañanas', exact: true }).click();
|
||||
await expect(page.getByRole('list', { name: 'Tus jukebox' })).not.toBeVisible();
|
||||
await expect(page.locator('.estado-jukebox')).toContainText('Mañanas');
|
||||
await page.getByRole('button', { name: 'Detener Jukebox', exact: true }).click();
|
||||
});
|
||||
test('mantiene borradores con el historial y permite cancelar un jukebox nuevo sin crearlo', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.getByRole('link', { name: 'Configura tu Jukebox', exact: true }).click();
|
||||
await page.getByRole('link', { name: 'Editar Mañanas', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Aleatoria', exact: true }).click();
|
||||
await page.getByRole('banner').getByRole('link', { name: 'Música', exact: true }).click();
|
||||
await expect(page).toHaveURL('/musica');
|
||||
await page.goBack();
|
||||
await expect(page.getByRole('button', { name: 'Aleatoria', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
);
|
||||
await page.getByRole('button', { name: 'Deshacer cambios', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Secuencial', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
);
|
||||
await page.getByRole('link', { name: 'Cancelar', exact: true }).click();
|
||||
await page.getByRole('link', { name: '+ Crear jukebox', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Crear primera franja' }).click();
|
||||
await page.getByRole('link', { name: 'Cancelar', exact: true }).click();
|
||||
await expect(page.locator('.coleccion .jukebox')).toHaveCount(2);
|
||||
});
|
||||
test('comprueba propietario, listas ajenas, revisión y la independencia de guardados', async ({
|
||||
page,
|
||||
request
|
||||
}) => {
|
||||
const endpoint = '/juke-box/' + primero + '/programacion';
|
||||
expect((await request.get(endpoint)).status()).toBe(401);
|
||||
const actual = await (await page.request.get(endpoint)).json();
|
||||
const headers = { origin: new URL(page.url()).origin };
|
||||
const ajena = structuredClone(actual);
|
||||
ajena.programacion.franjas[0].contenidos.push({ tipo: 'lista', id: listaAjena });
|
||||
expect((await page.request.put(endpoint, { headers, data: ajena })).status()).toBe(400);
|
||||
const otroJukebox = randomUUID();
|
||||
await db.query(
|
||||
'insert into jukebox(id,usuario_id,numero,zona_horaria,franjas) values($1,$2,1,$3,$4)',
|
||||
[otroJukebox, otroId, 'UTC', JSON.stringify(actual.programacion.franjas)]
|
||||
);
|
||||
expect((await page.request.get('/juke-box/' + otroJukebox + '/programacion')).status()).toBe(
|
||||
404
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await page.request.put('/juke-box/' + otroJukebox + '/programacion', {
|
||||
headers,
|
||||
data: actual
|
||||
})
|
||||
).status()
|
||||
).toBe(409);
|
||||
expect(
|
||||
(
|
||||
await page.request.delete('/juke-box/' + otroJukebox + '/programacion', {
|
||||
headers,
|
||||
data: { revision: 1 }
|
||||
})
|
||||
).status()
|
||||
).toBe(409);
|
||||
const revSegundo = await (
|
||||
await page.request.get('/juke-box/' + segundo + '/programacion')
|
||||
).json();
|
||||
actual.programacion.usuarioId = otroId;
|
||||
actual.programacion.franjas[0].nombre = 'Campo obsoleto';
|
||||
expect((await page.request.put(endpoint, { headers, data: actual })).status()).toBe(200);
|
||||
expect((await page.request.put(endpoint, { headers, data: actual })).status()).toBe(409);
|
||||
const recargado = await (await page.request.get(endpoint)).json();
|
||||
expect(recargado.programacion.usuarioId).toBeUndefined();
|
||||
expect(recargado.programacion.franjas[0].nombre).toBeUndefined();
|
||||
expect(
|
||||
(await (await page.request.get('/juke-box/' + segundo + '/programacion')).json()).revision
|
||||
).toBe(revSegundo.revision);
|
||||
expect(
|
||||
(
|
||||
await page.request.put(endpoint, {
|
||||
headers: { origin: 'https://ajena.example' },
|
||||
data: recargado
|
||||
})
|
||||
).status()
|
||||
).toBe(403);
|
||||
});
|
||||
test('el hero ocupa 100vh, conserva el play dorado y coloca las dos acciones debajo del título', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await expect(page.getByRole('heading', { name: 'Mis jukebox', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('contentinfo')).toHaveCount(1);
|
||||
expect(
|
||||
await page.getByRole('contentinfo').evaluate((pie) => pie.getBoundingClientRect().top)
|
||||
).toBe(1000);
|
||||
await page.locator('.heroe-jukebox img').evaluate((img: HTMLImageElement) => img.decode());
|
||||
const geometria = await page.locator('.heroe-jukebox').evaluate((h) => {
|
||||
const titulo = h.querySelector('h1')!.getBoundingClientRect();
|
||||
const botones = h.querySelector('.acciones-heroe')!.getBoundingClientRect();
|
||||
return {
|
||||
alto: h.getBoundingClientRect().height,
|
||||
ventana: innerHeight,
|
||||
izquierda: botones.x - titulo.x,
|
||||
debajo: botones.y >= titulo.bottom
|
||||
};
|
||||
});
|
||||
expect(geometria.alto).toBe(geometria.ventana);
|
||||
expect(geometria.izquierda).toBe(0);
|
||||
expect(geometria.debajo).toBe(true);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator('.titulo-heroe')
|
||||
.evaluate((h) =>
|
||||
Math.abs(
|
||||
h.querySelector('h1')!.getBoundingClientRect().width -
|
||||
h.querySelector('.entradilla span')!.getBoundingClientRect().width
|
||||
)
|
||||
)
|
||||
)
|
||||
.toBeLessThan(1);
|
||||
expect(
|
||||
await page
|
||||
.locator('.entradilla span')
|
||||
.evaluate((s) => parseFloat(getComputedStyle(s).fontSize))
|
||||
).toBeGreaterThan(24);
|
||||
await page.getByRole('button', { name: 'Play JukeBox', exact: true }).click();
|
||||
await expect(page.getByRole('list', { name: 'Tus jukebox' })).toContainText(
|
||||
'Música para empezar el día.'
|
||||
);
|
||||
await page.screenshot({ path: 'test-results-publica/jukebox-desplegable-oscuro.png' });
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByText('Piloto automático', { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Play JukeBox', exact: true })).toHaveClass(
|
||||
/btn--primary/
|
||||
);
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Configura tu Jukebox', exact: true })
|
||||
).not.toHaveClass(/btn--primary/);
|
||||
await page.screenshot({ path: 'test-results-publica/jukebox-hero-100vh-oscuro.png' });
|
||||
await page.getByRole('button', { name: 'Cambiar al tema claro' }).click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-tema', 'claro');
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((done) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => done()))
|
||||
)
|
||||
);
|
||||
await page.screenshot({ path: 'test-results-publica/jukebox-hero-100vh-claro.png' });
|
||||
await page.getByRole('button', { name: 'Play JukeBox', exact: true }).click();
|
||||
await page.screenshot({ path: 'test-results-publica/jukebox-desplegable-claro.png' });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
await page.locator('.heroe-jukebox').evaluate((h) => h.getBoundingClientRect().height)
|
||||
).toBe(844);
|
||||
await page.screenshot({ path: 'test-results-publica/jukebox-hero-100vh-movil.png' });
|
||||
expect(
|
||||
await page.getByRole('contentinfo').evaluate((pie) => pie.getBoundingClientRect().top)
|
||||
).toBe(844);
|
||||
await page.getByRole('link', { name: 'Configura tu Jukebox', exact: true }).click();
|
||||
await page.getByRole('link', { name: 'Editar Mañanas', exact: true }).click();
|
||||
await page.getByRole('button', { name: '+ Añadir contenido', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Canciones', exact: true }).click();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(
|
||||
true
|
||||
);
|
||||
const alturas = await page
|
||||
.locator('.selector .etiqueta')
|
||||
.evaluateAll((ns) => ns.map((n) => n.getBoundingClientRect().height));
|
||||
expect(Math.max(...alturas)).toBeLessThanOrEqual(26);
|
||||
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
|
||||
await page.screenshot({
|
||||
path: 'test-results-publica/jukebox-editor-movil.png',
|
||||
fullPage: true
|
||||
});
|
||||
const guardarFranja = page.getByRole('button', { name: 'Guardar franja', exact: true });
|
||||
const claro = await guardarFranja.evaluate((b) => getComputedStyle(b).backgroundColor);
|
||||
await expect(guardarFranja).toHaveCSS('background-image', 'none');
|
||||
await expect(page.locator('.toggle-rail')).toHaveCount(4);
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
|
||||
await page.screenshot({
|
||||
path: 'test-results-publica/jukebox-formulario-claro.png',
|
||||
fullPage: true
|
||||
});
|
||||
await page.getByRole('button', { name: 'Cambiar al tema oscuro' }).click();
|
||||
await expect
|
||||
.poll(() => guardarFranja.evaluate((b) => getComputedStyle(b).backgroundColor))
|
||||
.not.toBe(claro);
|
||||
await page.screenshot({
|
||||
path: 'test-results-publica/jukebox-formulario-oscuro.png',
|
||||
fullPage: true
|
||||
});
|
||||
});
|
||||
test('cambia de franja al navegar, aplica sus modos y reproduce audio real', async ({ page }) => {
|
||||
const endpoint = '/juke-box/' + primero + '/programacion';
|
||||
const actual = await (await page.request.get(endpoint)).json();
|
||||
actual.programacion = {
|
||||
zonaHoraria: 'UTC',
|
||||
franjas: [
|
||||
{
|
||||
id: 'primera',
|
||||
inicio: '09:00',
|
||||
fin: '10:00',
|
||||
orden: 'secuencial',
|
||||
catalogo: false,
|
||||
contenidos: [{ tipo: 'lista', id: listaId }]
|
||||
},
|
||||
{
|
||||
id: 'segunda',
|
||||
inicio: '10:00',
|
||||
fin: '11:00',
|
||||
orden: 'aleatoria',
|
||||
catalogo: false,
|
||||
contenidos: [{ tipo: 'cancion', id: canciones[1].slug }]
|
||||
}
|
||||
]
|
||||
};
|
||||
expect(
|
||||
(
|
||||
await page.request.put(endpoint, {
|
||||
headers: { origin: new URL(page.url()).origin },
|
||||
data: actual
|
||||
})
|
||||
).status()
|
||||
).toBe(200);
|
||||
await page.addInitScript(() => {
|
||||
window.Audio = new Proxy(window.Audio, {
|
||||
construct(Original, args) {
|
||||
const audio = Reflect.construct(Original, args) as HTMLAudioElement;
|
||||
audio.dataset.pruebaJukebox = '';
|
||||
document.body.append(audio);
|
||||
return audio;
|
||||
}
|
||||
});
|
||||
});
|
||||
await page.clock.setFixedTime(new Date('2026-09-14T08:59:50Z'));
|
||||
await page.goto('/juke-box?jukebox=' + primero);
|
||||
await page.locator('html[data-hidratado]').waitFor();
|
||||
await page.getByRole('button', { name: 'Play JukeBox', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Reproducir Mañanas', exact: true }).click();
|
||||
await expect(page.locator('.estado-jukebox')).toContainText('Esperando próxima franja');
|
||||
const respuesta = page.waitForResponse(
|
||||
(r) => r.url().includes('/escucha/' + canciones[0].slug) && [200, 206].includes(r.status())
|
||||
);
|
||||
await page.clock.setFixedTime(new Date('2026-09-14T09:00:01Z'));
|
||||
await respuesta;
|
||||
const audio = page.locator('audio[data-prueba-jukebox]');
|
||||
await expect
|
||||
.poll(() =>
|
||||
audio.evaluate(
|
||||
(a: HTMLAudioElement) => !a.paused && a.readyState >= 2 && a.currentTime > 0.05
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
await page.getByRole('banner').getByRole('link', { name: 'Música', exact: true }).click();
|
||||
await expect(page).toHaveURL('/musica');
|
||||
await page.clock.setFixedTime(new Date('2026-09-14T10:00:01Z'));
|
||||
await expect(page.locator('.control-jukebox')).toContainText('Franja 2');
|
||||
await expect(page.getByRole('region', { name: 'Reproductor', exact: true })).toContainText(
|
||||
canciones[1].titulo
|
||||
);
|
||||
await page.clock.setFixedTime(new Date('2026-09-14T11:00:01Z'));
|
||||
await expect(page.locator('.control-jukebox')).toContainText('Esperando próxima franja');
|
||||
await expect(page.getByRole('region', { name: 'Reproductor', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('group', { name: 'Sonando ahora', exact: true })).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Detener', exact: true }).click();
|
||||
const control = page.getByRole('group', { name: 'Jukebox', exact: true });
|
||||
const barra = page.getByRole('region', { name: 'Reproductor', exact: true });
|
||||
await expect(control).toContainText('Mañanas');
|
||||
await expect(control).toContainText('Detenido');
|
||||
await expect(control.getByRole('button', { name: 'Reproducir', exact: true })).toBeVisible();
|
||||
const guardado = await (await page.request.get(endpoint)).json();
|
||||
expect(guardado.nombre).toBe(actual.nombre);
|
||||
expect(guardado.programacion).toEqual(actual.programacion);
|
||||
await page.clock.setFixedTime(new Date('2026-09-15T09:15:00Z'));
|
||||
await page.waitForTimeout(1200);
|
||||
await expect(control).toContainText('Detenido');
|
||||
expect(await audio.evaluate((a: HTMLAudioElement) => a.paused)).toBe(true);
|
||||
await control.getByRole('button', { name: 'Reproducir', exact: true }).click();
|
||||
await expect(control).toContainText('Franja 1');
|
||||
await expect
|
||||
.poll(() => audio.evaluate((a: HTMLAudioElement) => !a.paused && a.currentTime > 0.05))
|
||||
.toBe(true);
|
||||
await control.getByRole('button', { name: 'Detener', exact: true }).click();
|
||||
await expect(control).toContainText('Detenido');
|
||||
await expect(barra.getByRole('group', { name: 'Sonando ahora', exact: true })).toHaveCount(0);
|
||||
expect(await audio.evaluate((a: HTMLAudioElement) => a.paused)).toBe(true);
|
||||
await barra.screenshot({ path: 'test-results-publica/jukebox-detenido-oscuro.png' });
|
||||
await page.getByRole('button', { name: 'Cambiar al tema claro', exact: true }).click();
|
||||
await barra.screenshot({ path: 'test-results-publica/jukebox-detenido-claro.png' });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await barra.screenshot({ path: 'test-results-publica/jukebox-detenido-movil.png' });
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)
|
||||
).toBeLessThanOrEqual(0);
|
||||
});
|
||||
test('interrumpe y recupera audio real, agrupa los controles y permite volver desde la configuración', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await page.clock.setFixedTime(new Date('2026-09-14T09:15:00Z'));
|
||||
await page.addInitScript(() => {
|
||||
window.Audio = new Proxy(window.Audio, {
|
||||
construct(Original, args) {
|
||||
const audio = Reflect.construct(Original, args) as HTMLAudioElement;
|
||||
audio.dataset.pruebaJukebox = '';
|
||||
document.body.append(audio);
|
||||
return audio;
|
||||
}
|
||||
});
|
||||
});
|
||||
await page.goto('/juke-box?jukebox=' + primero);
|
||||
await page.locator('html[data-hidratado]').waitFor();
|
||||
await page.getByRole('button', { name: 'Play JukeBox', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Reproducir Mañanas', exact: true }).click();
|
||||
const barra = page.getByRole('region', { name: 'Reproductor', exact: true });
|
||||
const control = barra.getByRole('group', { name: 'Jukebox', exact: true });
|
||||
const audio = page.locator('audio[data-prueba-jukebox]');
|
||||
const suena = async () => {
|
||||
await expect
|
||||
.poll(() =>
|
||||
audio.evaluate(
|
||||
(a: HTMLAudioElement) => !a.paused && a.readyState >= 2 && a.currentTime > 0
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
};
|
||||
await suena();
|
||||
await page.getByRole('banner').getByRole('link', { name: 'Música', exact: true }).click();
|
||||
await page.locator(`main a[href="/canciones/${canciones[1].slug}"]`).first().click();
|
||||
await expect(page).toHaveURL('/canciones/' + canciones[1].slug);
|
||||
await audio.evaluate((a: HTMLAudioElement) => {
|
||||
a.currentTime = 37;
|
||||
});
|
||||
await page.getByRole('button', { name: 'Escuchar completa', exact: true }).click();
|
||||
await expect(control).toContainText('Vuelve al terminar');
|
||||
await expect(barra.getByRole('group', { name: 'Sonando ahora', exact: true })).toContainText(
|
||||
canciones[1].titulo
|
||||
);
|
||||
await suena();
|
||||
|
||||
// El texto y sus acciones permanecen juntos, incluso con mucho ancho disponible.
|
||||
for (const [tema, width] of [
|
||||
['oscuro', 1440],
|
||||
['claro', 1440],
|
||||
['movil', 390]
|
||||
] as const) {
|
||||
if (tema === 'claro')
|
||||
await page.getByRole('button', { name: 'Cambiar al tema claro', exact: true }).click();
|
||||
await page.setViewportSize({ width, height: tema === 'movil' ? 844 : 1000 });
|
||||
await expect(
|
||||
control.getByRole('button', { name: 'Continuar', exact: true })
|
||||
).toBeInViewport();
|
||||
const medidas = await control.evaluate((grupo) => {
|
||||
const datos = grupo.querySelector('.datos')!.getBoundingClientRect();
|
||||
const botones = Array.from(grupo.querySelectorAll('button')).map((b) =>
|
||||
b.getBoundingClientRect()
|
||||
);
|
||||
return {
|
||||
separacion: botones[0].left - datos.right,
|
||||
entreBotones: botones[1].left - botones[0].right,
|
||||
desborde: document.documentElement.scrollWidth - innerWidth
|
||||
};
|
||||
});
|
||||
expect(medidas.separacion).toBeLessThanOrEqual(12);
|
||||
expect(medidas.separacion).toBeGreaterThanOrEqual(0);
|
||||
expect(medidas.entreBotones).toBeLessThanOrEqual(12);
|
||||
expect(medidas.desborde).toBeLessThanOrEqual(0);
|
||||
await barra.screenshot({ path: `test-results-publica/jukebox-retorno-${tema}.png` });
|
||||
await expect(barra.getByText('Calidad de escucha', { exact: true })).toHaveCount(0);
|
||||
const tipo = await control.evaluate((grupo) => ({
|
||||
nombre: getComputedStyle(grupo.querySelector('a')!).fontSize,
|
||||
boton: getComputedStyle(grupo.querySelector('button')!).fontSize,
|
||||
alto: grupo.querySelector('button')!.getBoundingClientRect().height
|
||||
}));
|
||||
expect(tipo).toEqual({ nombre: '16px', boton: '13px', alto: 36 });
|
||||
const estiloContinuar = await control
|
||||
.getByRole('button', { name: 'Continuar', exact: true })
|
||||
.evaluate((b) => ({
|
||||
fondo: getComputedStyle(b).backgroundImage,
|
||||
peso: getComputedStyle(b).fontWeight
|
||||
}));
|
||||
expect(estiloContinuar.fondo).toContain('linear-gradient');
|
||||
expect(estiloContinuar.peso).toBe('400');
|
||||
if (tema !== 'movil') {
|
||||
const continuar = control.getByRole('button', { name: 'Continuar', exact: true });
|
||||
const colorDorado = await continuar.evaluate((b) =>
|
||||
getComputedStyle(b).getPropertyValue('--c-accent-text').trim()
|
||||
);
|
||||
await expect(continuar).toHaveCSS('color', colorDorado);
|
||||
await continuar.hover();
|
||||
await continuar.evaluate(async (b) => {
|
||||
await Promise.all(b.getAnimations().map((a) => a.finished.catch(() => {})));
|
||||
});
|
||||
await expect(continuar).toHaveCSS('color', colorDorado);
|
||||
await barra.screenshot({
|
||||
path: `test-results-publica/jukebox-continuar-hover-${tema}.png`
|
||||
});
|
||||
await page.mouse.move(0, 0);
|
||||
await continuar.focus();
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(continuar).toBeFocused();
|
||||
await expect(continuar).toHaveCSS('color', colorDorado);
|
||||
await barra.screenshot({ path: `test-results-publica/jukebox-continuar-foco-${tema}.png` });
|
||||
}
|
||||
if (tema === 'movil') {
|
||||
expect((await barra.boundingBox())!.height).toBeLessThanOrEqual(170);
|
||||
await expect(
|
||||
barra.getByRole('button', { name: 'Tema anterior', exact: true })
|
||||
).toBeInViewport();
|
||||
await expect(
|
||||
barra.getByRole('button', { name: 'Tema siguiente', exact: true })
|
||||
).toBeInViewport();
|
||||
}
|
||||
}
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await control.getByRole('button', { name: 'Continuar', exact: true }).click();
|
||||
await suena();
|
||||
await expect(barra.getByRole('group', { name: 'Sonando ahora', exact: true })).toContainText(
|
||||
canciones[0].titulo
|
||||
);
|
||||
await expect
|
||||
.poll(() => audio.evaluate((a: HTMLAudioElement) => a.currentTime))
|
||||
.toBeGreaterThanOrEqual(37);
|
||||
expect(await audio.evaluate((a: HTMLAudioElement) => a.currentTime)).toBeLessThan(45);
|
||||
await expect(control.getByRole('button', { name: 'Continuar', exact: true })).toHaveCount(0);
|
||||
|
||||
// La pausa desde la ficha y desde la barra devuelve inmediatamente el jukebox.
|
||||
for (const origen of ['ficha', 'barra'] as const) {
|
||||
await page.getByRole('button', { name: 'Escuchar completa', exact: true }).click();
|
||||
await suena();
|
||||
await expect(control).toContainText('Vuelve al terminar');
|
||||
await (origen === 'ficha' ? page.locator('main') : barra)
|
||||
.getByRole('button', { name: 'Pausar', exact: true })
|
||||
.click();
|
||||
await expect(control).toContainText('Franja 1');
|
||||
await suena();
|
||||
await expect(barra.getByRole('group', { name: 'Sonando ahora', exact: true })).toContainText(
|
||||
canciones[0].titulo
|
||||
);
|
||||
await expect
|
||||
.poll(() => audio.evaluate((a: HTMLAudioElement) => a.currentTime))
|
||||
.toBeGreaterThanOrEqual(37);
|
||||
await expect(control.getByRole('button', { name: 'Continuar', exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
// Si el jukebox ya estaba pausado, una canción manual no levanta esa pausa.
|
||||
await barra.getByRole('button', { name: 'Pausar', exact: true }).click();
|
||||
await expect(control).toContainText('Franja 1 · En pausa');
|
||||
await page.getByRole('button', { name: 'Escuchar completa', exact: true }).click();
|
||||
await suena();
|
||||
await expect(control).toContainText('Jukebox en pausa');
|
||||
await page.locator('main').getByRole('button', { name: 'Pausar', exact: true }).click();
|
||||
await expect(control).toContainText('Franja 1 · En pausa');
|
||||
expect(await audio.evaluate((a: HTMLAudioElement) => a.paused)).toBe(true);
|
||||
await expect(barra.getByRole('group', { name: 'Sonando ahora', exact: true })).toContainText(
|
||||
canciones[0].titulo
|
||||
);
|
||||
await barra.getByRole('button', { name: 'Reproducir', exact: true }).click();
|
||||
await suena();
|
||||
|
||||
// Un final real del audio elegido vuelve automáticamente al mismo punto.
|
||||
await page.getByRole('button', { name: 'Escuchar completa', exact: true }).click();
|
||||
await suena();
|
||||
await audio.evaluate((a: HTMLAudioElement) => {
|
||||
a.currentTime = a.duration - 0.15;
|
||||
});
|
||||
await expect(control).toContainText('Franja 1');
|
||||
await suena();
|
||||
await expect
|
||||
.poll(() => audio.evaluate((a: HTMLAudioElement) => a.currentTime))
|
||||
.toBeGreaterThanOrEqual(37);
|
||||
|
||||
// El reloj cambia de franja durante la elección manual sin cortarla.
|
||||
await page.getByRole('button', { name: 'Escuchar completa', exact: true }).click();
|
||||
await suena();
|
||||
await page.clock.setFixedTime(new Date('2026-09-14T10:00:01Z'));
|
||||
await expect(control).toContainText('Vuelve al terminar');
|
||||
await audio.evaluate((a: HTMLAudioElement) => {
|
||||
a.currentTime = a.duration - 0.15;
|
||||
});
|
||||
await expect(control).toContainText('Franja 2');
|
||||
await suena();
|
||||
expect(await audio.evaluate((a: HTMLAudioElement) => a.currentTime)).toBeLessThan(10);
|
||||
await barra.getByRole('button', { name: 'Pausar', exact: true }).click();
|
||||
await page.clock.setFixedTime(new Date('2026-09-14T11:00:01Z'));
|
||||
// Dejamos pasar al menos una consulta real del reloj de programación.
|
||||
await page.waitForTimeout(1200);
|
||||
await expect(control).toContainText('Franja 2 · En pausa');
|
||||
expect(await audio.evaluate((a: HTMLAudioElement) => a.paused)).toBe(true);
|
||||
await barra.screenshot({ path: 'test-results-publica/jukebox-pausa-persistente.png' });
|
||||
await barra.getByRole('button', { name: 'Reproducir', exact: true }).click();
|
||||
await expect(control).toContainText('Esperando próxima franja');
|
||||
await expect(barra.getByRole('group', { name: 'Sonando ahora', exact: true })).toHaveCount(0);
|
||||
await barra.screenshot({ path: 'test-results-publica/jukebox-espera-compacta.png' });
|
||||
|
||||
await control.getByRole('link', { name: 'Mañanas', exact: true }).click();
|
||||
await expect(page).toHaveURL('/juke-box?jukebox=' + primero);
|
||||
await page.getByRole('link', { name: 'Configura tu Jukebox', exact: true }).click();
|
||||
await expect(page).toHaveURL('/juke-box/configurar');
|
||||
await expect(page.getByRole('link', { name: 'Volver al Juke-box', exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results-publica/jukebox-coleccion-claro.png' });
|
||||
await page.getByRole('link', { name: 'Editar Mañanas', exact: true }).click();
|
||||
await expect(page).toHaveURL('/juke-box/' + primero + '/editar');
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Editar Jukebox 1', exact: true })
|
||||
).toBeVisible();
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
const separacion = await page.evaluate(() => {
|
||||
const cabecera = document.querySelector('header')!.getBoundingClientRect();
|
||||
const migas = document.querySelector('.migas')!.getBoundingClientRect();
|
||||
return migas.top - cabecera.bottom;
|
||||
});
|
||||
expect(separacion).toBeGreaterThanOrEqual(0);
|
||||
expect(separacion).toBeLessThanOrEqual(32);
|
||||
expect((await page.locator('.agenda').boundingBox())!.y).toBeLessThanOrEqual(460);
|
||||
await page.screenshot({ path: 'test-results-publica/jukebox-editor-cabecera.png' });
|
||||
await page.getByRole('button', { name: 'Cambiar al tema oscuro', exact: true }).click();
|
||||
await page.screenshot({ path: 'test-results-publica/jukebox-editor-oscuro.png' });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.locator('.editor').scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: 'test-results-publica/jukebox-editor-movil.png' });
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)
|
||||
).toBeLessThanOrEqual(0);
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await page.getByRole('link', { name: 'Volver al Juke-box', exact: true }).click();
|
||||
await expect(page).toHaveURL('/juke-box?jukebox=' + primero);
|
||||
await control.getByRole('button', { name: 'Detener', exact: true }).click();
|
||||
await expect(barra).toBeVisible();
|
||||
await expect(control).toContainText('Detenido');
|
||||
});
|
||||
test('guardar una franja persiste sus cambios sin guardar el borrador de otra', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/juke-box/' + primero + '/editar');
|
||||
await page.locator('html[data-hidratado]').waitFor();
|
||||
await expect(page.getByLabel('Nombre del jukebox')).toHaveValue('Mañanas');
|
||||
await page.getByLabel('Descripción', { exact: true }).fill('Descripción editada y guardada.');
|
||||
await page.getByRole('button', { name: 'Aleatoria', exact: true }).click();
|
||||
await page.locator('.franjas .franja').nth(1).click();
|
||||
await page.getByLabel('Hasta', { exact: true }).fill('12:00');
|
||||
await page.getByRole('button', { name: 'Guardar franja', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toContainText('Franja 2 guardada');
|
||||
const guardado = await (
|
||||
await page.request.get('/juke-box/' + primero + '/programacion')
|
||||
).json();
|
||||
expect(guardado.nombre).toBe('Mañanas');
|
||||
expect(guardado.descripcion).toBe('Descripción editada y guardada.');
|
||||
expect(guardado.programacion.franjas[0].orden).toBe('secuencial');
|
||||
expect(guardado.programacion.franjas[1].fin).toBe('12:00');
|
||||
await expect(page.getByText('Cambios sin guardar', { exact: true })).toBeVisible();
|
||||
// Otra pestaña comprueba lo persistido sin restaurar el borrador del historial.
|
||||
const recargada = await page.context().newPage();
|
||||
await recargada.goto('/juke-box/' + primero + '/editar');
|
||||
await recargada.locator('html[data-hidratado]').waitFor();
|
||||
await expect(recargada.getByLabel('Descripción', { exact: true })).toHaveValue(
|
||||
'Descripción editada y guardada.'
|
||||
);
|
||||
await expect(
|
||||
recargada.getByRole('button', { name: 'Secuencial', exact: true })
|
||||
).toHaveAttribute('aria-pressed', 'true');
|
||||
await recargada.close();
|
||||
});
|
||||
test('detecta solapamientos y elimina solo el jukebox elegido', async ({ page }) => {
|
||||
await page.getByRole('link', { name: 'Configura tu Jukebox', exact: true }).click();
|
||||
await page.getByRole('link', { name: 'Editar Mañanas', exact: true }).click();
|
||||
await page.getByLabel('Hasta', { exact: true }).fill('10:30');
|
||||
await expect(page.getByRole('alert')).toContainText('solapa');
|
||||
await expect(page.getByRole('button', { name: 'Guardar jukebox' })).toBeDisabled();
|
||||
await page.getByRole('button', { name: 'Deshacer cambios', exact: true }).click();
|
||||
await page.getByRole('link', { name: 'Cancelar', exact: true }).click();
|
||||
await page.getByRole('link', { name: 'Editar Tardes', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Eliminar Jukebox', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Sí, eliminar Jukebox', exact: true }).click();
|
||||
await expect(page).toHaveURL('/juke-box/configurar');
|
||||
await expect(page.locator('.coleccion .jukebox')).toHaveCount(1);
|
||||
await expect(page.locator('.coleccion .jukebox')).toContainText('Mañanas');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,16 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
import { loadEnvFile } from 'node:process';
|
||||
|
||||
// Prueba local explícita: crea y elimina su propia cuenta suscrita.
|
||||
loadEnvFile('.env');
|
||||
process.env.E2E_JUKEBOX = '1';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: 'e2e',
|
||||
testMatch: ['jukebox*.e2e.ts', 'canciones-play-tabla.e2e.ts', 'albumes-tabla.e2e.ts'],
|
||||
workers: 1,
|
||||
timeout: 60_000,
|
||||
use: { baseURL: 'http://127.0.0.1:4180', headless: true, timezoneId: 'UTC' },
|
||||
reporter: 'list',
|
||||
outputDir: 'test-results-publica/jukebox-results'
|
||||
});
|
||||
@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
type AccionExploracion = {
|
||||
clave: string;
|
||||
texto: string;
|
||||
activa: boolean;
|
||||
alPulsar: () => void;
|
||||
controla?: string;
|
||||
expandida?: boolean;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
etiqueta: string;
|
||||
placeholder: string;
|
||||
valor?: string;
|
||||
acciones: AccionExploracion[];
|
||||
etiquetaAcciones: string;
|
||||
desplegada?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
etiqueta,
|
||||
placeholder,
|
||||
valor = $bindable(''),
|
||||
acciones,
|
||||
etiquetaAcciones,
|
||||
desplegada = false
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="barra" class:desplegada>
|
||||
<label class="visually-hidden" for={id}>{etiqueta}</label>
|
||||
<input {id} type="search" bind:value={valor} {placeholder} autocomplete="off" />
|
||||
|
||||
<div class="vistas" role="group" aria-label={etiquetaAcciones}>
|
||||
{#each acciones as accion (accion.clave)}
|
||||
<button
|
||||
type="button"
|
||||
class="vista"
|
||||
class:oro={accion.activa}
|
||||
aria-pressed={accion.activa}
|
||||
aria-expanded={accion.expandida}
|
||||
aria-controls={accion.controla}
|
||||
onclick={accion.alPulsar}
|
||||
>
|
||||
{accion.texto}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.barra {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-boton);
|
||||
background: var(--c-bg-sunken);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.barra.desplegada {
|
||||
border-radius: var(--radius-boton) var(--radius-boton) 0 0;
|
||||
}
|
||||
|
||||
.barra:focus-within {
|
||||
border-color: var(--c-accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--c-accent) 30%, transparent);
|
||||
}
|
||||
|
||||
input[type='search'] {
|
||||
flex: 1 1 12rem;
|
||||
min-width: 0;
|
||||
scroll-margin-top: calc(var(--altura-cabecera) + var(--sp-3));
|
||||
padding: var(--boton-relleno);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: var(--boton-texto);
|
||||
}
|
||||
|
||||
input[type='search']:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.vistas {
|
||||
display: flex;
|
||||
border-left: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.vista {
|
||||
padding: var(--boton-relleno);
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
font: inherit;
|
||||
font-size: var(--boton-texto);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--duration-fast) var(--ease-standard),
|
||||
color var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.vista:not(.oro) {
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.vista + .vista {
|
||||
border-left: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.vista:hover:not(.oro) {
|
||||
background-color: var(--c-bg-raised);
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
.barra {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
input[type='search'] {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.vistas {
|
||||
border-top: 1px solid var(--c-border);
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.vista {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vista {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
id: string;
|
||||
etiqueta: string;
|
||||
placeholder: string;
|
||||
valor?: string;
|
||||
}
|
||||
|
||||
let { id, etiqueta, placeholder, valor = $bindable('') }: Props = $props();
|
||||
</script>
|
||||
|
||||
<label class="visually-hidden" for={id}>{etiqueta}</label>
|
||||
<input {id} type="search" bind:value={valor} {placeholder} autocomplete="off" />
|
||||
|
||||
<style>
|
||||
input {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: var(--boton-relleno);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-boton);
|
||||
background: var(--c-bg-sunken);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: var(--boton-texto);
|
||||
}
|
||||
|
||||
input:focus-visible {
|
||||
outline: 2px solid var(--c-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
id,
|
||||
etiqueta,
|
||||
valor = $bindable(''),
|
||||
tipo = 'text',
|
||||
opciones,
|
||||
maxlength
|
||||
}: {
|
||||
id: string;
|
||||
etiqueta: string;
|
||||
valor?: string;
|
||||
tipo?: 'text' | 'time' | 'textarea';
|
||||
opciones?: { valor: string; texto: string }[];
|
||||
maxlength?: number;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="campo">
|
||||
<label for={id}>{etiqueta}</label>
|
||||
{#if opciones}
|
||||
<select {id} bind:value={valor}>
|
||||
{#each opciones as opcion (opcion.valor)}<option value={opcion.valor}>{opcion.texto}</option
|
||||
>{/each}
|
||||
</select>
|
||||
{:else if tipo === 'textarea'}
|
||||
<textarea {id} bind:value={valor} {maxlength} rows="3"></textarea>
|
||||
{:else}
|
||||
<input {id} type={tipo} bind:value={valor} {maxlength} required />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.campo {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
label {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: var(--control-height);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-boton);
|
||||
background: var(--c-bg);
|
||||
color: var(--c-text);
|
||||
font: inherit;
|
||||
font-size: var(--fs-base);
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { indice, contenido }: { indice: Snippet; contenido: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div class="container disposicion-con-indice">
|
||||
<aside class="indice">
|
||||
{@render indice()}
|
||||
</aside>
|
||||
|
||||
<div class="contenido">
|
||||
{@render contenido()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.disposicion-con-indice {
|
||||
display: grid;
|
||||
grid-template-columns: 13rem minmax(0, 1fr);
|
||||
gap: var(--sp-6);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.indice {
|
||||
position: sticky;
|
||||
top: calc(var(--altura-cabecera) + var(--sp-5));
|
||||
max-height: calc(100vh - var(--altura-cabecera) - var(--sp-6));
|
||||
overflow-y: auto;
|
||||
padding-top: var(--sp-7);
|
||||
}
|
||||
|
||||
.contenido {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
.disposicion-con-indice {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.indice {
|
||||
position: static;
|
||||
order: 1;
|
||||
max-height: none;
|
||||
padding-top: var(--sp-5);
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import { tonoDeEtiqueta } from '$lib/utils/etiquetas';
|
||||
|
||||
interface Props {
|
||||
texto: string;
|
||||
href?: string;
|
||||
activa?: boolean;
|
||||
etiquetaAccesible?: string;
|
||||
title?: string;
|
||||
contador?: number;
|
||||
/** Variante compacta para metadatos dentro de filas y tarjetas. */
|
||||
menuda?: boolean;
|
||||
alPulsar?: (evento: MouseEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
texto,
|
||||
href,
|
||||
activa = false,
|
||||
etiquetaAccesible,
|
||||
title,
|
||||
contador,
|
||||
menuda = false,
|
||||
alPulsar
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#snippet contenido()}
|
||||
<span>{texto}</span>
|
||||
{#if contador !== undefined}<span class="contador">{contador}</span>{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
class="etiqueta"
|
||||
class:activa
|
||||
class:menuda
|
||||
style="--tono: {tonoDeEtiqueta(texto)}"
|
||||
{href}
|
||||
aria-label={etiquetaAccesible}
|
||||
{title}
|
||||
onclick={alPulsar}
|
||||
>
|
||||
{@render contenido()}
|
||||
</a>
|
||||
{:else if alPulsar}
|
||||
<button
|
||||
type="button"
|
||||
class="etiqueta"
|
||||
class:activa
|
||||
class:menuda
|
||||
style="--tono: {tonoDeEtiqueta(texto)}"
|
||||
aria-label={etiquetaAccesible}
|
||||
aria-pressed={activa}
|
||||
{title}
|
||||
onclick={alPulsar}
|
||||
>
|
||||
{@render contenido()}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="etiqueta" class:menuda style="--tono: {tonoDeEtiqueta(texto)}" {title}>
|
||||
{@render contenido()}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.etiqueta {
|
||||
--claro: oklch(0.84 0.11 var(--tono));
|
||||
--apagado: oklch(0.52 0.08 var(--tono));
|
||||
--velo: oklch(0.84 0.11 var(--tono) / 0.14);
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
min-height: 1.75rem;
|
||||
padding: var(--sp-1) var(--sp-3);
|
||||
border: 1px solid var(--apagado);
|
||||
border-radius: var(--radius-boton);
|
||||
background: transparent;
|
||||
color: var(--claro);
|
||||
font: inherit;
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.35;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background-color var(--duration-normal) var(--ease-standard),
|
||||
border-color var(--duration-normal) var(--ease-standard);
|
||||
}
|
||||
|
||||
button.etiqueta {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/*
|
||||
* En tablas es información secundaria. La cápsula conserva el color y el
|
||||
* borde del sistema, pero ocupa una sola línea compacta y no fuerza la altura
|
||||
* táctil de los controles principales.
|
||||
*/
|
||||
.etiqueta.menuda {
|
||||
min-height: 1.5rem;
|
||||
padding: 0.0625rem var(--sp-2);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.etiqueta:is(a, button):hover,
|
||||
.etiqueta:is(a, button):focus-visible,
|
||||
.etiqueta.activa {
|
||||
border-color: var(--claro);
|
||||
background: var(--velo);
|
||||
color: var(--claro);
|
||||
}
|
||||
|
||||
.contador {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
:global(:root[data-tema='claro']) .etiqueta {
|
||||
--claro: oklch(0.45 0.13 var(--tono));
|
||||
--apagado: oklch(0.78 0.08 var(--tono));
|
||||
--velo: oklch(0.45 0.13 var(--tono) / 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 42rem) {
|
||||
.etiqueta:is(a, button):not(.menuda) {
|
||||
min-height: var(--control-height);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.etiqueta {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import Icono from './Icono.svelte';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
let {
|
||||
etiqueta,
|
||||
valor,
|
||||
opciones,
|
||||
cambiar
|
||||
}: {
|
||||
etiqueta: string;
|
||||
valor: string;
|
||||
opciones: readonly {
|
||||
valor: string;
|
||||
texto: string;
|
||||
icono?: ComponentProps<typeof Icono>['nombre'];
|
||||
}[];
|
||||
cambiar: (valor: string) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="toggle-rail" role="group" aria-label={etiqueta}>
|
||||
{#each opciones as opcion (opcion.valor)}
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-rail__option"
|
||||
aria-pressed={valor === opcion.valor}
|
||||
onclick={() => cambiar(opcion.valor)}
|
||||
>
|
||||
{#if opcion.icono}<Icono nombre={opcion.icono} tamano={18} />{/if}
|
||||
{opcion.texto}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { reproductor } from '$lib/player/player.svelte';
|
||||
$effect(() => {
|
||||
const usuarioId = page.data.usuario?.id;
|
||||
if (
|
||||
reproductor.programacion &&
|
||||
(reproductor.programacion.usuarioId !== usuarioId || !page.data.accesoJukebox)
|
||||
)
|
||||
reproductor.detenerProgramacion(true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:document onvisibilitychange={() => reproductor.comprobarProgramacion()} />
|
||||
@ -0,0 +1,215 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
let titulo: HTMLHeadingElement;
|
||||
let subtitulo: HTMLSpanElement;
|
||||
onMount(() => {
|
||||
let activo = true;
|
||||
const ajustar = () => {
|
||||
if (!activo || !titulo || !subtitulo) return;
|
||||
const ancho = titulo.getBoundingClientRect().width;
|
||||
const actual = subtitulo.getBoundingClientRect().width;
|
||||
if (actual > 0)
|
||||
subtitulo.style.fontSize =
|
||||
(parseFloat(getComputedStyle(subtitulo).fontSize) * ancho) / actual + 'px';
|
||||
};
|
||||
const observador = new ResizeObserver(ajustar);
|
||||
observador.observe(titulo);
|
||||
void document.fonts.ready.then(ajustar);
|
||||
return () => {
|
||||
activo = false;
|
||||
observador.disconnect();
|
||||
};
|
||||
});
|
||||
let {
|
||||
play,
|
||||
configurarHref,
|
||||
configurar,
|
||||
mensaje = '',
|
||||
estado,
|
||||
reproduciendo = false
|
||||
}: {
|
||||
play: Snippet;
|
||||
configurarHref: string;
|
||||
configurar?: (evento: MouseEvent) => void;
|
||||
mensaje?: string;
|
||||
estado?: Snippet;
|
||||
reproduciendo?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<header class="heroe-jukebox" class:reproduciendo>
|
||||
<picture class="imagen-heroe" aria-hidden="true">
|
||||
<source
|
||||
srcset="/images/jukebox/hero-768.webp 768w, /images/jukebox/hero-1280.webp 1280w, /images/jukebox/hero-1944.webp 1944w"
|
||||
sizes="100vw"
|
||||
type="image/webp"
|
||||
/>
|
||||
<img
|
||||
src="/images/jukebox/hero-1944.webp"
|
||||
alt=""
|
||||
width="1944"
|
||||
height="809"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
</picture>
|
||||
<div class="container contenido-heroe">
|
||||
<div class="texto-heroe">
|
||||
<div class="titulo-heroe">
|
||||
<h1 bind:this={titulo}>Juke-box</h1>
|
||||
<p class="entradilla"><span bind:this={subtitulo}>Tu música, a su hora.</span></p>
|
||||
</div>
|
||||
<div class="acciones-heroe">
|
||||
{@render play()}
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- La página resuelve esta ruta según el jukebox seleccionado. -->
|
||||
<a class="btn" href={configurarHref} onclick={configurar}>Configura tu Jukebox</a>
|
||||
{@render estado?.()}
|
||||
</div>
|
||||
{#if mensaje}<p class="mensaje-heroe" role="status">{mensaje}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.heroe-jukebox {
|
||||
--espacio-inferior-heroe: clamp(var(--sp-8), 12vh, calc(var(--sp-8) * 2));
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
margin-top: calc(var(--altura-cabecera) * -1);
|
||||
align-items: end;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
background: var(--c-media-bg-deep);
|
||||
}
|
||||
|
||||
.imagen-heroe,
|
||||
.imagen-heroe::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.imagen-heroe {
|
||||
z-index: -2;
|
||||
background: var(--c-media-bg);
|
||||
}
|
||||
|
||||
.imagen-heroe::after {
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--c-media-veil) 93%, transparent) 0%,
|
||||
color-mix(in srgb, var(--c-media-veil) 72%, transparent) 40%,
|
||||
transparent 72%
|
||||
),
|
||||
linear-gradient(
|
||||
270deg,
|
||||
color-mix(in srgb, var(--c-media-veil) 98%, transparent) 0%,
|
||||
color-mix(in srgb, var(--c-media-veil) 98%, transparent) 20%,
|
||||
transparent 32%
|
||||
),
|
||||
linear-gradient(
|
||||
0deg,
|
||||
color-mix(in srgb, var(--c-media-veil) 78%, transparent),
|
||||
transparent 48%
|
||||
);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.imagen-heroe img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: clamp(8rem, 20vw, 48rem);
|
||||
width: auto;
|
||||
max-width: none;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: right center;
|
||||
transform: translateY(calc(var(--sp-6) * -1));
|
||||
}
|
||||
|
||||
.contenido-heroe {
|
||||
width: 100%;
|
||||
padding-top: calc(var(--altura-cabecera) + var(--sp-7));
|
||||
padding-bottom: var(--espacio-inferior-heroe);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.reproduciendo .contenido-heroe {
|
||||
padding-bottom: calc(var(--espacio-inferior-heroe) + var(--player-height));
|
||||
}
|
||||
.texto-heroe {
|
||||
max-width: none;
|
||||
}
|
||||
.titulo-heroe {
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.texto-heroe h1,
|
||||
.texto-heroe p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.texto-heroe h1 {
|
||||
width: max-content;
|
||||
font-size: var(--fs-4xl);
|
||||
line-height: 0.95;
|
||||
}
|
||||
|
||||
.acciones-heroe {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-5);
|
||||
}
|
||||
.acciones-heroe > .btn {
|
||||
--c-text: var(--c-text-on-media);
|
||||
--c-border: var(--c-border-on-media);
|
||||
}
|
||||
|
||||
.texto-heroe .mensaje-heroe {
|
||||
margin-top: var(--sp-4);
|
||||
color: var(--c-marca-oro-brillo);
|
||||
font-size: var(--fs-base);
|
||||
}
|
||||
.texto-heroe .entradilla {
|
||||
width: max-content;
|
||||
margin-top: var(--sp-4);
|
||||
color: var(--c-text-on-media-muted);
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
.entradilla span {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 60rem) {
|
||||
.imagen-heroe img {
|
||||
position: static;
|
||||
transform: none;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
object-fit: cover;
|
||||
object-position: 63% center;
|
||||
}
|
||||
}
|
||||
@media (max-width: 34rem) {
|
||||
.imagen-heroe::after {
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--c-media-veil) 90%, transparent),
|
||||
color-mix(in srgb, var(--c-media-veil) 46%, transparent)
|
||||
),
|
||||
linear-gradient(
|
||||
0deg,
|
||||
color-mix(in srgb, var(--c-media-veil) 82%, transparent),
|
||||
transparent 55%
|
||||
);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import Migas, { type Miga } from '../Migas.svelte';
|
||||
let { migas, jukeboxId }: { migas: Miga[]; jukeboxId?: string } = $props();
|
||||
</script>
|
||||
|
||||
<div class="navegacion-jukebox">
|
||||
<Migas {migas} compacta />
|
||||
<a class="volver" href={resolve(jukeboxId ? `/juke-box?jukebox=${jukeboxId}` : '/juke-box')}>
|
||||
<span aria-hidden="true">←</span> Volver al Juke-box
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.navegacion-jukebox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--sp-4);
|
||||
row-gap: var(--sp-1);
|
||||
padding-block: var(--sp-3);
|
||||
}
|
||||
.volver {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
min-height: var(--control-height);
|
||||
font-size: var(--boton-texto);
|
||||
color: var(--c-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.volver:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,196 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import CampoBusquedaLocal from '../CampoBusquedaLocal.svelte';
|
||||
import Etiquetas from '../Etiquetas.svelte';
|
||||
import Icono from '../Icono.svelte';
|
||||
import GrupoToggle from '../GrupoToggle.svelte';
|
||||
import type { Cancion } from '$lib/types';
|
||||
import type { ListaDeReproduccion } from '$lib/server/listas-reproduccion';
|
||||
import type { FuenteJukebox } from '$lib/programacion-jukebox';
|
||||
let {
|
||||
listas,
|
||||
canciones,
|
||||
anadir,
|
||||
cerrar
|
||||
}: {
|
||||
listas: ListaDeReproduccion[];
|
||||
canciones: Cancion[];
|
||||
anadir: (fuente: FuenteJukebox) => void;
|
||||
cerrar: () => void;
|
||||
} = $props();
|
||||
let vista = $state<'lista' | 'cancion'>('lista');
|
||||
let busqueda = $state('');
|
||||
let aviso = $state('');
|
||||
const normalizar = (texto: string) =>
|
||||
texto
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLocaleLowerCase('es');
|
||||
const consulta = $derived(normalizar(busqueda.trim()));
|
||||
const listasVisibles = $derived(listas.filter((l) => normalizar(l.nombre).includes(consulta)));
|
||||
const temasVisibles = $derived(
|
||||
canciones.filter((c) => normalizar([c.titulo, ...c.etiquetas].join(' ')).includes(consulta))
|
||||
);
|
||||
function agregar(tipo: 'lista' | 'cancion', id: string, nombre: string) {
|
||||
anadir({ tipo, id });
|
||||
aviso = `«${nombre}» añadido a la franja.`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="selector" aria-label="Añadir contenido a la franja">
|
||||
<header>
|
||||
<h3>Añadir a esta franja</h3>
|
||||
<button class="btn" type="button" onclick={cerrar}
|
||||
>Listo <Icono nombre="cerrar" tamano={18} /></button
|
||||
>
|
||||
</header>
|
||||
<div class="tipos">
|
||||
<GrupoToggle
|
||||
etiqueta="Tipo de contenido"
|
||||
valor={vista}
|
||||
opciones={[
|
||||
{ valor: 'lista', texto: 'Mis listas' },
|
||||
{ valor: 'cancion', texto: 'Canciones' }
|
||||
]}
|
||||
cambiar={(valor) => (vista = valor === 'lista' ? 'lista' : 'cancion')}
|
||||
/>
|
||||
</div>
|
||||
<CampoBusquedaLocal
|
||||
id="buscar-contenido-jukebox"
|
||||
etiqueta="Buscar contenido para la franja"
|
||||
placeholder={vista === 'lista' ? 'Buscar en tus listas' : 'Buscar canciones o etiquetas'}
|
||||
bind:valor={busqueda}
|
||||
/>
|
||||
<p class="aviso muted" role="status">{aviso}</p>
|
||||
<div class="resultados" role="region" aria-label="Contenido disponible">
|
||||
{#if vista === 'lista'}
|
||||
{#each listasVisibles as lista (lista.id)}
|
||||
{@const disponibles = lista.canciones.filter((slug) =>
|
||||
canciones.some((c) => c.slug === slug)
|
||||
).length}
|
||||
<div class="resultado">
|
||||
<Icono nombre="lista" tamano={24} />
|
||||
<div class="datos">
|
||||
<strong>{lista.nombre}</strong><span class="muted"
|
||||
>Lista · {disponibles} canciones disponibles</span
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
class="btn"
|
||||
disabled={disponibles === 0}
|
||||
onclick={() => agregar('lista', lista.id, lista.nombre)}
|
||||
aria-label="Añadir lista {lista.nombre}">Añadir</button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted">
|
||||
{listas.length
|
||||
? 'No hay listas con ese nombre.'
|
||||
: 'Aún no tienes listas de reproducción.'}
|
||||
<a href={resolve('/listas')}>Ir a mis listas</a>
|
||||
</p>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each temasVisibles as cancion (cancion.slug)}
|
||||
<div class="resultado">
|
||||
{#if cancion.portada}<img
|
||||
src={cancion.portada}
|
||||
alt=""
|
||||
width="48"
|
||||
height="48"
|
||||
loading="lazy"
|
||||
/>{:else}<Icono nombre="nota" tamano={24} />{/if}
|
||||
<div class="datos">
|
||||
<strong>{cancion.titulo}</strong><Etiquetas
|
||||
etiquetas={cancion.etiquetas.slice(0, 3)}
|
||||
menudas
|
||||
enlaces={false}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="btn"
|
||||
onclick={() => agregar('cancion', cancion.slug, cancion.titulo)}
|
||||
aria-label="Añadir canción {cancion.titulo}">Añadir</button
|
||||
>
|
||||
</div>
|
||||
{:else}<p class="muted">No hay canciones disponibles con esa búsqueda.</p>{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.selector {
|
||||
padding: var(--sp-5);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-tarjeta);
|
||||
background: var(--c-bg);
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xl);
|
||||
}
|
||||
.tipos {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
.aviso {
|
||||
min-height: 1.5em;
|
||||
margin: var(--sp-2) 0;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
.resultados {
|
||||
max-height: 24rem;
|
||||
overflow: auto;
|
||||
padding-right: var(--sp-3);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.resultado {
|
||||
display: grid;
|
||||
grid-template-columns: 3rem minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding-block: var(--sp-3);
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
}
|
||||
.resultado:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.resultado img {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-detail);
|
||||
}
|
||||
.datos {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.datos strong {
|
||||
font-weight: var(--font-weight-medium);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.datos > span {
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
@media (max-width: 34rem) {
|
||||
.selector {
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
.resultado {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
.resultado > img,
|
||||
.resultado > :global(.icono) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import Icono from '../Icono.svelte';
|
||||
import { reproductor } from '$lib/player/player.svelte';
|
||||
</script>
|
||||
|
||||
{#if reproductor.programacion}
|
||||
<div
|
||||
class="control-jukebox"
|
||||
class:interrumpido={reproductor.jukeboxInterrumpido}
|
||||
role="group"
|
||||
aria-label="Jukebox"
|
||||
>
|
||||
<a
|
||||
class="emblema"
|
||||
href={resolve(`/juke-box?jukebox=${reproductor.programacion.jukeboxId}`)}
|
||||
aria-label="Abrir Juke-box"
|
||||
>
|
||||
<Icono nombre="jukebox" tamano={24} />
|
||||
</a>
|
||||
<div class="datos">
|
||||
<a
|
||||
class="nombre"
|
||||
href={resolve(`/juke-box?jukebox=${reproductor.programacion.jukeboxId}`)}
|
||||
title={reproductor.programacion.nombre}>{reproductor.programacion.nombre}</a
|
||||
>
|
||||
<span role="status">{reproductor.estadoJukebox}</span>
|
||||
</div>
|
||||
{#if reproductor.jukeboxInterrumpido || reproductor.programacionDetenida}
|
||||
<button class="btn btn--primary continuar" onclick={() => reproductor.reanudarProgramacion()}
|
||||
><Icono nombre="play" tamano={14} />{reproductor.programacionDetenida
|
||||
? 'Reproducir'
|
||||
: 'Continuar'}</button
|
||||
>
|
||||
{/if}
|
||||
{#if !reproductor.programacionDetenida}
|
||||
<button
|
||||
class="detener"
|
||||
aria-label="Detener"
|
||||
title="Detener jukebox"
|
||||
onclick={() => reproductor.detenerJukebox()}><Icono nombre="detener" tamano={18} /></button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.control-jukebox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex: 0 1 auto;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-overlay);
|
||||
background: var(--c-bg);
|
||||
font-size: var(--fs-base);
|
||||
}
|
||||
.emblema {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
color: var(--c-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.datos {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 0 1 auto;
|
||||
max-width: 16rem;
|
||||
line-height: var(--leading-ui);
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.nombre {
|
||||
color: var(--c-text);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nombre:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
span {
|
||||
color: var(--c-text-muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
.btn {
|
||||
flex: none;
|
||||
min-height: var(--control-height-compact);
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
}
|
||||
.btn.continuar {
|
||||
font-size: var(--boton-texto-corto);
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
.detener {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: var(--control-height-compact);
|
||||
height: var(--control-height-compact);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-round);
|
||||
background: transparent;
|
||||
color: var(--c-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.detener:hover {
|
||||
background: var(--c-bg-raised);
|
||||
color: var(--c-text);
|
||||
}
|
||||
@media (max-width: 48rem) {
|
||||
.control-jukebox {
|
||||
width: 100%;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,30 @@
|
||||
import type { Cancion } from './types';
|
||||
import { aPista, aPistas } from './player/pista';
|
||||
import { puedeEscucharCancion } from './acceso-canciones';
|
||||
import { cancionesDeFranja, type ProgramacionJukebox } from './programacion-jukebox';
|
||||
export function catalogoJukebox(
|
||||
canciones: Cancion[],
|
||||
acceso: { suscripcionActiva: boolean; accesosPrivados: string[] }
|
||||
) {
|
||||
return canciones.filter((c) => {
|
||||
const p = aPista(c);
|
||||
return Boolean(p.audio) && puedeEscucharCancion(p.acceso, p.recursoSlug, acceso);
|
||||
});
|
||||
}
|
||||
export function prepararFranjas(
|
||||
programacion: ProgramacionJukebox,
|
||||
listas: { id: string; canciones: string[] }[],
|
||||
canciones: Cancion[]
|
||||
) {
|
||||
return programacion.franjas.map((f, n) => ({
|
||||
...f,
|
||||
numero: n + 1,
|
||||
pistas: aPistas(
|
||||
cancionesDeFranja(
|
||||
f,
|
||||
listas,
|
||||
canciones.map((c) => c.slug)
|
||||
).flatMap((slug) => canciones.find((c) => c.slug === slug) ?? [])
|
||||
)
|
||||
}));
|
||||
}
|
||||
@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
cancionesDeFranja,
|
||||
contieneMinuto,
|
||||
franjaEnFecha,
|
||||
ocurrenciaDeFranja,
|
||||
validarProgramacion,
|
||||
type FranjaJukebox
|
||||
} from './programacion-jukebox';
|
||||
|
||||
const franja = (id: string, inicio = '08:00', fin = '10:00'): FranjaJukebox => ({
|
||||
id,
|
||||
orden: 'secuencial',
|
||||
catalogo: false,
|
||||
inicio,
|
||||
fin,
|
||||
contenidos: []
|
||||
});
|
||||
const programa = (franjas: FranjaJukebox[]) => ({ zonaHoraria: 'Europe/Madrid', franjas });
|
||||
describe('Programación diaria del Juke-box', () => {
|
||||
it('identifica una franja nocturna a ambos lados de medianoche en su zona horaria', () => {
|
||||
const nocturna = franja('noche', '22:00', '06:00');
|
||||
expect(ocurrenciaDeFranja(nocturna, 'Europe/Madrid', new Date('2026-09-14T21:00:00Z'))).toBe(
|
||||
'2026-09-14'
|
||||
);
|
||||
expect(ocurrenciaDeFranja(nocturna, 'Europe/Madrid', new Date('2026-09-15T03:00:00Z'))).toBe(
|
||||
'2026-09-14'
|
||||
);
|
||||
expect(ocurrenciaDeFranja(nocturna, 'Europe/Madrid', new Date('2026-09-15T21:00:00Z'))).toBe(
|
||||
'2026-09-15'
|
||||
);
|
||||
});
|
||||
it('incluye el inicio y excluye el final sin solapar franjas contiguas', () => {
|
||||
expect(contieneMinuto(franja('mañana'), 480)).toBe(true);
|
||||
expect(contieneMinuto(franja('mañana'), 600)).toBe(false);
|
||||
expect(
|
||||
validarProgramacion(programa([franja('uno'), franja('dos', '10:00', '12:00')]))
|
||||
).toBeNull();
|
||||
});
|
||||
it('resuelve franjas nocturnas y detecta su solapamiento a ambos lados de medianoche', () => {
|
||||
const noche = franja('noche', '22:00', '02:00');
|
||||
expect(contieneMinuto(noche, 60)).toBe(true);
|
||||
expect(contieneMinuto(noche, 720)).toBe(false);
|
||||
expect(validarProgramacion(programa([noche, franja('madrugada', '01:00', '03:00')]))).toContain(
|
||||
'solapa'
|
||||
);
|
||||
expect(validarProgramacion(programa([noche, franja('tarde', '21:00', '23:00')]))).toContain(
|
||||
'solapa'
|
||||
);
|
||||
});
|
||||
it('un horario de 24 horas ocupa el día completo', () => {
|
||||
expect(contieneMinuto(franja('día', '00:00', '00:00'), 1439)).toBe(true);
|
||||
expect(
|
||||
validarProgramacion(programa([franja('día', '00:00', '00:00'), franja('otra')]))
|
||||
).toContain('solapa');
|
||||
});
|
||||
it('usa la referencia horaria guardada y sus cambios de horario de verano', () => {
|
||||
expect(
|
||||
franjaEnFecha([franja('mañana')], 'Europe/Madrid', new Date('2026-07-01T06:30:00Z'))?.id
|
||||
).toBe('mañana');
|
||||
expect(
|
||||
franjaEnFecha([franja('mañana')], 'Europe/Madrid', new Date('2026-01-01T06:30:00Z'))
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
franjaEnFecha(
|
||||
[franja('noche', '22:00', '02:00')],
|
||||
'Europe/Madrid',
|
||||
new Date('2026-07-01T22:30:00Z')
|
||||
)?.id
|
||||
).toBe('noche');
|
||||
});
|
||||
it('rechaza datos malformados, horas inválidas e identificadores duplicados', () => {
|
||||
for (const valor of [
|
||||
null,
|
||||
{},
|
||||
{ zonaHoraria: null, franjas: [] },
|
||||
{ zonaHoraria: 'No/existe', franjas: [] },
|
||||
programa([franja('x', '24:30')]),
|
||||
programa([franja('x'), franja('x')])
|
||||
])
|
||||
expect(validarProgramacion(valor)).not.toBeNull();
|
||||
});
|
||||
it('expande las listas en el orden elegido, conserva repeticiones y admite una lista eliminada', () => {
|
||||
const f = {
|
||||
...franja('x'),
|
||||
contenidos: [
|
||||
{ tipo: 'cancion' as const, id: 'tres' },
|
||||
{ tipo: 'lista' as const, id: 'lista' },
|
||||
{ tipo: 'lista' as const, id: 'retirada' }
|
||||
]
|
||||
};
|
||||
expect(cancionesDeFranja(f, [{ id: 'lista', canciones: ['uno', 'tres', 'dos'] }])).toEqual([
|
||||
'tres',
|
||||
'uno',
|
||||
'tres',
|
||||
'dos'
|
||||
]);
|
||||
});
|
||||
it('el catálogo completo se resuelve al activar sin duplicar la selección guardada', () => {
|
||||
const f = {
|
||||
...franja('x'),
|
||||
catalogo: true,
|
||||
contenidos: [{ tipo: 'cancion' as const, id: 'uno' }]
|
||||
};
|
||||
expect(cancionesDeFranja(f, [], ['uno', 'dos', 'tres'])).toEqual(['uno', 'dos', 'tres']);
|
||||
expect(cancionesDeFranja({ ...f, catalogo: false }, [], ['uno', 'dos', 'tres'])).toEqual([
|
||||
'uno'
|
||||
]);
|
||||
});
|
||||
it('admite franjas sin nombre y exige un modo de reproducción conocido', () => {
|
||||
expect(validarProgramacion(programa([franja('x')]))).toBeNull();
|
||||
expect(validarProgramacion(programa([{ ...franja('x'), orden: 'otro' as never }]))).toContain(
|
||||
'modo'
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,172 @@
|
||||
/** Programación diaria. Las horas se interpretan en la zona elegida por la cuenta. */
|
||||
export type FuenteJukebox = { tipo: 'lista' | 'cancion'; id: string };
|
||||
export interface FranjaJukebox {
|
||||
id: string;
|
||||
orden: 'secuencial' | 'aleatoria';
|
||||
catalogo: boolean;
|
||||
inicio: string;
|
||||
fin: string;
|
||||
contenidos: FuenteJukebox[];
|
||||
}
|
||||
export interface ProgramacionJukebox {
|
||||
zonaHoraria: string;
|
||||
franjas: FranjaJukebox[];
|
||||
}
|
||||
export interface JukeboxGuardado {
|
||||
id: string;
|
||||
nombre: string;
|
||||
descripcion: string;
|
||||
numero: number;
|
||||
programacion: ProgramacionJukebox;
|
||||
revision: number;
|
||||
}
|
||||
export const PROGRAMACION_VACIA: ProgramacionJukebox = {
|
||||
zonaHoraria: 'Europe/Madrid',
|
||||
franjas: []
|
||||
};
|
||||
|
||||
export function minutosDeHora(hora: string): number {
|
||||
const [h, m] = hora.split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
/** Inicio incluido, fin excluido. Horas iguales significan un día completo. */
|
||||
export function contieneMinuto(
|
||||
franja: Pick<FranjaJukebox, 'inicio' | 'fin'>,
|
||||
minuto: number
|
||||
): boolean {
|
||||
const inicio = minutosDeHora(franja.inicio);
|
||||
const fin = minutosDeHora(franja.fin);
|
||||
return (
|
||||
inicio === fin ||
|
||||
(inicio < fin ? minuto >= inicio && minuto < fin : minuto >= inicio || minuto < fin)
|
||||
);
|
||||
}
|
||||
|
||||
export function franjaEnFecha<T extends Pick<FranjaJukebox, 'inicio' | 'fin'>>(
|
||||
franjas: T[],
|
||||
zonaHoraria: string,
|
||||
ahora = new Date()
|
||||
): T | undefined {
|
||||
const partes = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zonaHoraria,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23'
|
||||
}).formatToParts(ahora);
|
||||
const minuto =
|
||||
Number(partes.find((p) => p.type === 'hour')?.value) * 60 +
|
||||
Number(partes.find((p) => p.type === 'minute')?.value);
|
||||
return franjas.find((franja) => contieneMinuto(franja, minuto));
|
||||
}
|
||||
|
||||
/** Distingue la sesión de hoy de la de mañana, incluyendo franjas nocturnas. */
|
||||
export function ocurrenciaDeFranja(
|
||||
franja: Pick<FranjaJukebox, 'inicio' | 'fin'>,
|
||||
zonaHoraria: string,
|
||||
ahora = new Date()
|
||||
): string {
|
||||
if (franja.inicio === franja.fin) return 'continua';
|
||||
const partes = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zonaHoraria,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23'
|
||||
}).formatToParts(ahora);
|
||||
const valor = (tipo: Intl.DateTimeFormatPartTypes) =>
|
||||
Number(partes.find((p) => p.type === tipo)?.value);
|
||||
const minuto = valor('hour') * 60 + valor('minute');
|
||||
const diaAnterior = franja.inicio > franja.fin && minuto < minutosDeHora(franja.fin);
|
||||
return new Date(Date.UTC(valor('year'), valor('month') - 1, valor('day') - Number(diaAnterior)))
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
export function validarProgramacion(valor: unknown): string | null {
|
||||
if (!valor || typeof valor !== 'object') return 'La programación no es válida.';
|
||||
const { zonaHoraria, franjas } = valor as ProgramacionJukebox;
|
||||
if (typeof zonaHoraria !== 'string' || zonaHoraria.length > 80)
|
||||
return 'Elige una zona horaria válida.';
|
||||
try {
|
||||
new Intl.DateTimeFormat('es', { timeZone: zonaHoraria }).format();
|
||||
} catch {
|
||||
return 'Elige una zona horaria válida.';
|
||||
}
|
||||
if (!Array.isArray(franjas) || franjas.length > 48) return 'Puedes guardar hasta 48 franjas.';
|
||||
const ids = new Set<string>();
|
||||
const horas = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
const ocupados = new Array<string | undefined>(1440);
|
||||
for (const [indice, franja] of franjas.entries()) {
|
||||
if (
|
||||
!franja ||
|
||||
typeof franja.id !== 'string' ||
|
||||
!franja.id ||
|
||||
franja.id.length > 120 ||
|
||||
ids.has(franja.id)
|
||||
)
|
||||
return 'Cada franja debe tener un identificador único.';
|
||||
ids.add(franja.id);
|
||||
const etiqueta = `Franja ${indice + 1}`;
|
||||
if (!['secuencial', 'aleatoria'].includes(franja.orden) || typeof franja.catalogo !== 'boolean')
|
||||
return `Revisa el modo de reproducción y el contenido de ${etiqueta}.`;
|
||||
if (
|
||||
typeof franja.inicio !== 'string' ||
|
||||
typeof franja.fin !== 'string' ||
|
||||
!horas.test(franja.inicio) ||
|
||||
!horas.test(franja.fin)
|
||||
)
|
||||
return `Revisa las horas de «${etiqueta}».`;
|
||||
if (
|
||||
!Array.isArray(franja.contenidos) ||
|
||||
franja.contenidos.length > 100 ||
|
||||
franja.contenidos.some(
|
||||
(f) =>
|
||||
!f ||
|
||||
!['lista', 'cancion'].includes(f.tipo) ||
|
||||
typeof f.id !== 'string' ||
|
||||
!f.id ||
|
||||
f.id.length > 160
|
||||
)
|
||||
)
|
||||
return `Revisa las listas y canciones de «${etiqueta}».`;
|
||||
for (let minuto = 0; minuto < 1440; minuto++) {
|
||||
if (!contieneMinuto(franja, minuto)) continue;
|
||||
if (ocupados[minuto])
|
||||
return `«${etiqueta}» se solapa con «${ocupados[minuto]}». Ajusta sus horas.`;
|
||||
ocupados[minuto] = etiqueta;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Expande cada lista en su posición. Las repeticiones expresamente elegidas se conservan. */
|
||||
export function cancionesDeFranja(
|
||||
franja: FranjaJukebox,
|
||||
listas: { id: string; canciones: string[] }[],
|
||||
catalogo: string[] = []
|
||||
): string[] {
|
||||
if (franja.catalogo) return [...catalogo];
|
||||
return franja.contenidos.flatMap((fuente) =>
|
||||
fuente.tipo === 'cancion'
|
||||
? [fuente.id]
|
||||
: (listas.find((lista) => lista.id === fuente.id)?.canciones ?? [])
|
||||
);
|
||||
}
|
||||
|
||||
/** Descarta campos ajenos al modelo antes de persistir. */
|
||||
export function sanearProgramacion(p: ProgramacionJukebox): ProgramacionJukebox {
|
||||
return {
|
||||
zonaHoraria: p.zonaHoraria,
|
||||
franjas: p.franjas.map((f) => ({
|
||||
id: f.id,
|
||||
inicio: f.inicio,
|
||||
fin: f.fin,
|
||||
orden: f.orden,
|
||||
catalogo: f.catalogo,
|
||||
contenidos: f.contenidos.map((c) => ({ tipo: c.tipo, id: c.id }))
|
||||
}))
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { usuarioTieneAccesoJukebox } from './suscripciones';
|
||||
export async function exigirJukebox(locals: App.Locals, volverA: string) {
|
||||
if (!locals.usuario) redirect(303, '/entrar?volverA=' + encodeURIComponent(volverA));
|
||||
if (!(await usuarioTieneAccesoJukebox(locals.usuario.id)))
|
||||
error(403, 'Tu suscripción no incluye el Juke-box.');
|
||||
return locals.usuario.id;
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
import { integer, jsonb, pgTable, text, check, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { usuario } from './cuentas';
|
||||
import { ahora } from './comun';
|
||||
import type { FranjaJukebox } from '$lib/programacion-jukebox';
|
||||
|
||||
export const programacionJukebox = pgTable(
|
||||
'programacion_jukebox',
|
||||
{
|
||||
usuarioId: text('usuario_id')
|
||||
.primaryKey()
|
||||
.references(() => usuario.id, { onDelete: 'cascade' }),
|
||||
zonaHoraria: text('zona_horaria').notNull().default('Europe/Madrid'),
|
||||
franjas: jsonb('franjas').notNull().default([]).$type<FranjaJukebox[]>(),
|
||||
revision: integer('revision').notNull().default(1),
|
||||
actualizadaEn: ahora('actualizada_en')
|
||||
},
|
||||
(t) => [
|
||||
check('programacion_jukebox_franjas_array', sql`jsonb_typeof(${t.franjas}) = 'array'`),
|
||||
check('programacion_jukebox_revision_positiva', sql`${t.revision} > 0`)
|
||||
]
|
||||
);
|
||||
|
||||
export const jukebox = pgTable(
|
||||
'jukebox',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
usuarioId: text('usuario_id')
|
||||
.notNull()
|
||||
.references(() => usuario.id, { onDelete: 'cascade' }),
|
||||
numero: integer('numero').notNull(),
|
||||
nombre: text('nombre').notNull().default(''),
|
||||
descripcion: text('descripcion').notNull().default(''),
|
||||
zonaHoraria: text('zona_horaria').notNull(),
|
||||
franjas: jsonb('franjas').notNull().$type<FranjaJukebox[]>(),
|
||||
revision: integer('revision').notNull().default(1),
|
||||
actualizadaEn: ahora('actualizada_en')
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('jukebox_usuario_numero').on(t.usuarioId, t.numero),
|
||||
check('jukebox_franjas_array', sql`jsonb_typeof(${t.franjas}) = 'array'`),
|
||||
check('jukebox_revision_positiva', sql`${t.revision} > 0`)
|
||||
]
|
||||
);
|
||||
@ -0,0 +1,79 @@
|
||||
import { and, asc, eq, sql } from 'drizzle-orm';
|
||||
import { db } from './db';
|
||||
import { jukebox } from './db/schema';
|
||||
import {
|
||||
sanearProgramacion,
|
||||
type JukeboxGuardado,
|
||||
type ProgramacionJukebox
|
||||
} from '$lib/programacion-jukebox';
|
||||
function presentar(f: typeof jukebox.$inferSelect): JukeboxGuardado {
|
||||
return {
|
||||
id: f.id,
|
||||
nombre: f.nombre || `Jukebox ${f.numero}`,
|
||||
descripcion: f.descripcion,
|
||||
numero: f.numero,
|
||||
revision: f.revision,
|
||||
programacion: { zonaHoraria: f.zonaHoraria, franjas: f.franjas }
|
||||
};
|
||||
}
|
||||
export async function jukeboxesDeUsuario(usuarioId: string) {
|
||||
return (
|
||||
await db
|
||||
.select()
|
||||
.from(jukebox)
|
||||
.where(eq(jukebox.usuarioId, usuarioId))
|
||||
.orderBy(asc(jukebox.numero))
|
||||
).map(presentar);
|
||||
}
|
||||
export async function leerJukebox(usuarioId: string, id: string) {
|
||||
const [f] = await db
|
||||
.select()
|
||||
.from(jukebox)
|
||||
.where(and(eq(jukebox.usuarioId, usuarioId), eq(jukebox.id, id)));
|
||||
return f ? presentar(f) : null;
|
||||
}
|
||||
export async function guardarJukebox(
|
||||
usuarioId: string,
|
||||
id: string,
|
||||
programacion: ProgramacionJukebox,
|
||||
revision: number,
|
||||
nombre: string,
|
||||
descripcion: string
|
||||
) {
|
||||
const datos = sanearProgramacion(programacion);
|
||||
if (revision === 0)
|
||||
return db.transaction(async (tx) => {
|
||||
// Numeración por cuenta, también si se crean desde dos pestañas a la vez.
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${usuarioId}))`);
|
||||
const [ultimo] = await tx
|
||||
.select({ numero: sql<number>`coalesce(max(${jukebox.numero}),0)` })
|
||||
.from(jukebox)
|
||||
.where(eq(jukebox.usuarioId, usuarioId));
|
||||
const [creado] = await tx
|
||||
.insert(jukebox)
|
||||
.values({ id, usuarioId, nombre, descripcion, numero: Number(ultimo.numero) + 1, ...datos })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
return creado ? presentar(creado) : null;
|
||||
});
|
||||
const [guardado] = await db
|
||||
.update(jukebox)
|
||||
.set({ ...datos, nombre, descripcion, revision: revision + 1, actualizadaEn: new Date() })
|
||||
.where(
|
||||
and(eq(jukebox.usuarioId, usuarioId), eq(jukebox.id, id), eq(jukebox.revision, revision))
|
||||
)
|
||||
.returning();
|
||||
return guardado ? presentar(guardado) : null;
|
||||
}
|
||||
export async function eliminarJukebox(usuarioId: string, id: string, revision: number) {
|
||||
return (
|
||||
(
|
||||
await db
|
||||
.delete(jukebox)
|
||||
.where(
|
||||
and(eq(jukebox.usuarioId, usuarioId), eq(jukebox.id, id), eq(jukebox.revision, revision))
|
||||
)
|
||||
.returning({ id: jukebox.id })
|
||||
).length > 0
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { db } from './db';
|
||||
import { programacionJukebox } from './db/schema';
|
||||
import { PROGRAMACION_VACIA, type ProgramacionJukebox } from '$lib/programacion-jukebox';
|
||||
|
||||
export async function leerProgramacion(usuarioId: string) {
|
||||
const [guardada] = await db
|
||||
.select()
|
||||
.from(programacionJukebox)
|
||||
.where(eq(programacionJukebox.usuarioId, usuarioId));
|
||||
return {
|
||||
programacion: guardada
|
||||
? { zonaHoraria: guardada.zonaHoraria, franjas: guardada.franjas }
|
||||
: structuredClone(PROGRAMACION_VACIA),
|
||||
revision: guardada?.revision ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
/** Evita que otra pestaña sobrescriba silenciosamente una edición más reciente. */
|
||||
export async function guardarProgramacion(
|
||||
usuarioId: string,
|
||||
programa: ProgramacionJukebox,
|
||||
revision: number
|
||||
): Promise<number | undefined> {
|
||||
// Solo los campos editables: el propietario procede siempre de la sesión.
|
||||
const datos = { zonaHoraria: programa.zonaHoraria, franjas: programa.franjas };
|
||||
if (revision === 0) {
|
||||
const [creada] = await db
|
||||
.insert(programacionJukebox)
|
||||
.values({ usuarioId, ...datos })
|
||||
.onConflictDoNothing()
|
||||
.returning({ revision: programacionJukebox.revision });
|
||||
return creada?.revision;
|
||||
}
|
||||
const [guardada] = await db
|
||||
.update(programacionJukebox)
|
||||
.set({ ...datos, revision: revision + 1, actualizadaEn: new Date() })
|
||||
.where(
|
||||
sql`${programacionJukebox.usuarioId} = ${usuarioId} and ${programacionJukebox.revision} = ${revision}`
|
||||
)
|
||||
.returning({ revision: programacionJukebox.revision });
|
||||
return guardada?.revision;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue