diff --git a/e2e/musica.e2e.ts b/e2e/musica.e2e.ts
index e0eeb96..a295479 100644
--- a/e2e/musica.e2e.ts
+++ b/e2e/musica.e2e.ts
@@ -89,7 +89,9 @@ test.describe('Página de música', () => {
await columna.getByRole('button').click();
await expect(columna).toHaveAttribute('aria-sort', 'descending');
- const duraciones = await page.locator('tbody tr td:nth-child(6)').allInnerTexts();
+ // Por su clase y no por su posición: la tabla lleva portada, y el número
+ // de columna cambia con lo que se enseñe en cada página.
+ const duraciones = await page.locator('tbody td.duracion').allInnerTexts();
const enSegundos = duraciones.map((d) => {
const [m, s] = d.trim().split(':').map(Number);
return m * 60 + s;
diff --git a/e2e/navegacion.e2e.ts b/e2e/navegacion.e2e.ts
index 898e8c9..abe456d 100644
--- a/e2e/navegacion.e2e.ts
+++ b/e2e/navegacion.e2e.ts
@@ -5,7 +5,7 @@ test.describe('Navegación', () => {
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Senza Paura');
- await expect(page.getByText('Letrista y compositor').first()).toBeVisible();
+ await expect(page.getByText('Letrista y creador musical').first()).toBeVisible();
// La cabecera destaca un tema y ofrece el catálogo entero.
await page.getByRole('link', { name: 'Toda la música' }).click();
diff --git a/e2e/responsive.e2e.ts b/e2e/responsive.e2e.ts
index d5de119..cb2c0fa 100644
--- a/e2e/responsive.e2e.ts
+++ b/e2e/responsive.e2e.ts
@@ -90,9 +90,12 @@ test.describe('Responsive', () => {
// Por nombre y no por cuenta: en móvil no se recorta el menú, y así la
// prueba dice cuál falta en vez de que el número no cuadra.
- for (const seccion of ['Música', 'Taller de letras', 'Géneros', 'Blog', 'Bio', 'Contacto']) {
+ for (const seccion of ['Música', 'Taller de letras', 'Géneros', 'Blog', 'Bio']) {
await expect(nav.getByRole('link', { name: seccion })).toBeVisible();
}
+
+ // Contacto no: va solo en el pie, que es donde se busca escribir.
+ await expect(nav.getByRole('link', { name: 'Contacto' })).toHaveCount(0);
});
test('las barras laterales bajan en vez de desaparecer', async ({ page }) => {
diff --git a/e2e/tienda.e2e.ts b/e2e/tienda.e2e.ts
index 8db213d..71de243 100644
--- a/e2e/tienda.e2e.ts
+++ b/e2e/tienda.e2e.ts
@@ -5,15 +5,11 @@ const CANCIONES_POR_PACK = 10;
/** Añade un tema desde el listado de un álbum, por su título. */
async function anadir(page: Page, titulo: string) {
- // Dentro de la lista de temas, no en cualquier lista: las migas de pan
- // también son elementos de lista, y el título del disco se repite en ellas.
- const fila = page
- .getByRole('list', { name: 'Temas' })
- .getByRole('listitem')
- .filter({ hasText: titulo })
- .first();
- await fila.getByRole('button', { name: 'Añadir' }).click();
- await expect(fila.getByRole('button', { name: 'Quitar' })).toBeVisible();
+ // Dentro de la tabla de temas, no en cualquier sitio: el título del disco se
+ // repite en las migas de pan y en la cabecera.
+ const fila = page.getByRole('row').filter({ hasText: titulo }).first();
+ await fila.getByRole('button', { name: 'Añadir', exact: true }).click();
+ await expect(fila.getByRole('button', { name: 'Quitar', exact: true })).toBeVisible();
}
/**
@@ -31,9 +27,7 @@ async function llenarUnPack(page: Page, cuantas = CANCIONES_POR_PACK) {
if (puestas >= cuantas) break;
await page.goto(`/albumes/${album}`);
- const botones = page
- .getByRole('list', { name: 'Temas' })
- .getByRole('button', { name: 'Añadir' });
+ const botones = page.getByRole('table').getByRole('button', { name: 'Añadir', exact: true });
while (puestas < cuantas && (await botones.count()) > 0) {
await botones.first().click();
// El botón cambia a «Quitar», así que la cuenta de «Añadir» baja sola.
diff --git a/src/content/paginas/bio.md b/src/content/paginas/bio.md
index 0cc8bd8..6869e0e 100644
--- a/src/content/paginas/bio.md
+++ b/src/content/paginas/bio.md
@@ -1,6 +1,6 @@
---
titulo: 'Biografía'
-descripcion: 'Letrista y compositor. Escribe baladas románticas desde hace veinte años, y todo lo demás por curiosidad.'
+descripcion: 'Letrista y creador musical. Escribe baladas románticas desde hace veinte años, y todo lo demás por curiosidad.'
---
Escribo canciones desde antes de saber que eso era un oficio.
diff --git a/src/lib/components/BotonCola.svelte b/src/lib/components/BotonCola.svelte
new file mode 100644
index 0000000..c19f1a1
--- /dev/null
+++ b/src/lib/components/BotonCola.svelte
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+ {acusado ? `«${cancion.titulo}» añadida a la cola.` : ''}
+
+
+
+
diff --git a/src/lib/components/Etiquetas.svelte b/src/lib/components/Etiquetas.svelte
index 041f127..9440493 100644
--- a/src/lib/components/Etiquetas.svelte
+++ b/src/lib/components/Etiquetas.svelte
@@ -101,28 +101,14 @@
--velo: oklch(0.45 0.13 var(--tono) / 0.1);
}
- /* En una fila: sin caja, solo el nombre en su color y un punto delante. */
+ /* La misma cápsula, más pequeña: en una fila no debe pesar más que el título. */
.menudas {
- gap: var(--sp-3);
+ gap: var(--sp-2);
}
.menudas .etiqueta {
- padding: 0;
- border: 0;
+ padding: 0.1em 0.55em;
font-size: 0.6875rem;
- letter-spacing: 0.02em;
- }
-
- .menudas li + li .etiqueta::before {
- content: '·';
- margin-right: var(--sp-3);
- color: var(--c-text-muted);
- }
-
- .menudas a.etiqueta:hover,
- .menudas a.etiqueta:focus-visible {
- background: none;
- text-decoration: underline;
}
a.etiqueta:hover,
diff --git a/src/lib/components/Icono.svelte b/src/lib/components/Icono.svelte
index 557467b..277b2fb 100644
--- a/src/lib/components/Icono.svelte
+++ b/src/lib/components/Icono.svelte
@@ -31,6 +31,21 @@
},
silencio: { tipo: 'linea', d: ['M11 5 6 9H2v6h4l5 4z', 'M22 9l-6 6', 'M16 9l6 6'] },
cola: { tipo: 'linea', d: ['M4 6h11', 'M4 12h11', 'M4 18h7', 'M17 11l4 3-4 3z'] },
+ /* Las mismas tres rayas de la cola, con un «+» en vez de la flecha. */
+ 'anadir-cola': {
+ tipo: 'linea',
+ d: ['M4 6h11', 'M4 12h11', 'M4 18h7', 'M18 12v8', 'M14 16h8']
+ },
+ /* Una lista con una estrella: la lista propia, no la de ahora. */
+ lista: {
+ tipo: 'linea',
+ d: [
+ 'M4 6h10',
+ 'M4 12h10',
+ 'M4 18h6',
+ 'M17.5 12.5l1.4 2.9 3.1.4-2.3 2.2.6 3.1-2.8-1.5-2.8 1.5.6-3.1-2.3-2.2 3.1-.4z'
+ ]
+ },
cerrar: { tipo: 'linea', d: ['M6 6l12 12', 'M18 6 6 18'] },
menu: { tipo: 'linea', d: ['M4 7h16', 'M4 12h16', 'M4 17h16'] },
externo: {
diff --git a/src/lib/components/ListaCanciones.svelte b/src/lib/components/ListaCanciones.svelte
deleted file mode 100644
index 99f5a45..0000000
--- a/src/lib/components/ListaCanciones.svelte
+++ /dev/null
@@ -1,325 +0,0 @@
-
-
-{#if canciones.length === 0}
-
Todavía no hay temas publicados en esta sección.
-{:else}
-
- {#each canciones as cancion, i (cancion.slug)}
-
- {/if}
-{/if}
-
-
diff --git a/src/lib/components/ListaCanciones.svelte.spec.ts b/src/lib/components/ListaCanciones.svelte.spec.ts
deleted file mode 100644
index 51e8d46..0000000
--- a/src/lib/components/ListaCanciones.svelte.spec.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import { page } from '@vitest/browser/context';
-import { describe, expect, it } from 'vitest';
-import { render } from 'vitest-browser-svelte';
-import ListaCanciones from './ListaCanciones.svelte';
-import type { Cancion } from '$lib/types';
-
-function cancion(parcial: Partial = {}): Cancion {
- return {
- slug: 'como-si-nada',
- titulo: 'Como si nada',
- estilo: 'baladas-romanticas',
- album: 'como-si-nada',
- numero: 1,
- audio: 'como-si-nada/01-como-si-nada.wav',
- duracion: 252,
- ano: 2024,
- letra: ['Senza Paura'],
- musica: ['Senza Paura'],
- interpretes: ['Marta Cifuentes'],
- destacada: false,
- etiquetas: [],
- generos: [],
- enlaces: {},
- instrumental: false,
- idioma: 'es',
- inspiracion: [],
- extracto: [],
- versiones: [],
- alaVenta: true,
- registro: { reparto: [] },
- videos: [],
- ...parcial
- };
-}
-
-describe('ListaCanciones', () => {
- it('pinta el titulo y la duracion de cada tema', async () => {
- render(ListaCanciones, { canciones: [cancion()] });
-
- await expect.element(page.getByRole('link', { name: 'Como si nada' })).toBeInTheDocument();
- await expect.element(page.getByText('4:12')).toBeInTheDocument();
- });
-
- it('enlaza cada tema con su ficha', async () => {
- render(ListaCanciones, { canciones: [cancion()] });
-
- await expect
- .element(page.getByRole('link', { name: 'Como si nada' }))
- .toHaveAttribute('href', '/canciones/como-si-nada');
- });
-
- it('enseña de qué está hecho el tema, no su letra', async () => {
- // El primer verso ya no va aquí: la letra está entera en la ficha, y en
- // una lista lo que sirve para elegir es el género y las etiquetas.
- render(ListaCanciones, {
- canciones: [
- cancion({
- extracto: ['Entras en la casa y no preguntas nada,'],
- generos: [{ slug: 'bolero', nombre: 'Bolero' }],
- etiquetas: ['Desamor']
- })
- ]
- });
-
- await expect.element(page.getByRole('link', { name: 'Bolero' })).toBeInTheDocument();
- await expect.element(page.getByRole('link', { name: 'Desamor' })).toBeInTheDocument();
- expect(document.body.textContent).not.toContain('Entras en la casa');
- });
-
- it('cae en los intérpretes cuando no hay género ni etiqueta', async () => {
- render(ListaCanciones, {
- canciones: [cancion({ generos: [], etiquetas: [], interpretes: ['Marta Cifuentes'] })]
- });
-
- await expect.element(page.getByText('Marta Cifuentes')).toBeInTheDocument();
- });
-
- it('marca las piezas instrumentales en vez de acreditar la letra', async () => {
- render(ListaCanciones, {
- canciones: [cancion({ slug: 'preludio-i', titulo: 'Preludio I', instrumental: true })]
- });
-
- await expect.element(page.getByText(/Instrumental/)).toBeInTheDocument();
- });
-
- it('desactiva el play de los temas sin audio', async () => {
- // Se publica la ficha y la letra aunque el audio aun no exista; lo que no
- // puede pasar es que el boton prometa algo que no va a sonar.
- render(ListaCanciones, { canciones: [cancion({ audio: undefined })] });
-
- await expect
- .element(page.getByRole('button', { name: 'Reproducir Como si nada' }))
- .toBeDisabled();
- });
-
- it('muestra la duracion desconocida como guiones', async () => {
- render(ListaCanciones, { canciones: [cancion({ duracion: undefined })] });
-
- await expect.element(page.getByText('--:--')).toBeInTheDocument();
- });
-
- it('avisa cuando no hay temas', async () => {
- render(ListaCanciones, { canciones: [] });
-
- await expect.element(page.getByText(/no hay temas publicados/i)).toBeInTheDocument();
- });
-});
diff --git a/src/lib/components/TablaCanciones.svelte b/src/lib/components/TablaCanciones.svelte
index 0d783fb..da4f3bb 100644
--- a/src/lib/components/TablaCanciones.svelte
+++ b/src/lib/components/TablaCanciones.svelte
@@ -1,14 +1,6 @@
@@ -114,69 +205,212 @@
- Todos los temas. Cada encabezado ordena por su columna.
+ {ordenable
+ ? 'Los temas. Cada encabezado ordena por su columna.'
+ : 'Los temas, en el orden del disco.'}
+ {/if}
{/if}
diff --git a/src/lib/navegacion.ts b/src/lib/navegacion.ts
index 605d294..3e16ec6 100644
--- a/src/lib/navegacion.ts
+++ b/src/lib/navegacion.ts
@@ -17,6 +17,7 @@ export const navegacion: { href: ResolvedPathname; texto: string }[] = [
{ href: resolve('/taller'), texto: 'Taller de letras' },
{ href: resolve('/generos'), texto: 'Géneros' },
{ href: resolve('/blog'), texto: 'Blog' },
- { href: resolve('/bio'), texto: 'Bio' },
- { href: resolve('/contacto'), texto: 'Contacto' }
+ { href: resolve('/bio'), texto: 'Bio' }
+ // Contacto no está aquí: va solo en el pie. La barra es para lo que se viene
+ // a ver, y escribir no es de eso; en el pie es donde se busca.
];
diff --git a/src/lib/player/player.svelte.ts b/src/lib/player/player.svelte.ts
index 0881833..6a82bc2 100644
--- a/src/lib/player/player.svelte.ts
+++ b/src/lib/player/player.svelte.ts
@@ -283,6 +283,42 @@ class Reproductor {
this.cargar(true);
}
+ /**
+ * Anade pistas al final de la cola, sin tocar lo que suena.
+ *
+ * Hasta ahora la cola solo se podia sustituir: cada play la vaciaba y la
+ * llenaba con su contexto, asi que no habia forma de armar una escucha
+ * cogiendo temas de discos distintos. Esto es la otra mitad.
+ *
+ * Si no habia nada cargado, encolar arranca: pulsar "anadir a la cola" con el
+ * reproductor vacio y que no pase nada seria un boton que no hace nada.
+ *
+ * Lo que ya este en la cola no se repite. Un mismo tema dos veces seguidas no
+ * es lo que se pide al pulsar dos veces; lo que se pide es que este.
+ */
+ encolar(pistas: PistaReproductor[]): number {
+ const reproducibles = pistas.filter((p) => p.audio);
+ const yaEstan = new Set(this.cola.map((p) => p.id));
+ const nuevas = reproducibles.filter((p) => !yaEstan.has(p.id));
+
+ if (nuevas.length === 0) return 0;
+
+ if (this.cola.length === 0) {
+ this.reproducirLista(nuevas, 0);
+ return nuevas.length;
+ }
+
+ const antes = this.cola.length;
+ this.cola = [...this.cola, ...nuevas];
+ /*
+ * El orden se alarga por el final con las posiciones nuevas. En aleatorio
+ * tambien: barajarlo entero movería de sitio lo que aún no ha sonado, y
+ * quien encola espera que lo suyo vaya después, no en medio.
+ */
+ this.orden = [...this.orden, ...nuevas.map((_, i) => antes + i)];
+ return nuevas.length;
+ }
+
/**
* Alterna una pista concreta: si ya es la actual, pausa o reanuda; si no,
* la pone a sonar dentro de su contexto (el album del que forma parte).
diff --git a/src/lib/site.ts b/src/lib/site.ts
index 4efb037..a142bc7 100644
--- a/src/lib/site.ts
+++ b/src/lib/site.ts
@@ -13,9 +13,9 @@
export const site = {
nombre: 'Senza Paura',
/** Como se describe el artista en una linea. */
- titular: 'Letrista y compositor',
+ titular: 'Letrista y creador musical',
descripcion:
- 'Letrista y compositor. Baladas románticas, y también clásica, rock y otros estilos, agrupados por bloques.',
+ 'Letrista y creador musical. Baladas románticas, y también clásica, rock y otros estilos, agrupados por bloques.',
url: 'https://senzapaura.es',
idioma: 'es',
email: 'hola@senzapaura.es',
diff --git a/src/routes/albumes/[slug]/+page.svelte b/src/routes/albumes/[slug]/+page.svelte
index 9235a6f..e0a8843 100644
--- a/src/routes/albumes/[slug]/+page.svelte
+++ b/src/routes/albumes/[slug]/+page.svelte
@@ -2,7 +2,7 @@
import { resolve } from '$app/paths';
import BotonReproducir from '$lib/components/BotonReproducir.svelte';
import EnlacesPlataformas from '$lib/components/EnlacesPlataformas.svelte';
- import ListaCanciones from '$lib/components/ListaCanciones.svelte';
+ import TablaCanciones from '$lib/components/TablaCanciones.svelte';
import RejillaCanciones from '$lib/components/RejillaCanciones.svelte';
import Meta from '$lib/components/Meta.svelte';
import SinPortada from '$lib/components/SinPortada.svelte';
@@ -107,7 +107,11 @@
-->
{:else}
-
+
+
{/if}
diff --git a/src/routes/canciones/[slug]/+page.svelte b/src/routes/canciones/[slug]/+page.svelte
index 4bbfe50..b67860e 100644
--- a/src/routes/canciones/[slug]/+page.svelte
+++ b/src/routes/canciones/[slug]/+page.svelte
@@ -58,6 +58,22 @@
const enEspanol = $derived(cancion.idioma.split('-')[0] === 'es');
const aDosColumnas = $derived(!enEspanol && Boolean(cancion.traduccion));
+ /**
+ * Una letra larga a una columna hace la página el doble de alta de lo que
+ * hace falta: casi dos mil píxeles solo de versos. A partir de seis estrofas
+ * se parte en dos columnas, si hay ancho para ello.
+ *
+ * Seis y no cuatro porque el corte tiene que dejar algo en cada lado: con
+ * tres estrofas por columna ya se lee como dos bloques, y con dos parece que
+ * la letra se ha quedado a medias.
+ *
+ * No se hace nunca cuando hay traducción: ahí las dos columnas ya están
+ * ocupadas por el original y el castellano, que es un enfrentamiento verso a
+ * verso y no una letra partida.
+ */
+ const estrofas = $derived((letra?.match(/
]/g) ?? []).length);
+ const enColumnas = $derived(!aDosColumnas && !cancion.instrumental && estrofas >= 6);
+
/**
* El nombre del idioma en español, sin tabla que mantener. Si el navegador o
* el servidor no saben traducirlo, se queda el código, que sigue diciendo
@@ -182,7 +198,12 @@
{#if id === 'letra'}
{#if letra}
-
+
{#if aDosColumnas}
Letra original · {nombreIdioma}
{/if}
@@ -404,6 +425,36 @@
line-height: 1.75;
}
+ /*
+ * Dos columnas de verso, solo con ancho de sobra: `column-width` y no
+ * `columns: 2` para que el navegador se quede en una sola en cuanto no
+ * quepan dos de 34 caracteres, sin tener que acertar el punto de corte en un
+ * `@media`.
+ *
+ * `break-inside: avoid` en cada estrofa es lo que hace que esto se pueda
+ * hacer: sin ello el corte cae en mitad de una y la letra deja de leerse.
+ */
+ .texto.columnas {
+ max-width: none;
+ /*
+ * 37ch es el mínimo que deja caber dos columnas en la columna principal
+ * —864 px— y, a la vez, el verso más largo del catálogo, que mide 36.
+ * Importa afinarlo: en una letra, dónde acaba cada verso es parte del
+ * texto, así que una columna que doble renglones ahorra alto rompiendo
+ * justo lo que no se puede romper.
+ *
+ * Con `column-width` y no `columns: 2`: donde no quepan dos de 37ch, el
+ * navegador se queda en una sola sin que haya que acertar el punto de
+ * corte en un `@media`.
+ */
+ column-width: 37ch;
+ column-gap: var(--sp-7);
+ }
+
+ .texto.columnas :global(p) {
+ break-inside: avoid;
+ }
+
.videos {
display: grid;
gap: var(--sp-4);
diff --git a/src/routes/musica/+page.svelte b/src/routes/musica/+page.svelte
index e9dc7ad..ac30afe 100644
--- a/src/routes/musica/+page.svelte
+++ b/src/routes/musica/+page.svelte
@@ -2,7 +2,8 @@
import Meta from '$lib/components/Meta.svelte';
import RejillaCanciones from '$lib/components/RejillaCanciones.svelte';
import BloqueDeEstilo from '$lib/components/BloqueDeEstilo.svelte';
- import TablaCanciones, { type FilaCancion } from '$lib/components/TablaCanciones.svelte';
+ import TablaCanciones from '$lib/components/TablaCanciones.svelte';
+ import type { Cancion } from '$lib/types';
let { data } = $props();
@@ -11,18 +12,28 @@
let consulta = $state('');
/**
- * Una fila por tema, con su estilo y su disco al lado. Sale de los bloques
- * que ya vienen cargados, así que no viaja ni un byte de más.
+ * Todos los temas, en plano. El estilo y el disco de cada uno los resuelve la
+ * tabla contra el catálogo; aquí solo hacen falta para buscar, y para eso se
+ * arma aparte un índice de texto.
*/
- const filas = $derived(
+ const filas = $derived(
data.bloques.flatMap((bloque) =>
bloque.albumes.flatMap((album) =>
album.canciones.map((cancion) => ({
cancion,
- estiloSlug: bloque.estilo.slug,
- estiloNombre: bloque.estilo.nombre,
- albumSlug: album.slug,
- albumTitulo: album.titulo
+ /* Todo lo que identifica un tema, junto y sin tildes: quien
+ recuerda «el dueto de baladas» o «desamor» tiene que llegar. */
+ buscable: llano(
+ [
+ cancion.titulo,
+ bloque.estilo.nombre,
+ album.titulo,
+ String(cancion.ano ?? ''),
+ ...cancion.etiquetas,
+ ...cancion.generos.map((g) => g.nombre),
+ ...cancion.interpretes
+ ].join(' ')
+ )
}))
)
)
@@ -36,24 +47,10 @@
.toLowerCase();
}
- /**
- * Se busca en todo lo que identifica un tema, no solo en el título: quien
- * recuerda «el dueto de baladas» o «desamor» tiene que llegar igual.
- */
- function coincide(fila: FilaCancion, termino: string): boolean {
- const campos = [
- fila.cancion.titulo,
- fila.estiloNombre,
- fila.albumTitulo,
- String(fila.cancion.ano ?? ''),
- ...fila.cancion.etiquetas,
- ...fila.cancion.interpretes
- ];
- return campos.some((campo) => llano(campo).includes(termino));
- }
-
const termino = $derived(llano(consulta.trim()));
- const filtradas = $derived(termino ? filas.filter((f) => coincide(f, termino)) : filas);
+ const filtradas = $derived(
+ (termino ? filas.filter((f) => f.buscable.includes(termino)) : filas).map((f) => f.cancion)
+ );
const buscando = $derived(termino.length > 0);
@@ -118,13 +115,13 @@
{#if vista === 'tabla'}
-
+
{:else if buscando}
- f.cancion)} vacio="Ningún tema coincide." />
+
{:else}
{#each data.bloques as bloque (bloque.estilo.slug)}