You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
senzapaura_es/src/routes/canciones/[slug]/+page.svelte

677 lines
17 KiB

<script lang="ts">
import { resolve } from '$app/paths';
import BotonCarrito from '$lib/components/BotonCarrito.svelte';
import BotonFavorito from '$lib/components/BotonFavorito.svelte';
import BotonReproducir from '$lib/components/BotonReproducir.svelte';
import EnlacesPlataformas from '$lib/components/EnlacesPlataformas.svelte';
import Meta from '$lib/components/Meta.svelte';
import Pestanas, { type Pestana } from '$lib/components/Pestanas.svelte';
import Valoracion from '$lib/components/Valoracion.svelte';
import VideoYoutube from '$lib/components/VideoYoutube.svelte';
import { slugDeEtiqueta } from '$lib/content';
import { aPista, aPistaDeVersion, aPistas } from '$lib/player/pista';
import { CANCIONES_POR_PACK, formatearPrecio, PRECIO_PACK_CENTIMOS } from '$lib/tienda';
import { listaDeNombres, segundosADuracion } from '$lib/utils/format';
import Migas from '$lib/components/Migas.svelte';
let { data } = $props();
const cancion = $derived(data.cancion);
const Cuerpo = $derived(data.Cuerpo);
/** El contexto es el álbum entero; el índice, la posición del tema en él. */
const pistas = $derived(aPistas(data.hermanas));
const indice = $derived(data.hermanas.findIndex((c) => c.slug === cancion.slug));
/**
* Las alternativas musicales forman su propio contexto, con la versión
* principal a la cabeza: así se pueden comparar arreglos seguidos sin que
* el reproductor salte al tema siguiente del disco.
*/
const pistasDeVersiones = $derived([
aPista(cancion),
...cancion.versiones.map((v) => aPistaDeVersion(cancion, v))
]);
/**
* Las pestañas del cuerpo. La letra va primero y abierta: es el contenido
* principal de la ficha. Las otras dos solo aparecen si el tema las tiene.
*/
const pestanas: Pestana[] = $derived([
{ id: 'letra', etiqueta: cancion.instrumental ? 'Sobre la pieza' : 'Letra' },
...(cancion.inspiracion.length > 0 ? [{ id: 'inspiracion', etiqueta: 'De dónde sale' }] : []),
...(cancion.versiones.length > 0
? [{ id: 'versiones', etiqueta: 'Alternativas musicales' }]
: []),
...(cancion.videos.length > 0
? [{ id: 'videos', etiqueta: cancion.videos.length === 1 ? 'Vídeo' : 'Vídeos' }]
: [])
]);
/**
* La letra va a dos columnas cuando no está en español y hay traducción:
* original a la izquierda, castellano a la derecha. Con una sola de las dos
* cosas no hay nada que enfrentar, y una columna vacía sería peor que
* ninguna.
*/
const enEspanol = $derived(cancion.idioma.split('-')[0] === 'es');
const aDosColumnas = $derived(!enEspanol && Boolean(cancion.traduccion));
/**
* 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
* algo.
*/
const nombreIdioma = $derived.by(() => {
try {
return (
new Intl.DisplayNames(['es'], { type: 'language' }).of(cancion.idioma) ?? cancion.idioma
);
} catch {
return cancion.idioma;
}
});
const registro = $derived(cancion.registro);
/** El rol se guarda sin tildes por ser clave de datos; se pinta con ellas. */
const etiquetasRol: Record<string, string> = {
letra: 'Letra',
musica: 'Música',
'letra y musica': 'Letra y música'
};
const hayRegistro = $derived(
Boolean(
registro.iswc ||
registro.isrc ||
registro.entidad ||
registro.editorial ||
registro.reparto.length > 0
)
);
const descripcion = $derived(
`${cancion.titulo}. Letra de ${listaDeNombres(cancion.letra)}, música de ${listaDeNombres(cancion.musica)}.`
);
const migas = $derived([
{ texto: 'Música', href: resolve('/musica') },
...(data.estilo
? [
{
texto: data.estilo.nombre,
href: resolve('/musica/[estilo]', { estilo: data.estilo.slug })
}
]
: []),
...(data.album
? [{ texto: data.album.titulo, href: resolve('/albumes/[slug]', { slug: data.album.slug }) }]
: []),
{ texto: cancion.titulo }
]);
</script>
<Meta titulo={cancion.titulo} {descripcion} imagen={data.portada} />
<div class="container">
<Migas {migas} />
<header class="ficha">
<img
class="portada"
src={data.portada}
alt="Portada de {cancion.titulo}"
width="280"
height="280"
/>
<div class="titulo">
<h1>{cancion.titulo}</h1>
<p class="meta muted">
<!--
El album de singles es sintetico y se titula «Singles», asi que
nombrarlo aqui diria «Single del estilo Singles». Lo que situa un
tema suelto es su estilo; a la coleccion se llega por las migas.
-->
{#if data.album?.sintetico && data.estilo}
Single del estilo
<a href={resolve('/musica/[estilo]', { estilo: data.estilo.slug })}>
{data.estilo.nombre}
</a>
{:else if data.album}
Del disco
<a href={resolve('/albumes/[slug]', { slug: data.album.slug })}>{data.album.titulo}</a>
{/if}
{#if cancion.ano}
<span aria-hidden="true">&middot;</span> {cancion.ano}
{/if}
{#if cancion.duracion}
<span aria-hidden="true">&middot;</span>
{segundosADuracion(cancion.duracion)}
{/if}
</p>
<div class="acciones">
<BotonReproducir
{pistas}
indice={Math.max(0, indice)}
texto="Escuchar la muestra"
variante="solido"
tamano={18}
/>
<BotonCarrito {cancion} />
<BotonFavorito cancionSlug={cancion.slug} />
</div>
{#if cancion.alaVenta}
<p class="venta muted">
En la web suena un fragmento. La descarga completa en MP3, con todas sus versiones, va en
packs de {CANCIONES_POR_PACK} canciones por {formatearPrecio(PRECIO_PACK_CENTIMOS)}.
</p>
{/if}
<EnlacesPlataformas enlaces={cancion.enlaces} etiqueta="Escuchar {cancion.titulo} en" />
</div>
</header>
<div class="cuerpo">
<div class="principal">
<Pestanas {pestanas} etiqueta="Contenido del tema">
{#snippet panel(id)}
{#if id === 'letra'}
{#if Cuerpo}
<div class="letra" class:pareada={aDosColumnas}>
<div class="texto" class:versos={!cancion.instrumental} lang={cancion.idioma}>
{#if aDosColumnas}
<p class="rotulo-columna muted">Letra original · {nombreIdioma}</p>
{/if}
<Cuerpo />
</div>
{#if aDosColumnas}
<!--
`pre-line` conserva el corte de los versos sin necesidad de
compilar Markdown: una traducción que pierde los cortes deja
de servir para seguir la original línea a línea.
-->
<div class="texto versos traduccion" lang="es">
<p class="rotulo-columna muted">Traducción al español</p>
<p class="verso-a-verso">{cancion.traduccion}</p>
</div>
{/if}
</div>
{:else}
<p class="muted">
{cancion.instrumental
? 'Todavía no hay notas publicadas de esta pieza.'
: 'La letra de este tema aún no está publicada.'}
</p>
{/if}
{:else if id === 'inspiracion'}
<div class="texto">
{#each cancion.inspiracion as parrafo, i (i)}
<p>{parrafo}</p>
{/each}
</div>
{:else if id === 'videos'}
<div class="videos">
{#each cancion.videos as video (video.youtube)}
<VideoYoutube youtube={video.youtube} titulo={video.titulo} poster={data.portada} />
{/each}
</div>
<p class="muted aviso">
El vídeo se carga solo al pulsarlo: hasta entonces esta página no pide nada a YouTube.
</p>
{:else if id === 'versiones'}
<p class="muted">
El mismo tema en otros arreglos. Quien compra la canción se lleva todas sus versiones.
</p>
<ul class="versiones">
{#each cancion.versiones as version, i (version.id)}
<li class="version">
<BotonReproducir pistas={pistasDeVersiones} indice={i + 1} />
<div class="datos">
<p class="nombre">{version.nombre}</p>
{#if version.nota}
<p class="nota muted">{version.nota}</p>
{/if}
</div>
<span class="duracion muted">
{version.duracion ? segundosADuracion(version.duracion) : '--:--'}
</span>
</li>
{/each}
</ul>
{/if}
{/snippet}
</Pestanas>
</div>
<aside class="lateral">
<!--
Los datos del tema y la autoría van separados a propósito: la ficha
son hechos del registro (cuándo, dónde, cuánto dura) y los créditos
son personas. Mezclarlos en una sola lista los vuelve ilegibles.
-->
<section class="bloque">
<Valoracion
cancionSlug={cancion.slug}
media={data.valoracion.media}
cuantas={data.valoracion.cuantas}
mia={data.miValoracion}
/>
</section>
<section class="bloque">
<h2>Ficha</h2>
<dl>
{#if data.album}
<dt>{data.album.sintetico ? 'Publicación' : 'Álbum'}</dt>
<dd>
<a href={resolve('/albumes/[slug]', { slug: data.album.slug })}>
{data.album.sintetico ? 'Single suelto' : data.album.titulo}
</a>
</dd>
{#if !data.album.sintetico && cancion.numero !== null}
<dt>Pista</dt>
<dd>{cancion.numero} de {data.album.canciones.length}</dd>
{/if}
{/if}
{#if cancion.ano}
<dt>Año</dt>
<dd>{cancion.ano}</dd>
{/if}
{#if cancion.duracion}
<dt>Duración</dt>
<dd>{segundosADuracion(cancion.duracion)}</dd>
{/if}
{#if cancion.versiones.length > 0}
<dt>Versiones</dt>
<dd>{cancion.versiones.length + 1}</dd>
{/if}
{#if data.estilo}
<dt>Estilo</dt>
<dd>
<a href={resolve('/musica/[estilo]', { estilo: data.estilo.slug })}>
{data.estilo.nombre}
</a>
</dd>
{/if}
{#if cancion.etiquetas.length > 0}
<dt>Etiquetas</dt>
<dd>
<ul class="chips">
{#each cancion.etiquetas as etiqueta (etiqueta)}
<li>
<a
class="chip"
href={resolve('/etiquetas/[etiqueta]', {
etiqueta: slugDeEtiqueta(etiqueta)
})}
>
{etiqueta}
</a>
</li>
{/each}
</ul>
</dd>
{/if}
{#if data.album?.sello}
<dt>Sello</dt>
<dd>{data.album.sello}</dd>
{/if}
</dl>
</section>
<section class="bloque">
<h2>Créditos</h2>
<dl>
{#if !cancion.instrumental}
<dt>Letra</dt>
<dd>{listaDeNombres(cancion.letra)}</dd>
{/if}
<dt>Música</dt>
<dd>{listaDeNombres(cancion.musica)}</dd>
{#if cancion.interpretes.length > 0}
<dt>Interpretación</dt>
<dd>{listaDeNombres(cancion.interpretes)}</dd>
{/if}
</dl>
</section>
{#if hayRegistro}
<!--
Los identificadores son públicos por su naturaleza —el ISRC va en
cada plataforma, el ISWC en los repertorios— y quien quiera
licenciar la obra los necesita para localizarla y saber con quién
negociar. Lo que no se rellene, no se pinta.
-->
<section class="bloque">
<h2>Registro</h2>
<dl>
{#if registro.iswc}
<dt title="Identifica la obra, la misma en todas sus versiones">ISWC</dt>
<dd class="codigo">{registro.iswc}</dd>
{/if}
{#if registro.isrc}
<dt title="Identifica esta grabación en concreto">ISRC</dt>
<dd class="codigo">{registro.isrc}</dd>
{/if}
{#if registro.entidad}
<dt>Entidad</dt>
<dd>
{registro.entidad}{#if registro.numero}<span class="muted"
>&nbsp;·&nbsp;{registro.numero}</span
>{/if}
</dd>
{/if}
{#if registro.editorial}
<dt>Editorial</dt>
<dd>{registro.editorial}</dd>
{/if}
</dl>
{#if registro.reparto.length > 0}
<p class="subtitulo">Reparto de autoría</p>
<ul class="reparto">
{#each registro.reparto as parte (parte.nombre + parte.rol)}
<li>
<span class="quien">{parte.nombre}</span>
<span class="rol muted">{etiquetasRol[parte.rol] ?? parte.rol}</span>
<span class="parte">{parte.porcentaje} %</span>
</li>
{/each}
</ul>
{/if}
</section>
{/if}
</aside>
</div>
{#if data.anterior || data.siguiente}
<nav class="vecinos" aria-label="Otros temas del disco">
{#if data.anterior}
<a class="vecino" href={resolve('/canciones/[slug]', { slug: data.anterior.slug })}>
<span class="muted">Anterior</span>
<span>{data.anterior.titulo}</span>
</a>
{:else}
<span></span>
{/if}
{#if data.siguiente}
<a
class="vecino derecha"
href={resolve('/canciones/[slug]', { slug: data.siguiente.slug })}
>
<span class="muted">Siguiente</span>
<span>{data.siguiente.titulo}</span>
</a>
{/if}
</nav>
{/if}
</div>
<style>
/*
* A dos columnas se leen en paralelo, que es para lo que sirve tener la
* traducción al lado. Al estrecharse caen una debajo de otra: partir un
* verso para que quepan dos columnas sería peor que apilarlas.
*/
.letra.pareada {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-5) var(--sp-6);
align-items: start;
}
.rotulo-columna {
margin: 0 0 var(--sp-3);
padding-bottom: var(--sp-2);
border-bottom: 1px solid var(--c-border);
font-family: var(--font-body);
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.verso-a-verso {
margin: 0;
white-space: pre-line;
}
@media (max-width: 52rem) {
.letra.pareada {
grid-template-columns: minmax(0, 1fr);
}
}
/* Las etiquetas son enlaces, no datos sueltos: llevan a lo que agrupan. */
@media (prefers-reduced-motion: reduce) {
}
.ficha {
display: grid;
grid-template-columns: minmax(0, 17rem) minmax(0, 1fr);
gap: var(--sp-6);
align-items: start;
padding-bottom: var(--sp-5);
border-bottom: 1px solid var(--c-border);
}
.portada {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border: 1px solid var(--c-border);
border-radius: var(--radius);
background: var(--c-bg-sunken);
}
.meta {
font-size: var(--fs-sm);
}
.acciones {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-3);
margin-bottom: var(--sp-3);
}
.venta {
max-width: 46ch;
font-size: var(--fs-xs);
}
.cuerpo {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 16rem);
gap: var(--sp-6);
padding-block: var(--sp-6);
}
/*
* La letra se compone en bloque estrecho y con los saltos de línea del
* original: en una canción, dónde termina cada verso es parte del texto.
*/
.texto {
max-width: 46ch;
}
.versos {
font-family: var(--font-versos);
font-size: var(--fs-lg);
}
.versos :global(p) {
margin-bottom: var(--sp-5);
line-height: 1.75;
}
.videos {
display: grid;
gap: var(--sp-4);
max-width: 44rem;
}
.aviso {
margin-top: var(--sp-3);
font-size: var(--fs-xs);
max-width: 44rem;
}
.versiones {
list-style: none;
margin: var(--sp-4) 0 0;
padding: 0;
border-top: 1px solid var(--c-border);
}
.version {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-2) var(--sp-1);
border-bottom: 1px solid var(--c-border);
}
.version .datos p {
margin: 0;
}
.nombre {
font-size: var(--fs-sm);
font-weight: 600;
}
.nota {
font-size: var(--fs-xs);
}
.duracion {
font-size: var(--fs-sm);
font-variant-numeric: tabular-nums;
}
.lateral {
display: flex;
flex-direction: column;
gap: var(--sp-5);
}
.lateral h2 {
font-size: var(--fs-lg);
margin-bottom: var(--sp-2);
padding-bottom: var(--sp-2);
border-bottom: 1px solid var(--c-border);
}
.lateral dl {
margin: 0;
font-size: var(--fs-sm);
}
.lateral dt {
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--c-text-muted);
margin-top: var(--sp-3);
}
/* El primero pega con el encabezado de su bloque; los demás se separan. */
.lateral dt:first-of-type {
margin-top: 0;
}
.lateral dd {
margin: 0;
}
/* Los códigos se leen dígito a dígito: monoespaciada y sin ligaduras. */
.codigo {
font-family: var(--font-mono);
font-size: var(--fs-xs);
}
.subtitulo {
margin: var(--sp-4) 0 var(--sp-2);
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--c-text-muted);
}
.reparto {
list-style: none;
margin: 0;
padding: 0;
font-size: var(--fs-sm);
}
.reparto li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0 var(--sp-2);
padding-block: var(--sp-1);
border-bottom: 1px solid var(--c-border);
}
.reparto .rol {
grid-column: 1;
font-size: var(--fs-xs);
}
.reparto .parte {
grid-row: 1 / span 2;
grid-column: 2;
align-self: center;
font-variant-numeric: tabular-nums;
}
.vecinos {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-4);
padding-block: var(--sp-5);
border-top: 1px solid var(--c-border);
}
.vecino {
display: flex;
flex-direction: column;
font-size: var(--fs-sm);
text-decoration: none;
}
.vecino:hover span:last-child {
text-decoration: underline;
}
.vecino.derecha {
text-align: right;
}
@media (max-width: 48rem) {
.ficha,
.cuerpo {
grid-template-columns: minmax(0, 1fr);
}
.portada {
max-width: 14rem;
}
}
</style>

Powered by TurnKey Linux.