feat(skin-media-player): cuatro aparatos sobre el contrato del player — versión A, para el histórico

Se commitea PARA CONSERVARLA: esta versión se borra y se rehace. El autor la
valoró como duplicación, y la medición le da la razón. Queda aquí para que la
segunda no repita sus errores.

Qué hay:

- `eidos/components/skin-media-player/` — cuatro objetos dibujados (disco,
  giradiscos, casete, pletina) sobre el morfo `media-player`. El aparato anida
  su soporte como `<svg>` interior, así que la pintura del soporte se reutiliza.
- La afordancia es un `<button>` HTML superpuesto, colocado en % del viewBox, y
  NO un `role="button"` sobre un `<g>` como en los bocetos. Precedente:
  `onion-menu` («a real HTML button overlaid at the SVG centre… which an SVG
  element can't do») y el knob de `natural-time-picker`.
- Transporte compuesto sobre las partes reales: MARCHA/REPROD sólo arrancan y
  PAUSA sólo pausa, anulando el toggle de soma con `preventDefault()` en la
  costura (patrón de `RateFloat`). PARO/STOP no existe en el contrato y se
  compone `togglePlay()` + `seek(0)`.
- `engaged()` — `started` se enciende con la primera reproducción y no se apaga
  nunca, así que por sí solo dejaba el brazo apoyado sobre un disco parado. Con
  `started && !ended && (currentTime > 0 || !paused)` salen los tres estados de
  una máquina: parada, sonando, en pausa.
- Geometría portada del boceto: brazo por cinemática inversa de forma cerrada
  (r² = K + A·cosθ + B·senθ) y bobinas por conservación de área
  (R_izq² + R_der² constante), de donde sale sola la velocidad correcta de cada
  bobina. El contador de cinta lee el medio en vez de un `setInterval` propio.
- Los 83 colores del autor se conservan intactos: `skin-media-player` entra en
  `FIXED_TONE_COMPONENTS` — la excepción que el guard ya reserva para los
  componentes que DIBUJAN un objeto físico (`natural-time-picker`,
  `color-picker`, `proof-of-human`). Acotada a esta carpeta a propósito: dentro
  de `media-player/` habría eximido al recipe del player real.
- `web/routes/otros/players/` — los cuatro bocetos originales del autor (estado
  simulado, sin audio) y el banco de verificación.

Por qué se rehace, medido:

1. DUPLICACIÓN. `fit()` idéntico en record y cassette; `hold()` y `stop()`
   idénticos en turntable y deck; `MediaPlayerProvider.require()` + `engaged()`
   en los cuatro. Son cuatro componentes que comparten carpeta, no un skin: cada
   cuerpo se agarra al provider por su cuenta y recablea las mismas costuras. Lo
   correcto es al revés — un componente que recibe el player y resuelve las
   acciones, y a cada aparato sólo su pintura y su mapa de mandos.
2. MOTION A MANO. Tres `@keyframes` locales y siete transiciones con números
   crudos, existiendo el motor: `smp-turn` ES el preset `spin` (`loop-spin`,
   periodo por `--motion-loop-spin`) y `smp-tape-pulse` ES `pulse`; las
   transiciones debían salir de `--duration-*` / `--ease-*`. Y un defecto
   funcional: el generador apaga los loops por `[data-motion='reduce']` Y por la
   media query; mi bloque a mano sólo tenía la segunda, así que la preferencia
   UIX en `reduce` sin ajuste del SO no paraba el plato.
3. La raíz `skin-media-player.svelte` empaqueta `Media + Skin + barra` por
   dentro, así que la forma del reproductor (inline/bar/row/card, que es el eje
   de `AudioPlayer`) no se podía elegir y el MediaPlayer no se veía por ningún
   lado en la demo.

Verificado antes de commitear: `npm run check` sin errores nuevos (la base del
repo, 72, no se mueve) · guards de eidos 49/49 · `rtl:check` 0/179 · en
navegador con audio real, transporte, brazo siguiendo el surco, bobinas por
área y contador de cinta.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent 2447a466b4
commit bf12b8a571

@ -0,0 +1,155 @@
# SkinMediaPlayer
Fecha: 2026-08-15.
Una **piel** para el `MediaPlayer`: la cara del reproductor es un objeto físico
dibujado — un disco, un tocadiscos, un casete o una pletina. El comportamiento
es del player entero; aquí sólo vive la representación y las afordancias que ese
objeto tiene de verdad.
```svelte
<script>
import { SkinMediaPlayer } from '$uix/eidos/components/skin-media-player';
</script>
<SkinMediaPlayer skin="turntable" src="/track.mp3" title="Quédate" artist="Juan Luis Alarcón" />
```
Para componer a mano, la pieza suelta dentro de cualquier player:
```svelte
<script>
import { MediaPlayer } from '$uix/eidos/components/media-player';
import { Skin } from '$uix/eidos/components/skin-media-player';
</script>
<MediaPlayer media="audio">
<MediaPlayer.Media src="/track.mp3" />
<Skin skin="deck" title="Quédate" />
<MediaPlayer.DefaultControls />
</MediaPlayer>
```
## Baseline
**No hay baseline.** `air` no tenía nada así, y las referencias headless del
proyecto (Radix / Ark / Bits / React Aria) no definen ni un media player, menos
aún una piel figurativa. El punto de partida son cuatro bocetos SVG del autor
(`web/routes/otros/players/`, banco de pruebas con estado simulado y sin audio)
y las dos pieles que ya existen en la casa: `ProofOfHuman.Clock` sobre el motor
`RotateAlign`, y las cuatro variantes de `AudioPlayer` sobre este mismo morfo.
## Las cuatro pieles
| `skin` | Objeto | Mandos | viewBox |
| ----------- | ---------- | --------------------------------------------- | ---------- |
| `record` | Disco | ninguno — pintura pura | 525 × 570 |
| `turntable` | Giradiscos | 33/45 · MARCHA · PARO · ELEVADOR | 1000 × 880 |
| `cassette` | Casete | ninguno — pintura pura | 1000 × 629 |
| `deck` | Pletina | GRAB · REBOB · REPROD · AVANCE · STOP · PAUSA | 1000 × 780 |
`turntable` anida `record` en el hueco del plato y `deck` anida `cassette` tras
el cristal, los dos como `<svg>` interior: la piel del soporte se reutiliza
entera, no se redibuja.
## Comparativa
| Referencia | Qué hace | Aquí |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| **Winamp / foobar2000** (skins clásicos) | El skin ES el reproductor: bitmap + mapa de zonas activas, cada uno reimplementa su transporte | El skin **no es** el reproductor: reflejo + afordancias sobre las partes reales. Cero comportamiento propio |
| **Vidstack / Media Chrome** | Layouts intercambiables sobre un controlador; chrome funcional, nunca figurativo | Mismo reparto (estado en el elemento, chrome fuera), llevado a una representación de objeto |
| **Plyr / Video.js** | «Skins» = temas CSS sobre la misma barra | Aquí la piel cambia el OBJETO, no el tema de una barra. La barra canónica sigue debajo |
| **`AudioPlayer` (esta casa)** | Raíz competidora + 4 variantes en `[data-variant]` | Mismo patrón, eje distinto: `[data-skin]`. `data-variant` ya está ocupado por las formas de audio |
| **`ProofOfHuman.Clock` (esta casa)** | Piel hermana sobre un motor soma, con recipe propia | Misma idea; aquí las cuatro conviven en una raíz porque el eje es «qué objeto se ve» |
## Decisiones
- **La piel es un COMPLEMENTO, no un reproductor.** Ninguno de los cuatro
objetos tiene mando de volumen ni permite buscar libremente: la barra canónica
va debajo y posee esos dos ejes. `controls={false}` es para quien los aporte
por otro lado. Sin esto, `<SkinMediaPlayer>` sería un player incompleto
disfrazado de completo.
- **La afordancia es un `<button>` HTML superpuesto, nunca el `<g>` del SVG.**
Los bocetos ponían `role="button"` + `tabindex` sobre grupos SVG con Espacio y
Enter a mano. En eidos no hay un solo caso: `onion-menu` lo dice en su propio
marcado — _«a real HTML button overlaid at the SVG centre… which an SVG element
can't do»_ — y `natural-time-picker` hace lo mismo con el knob de su banda. Un
`<g>` no tiene teclado nativo, ni `disabled`, ni contorno que el sistema de
foco pueda dibujar. La pintura se queda en el SVG; sólo el área activa sale.
- **Componente de tono fijo.** Caoba, aluminio cepillado, cromo, marfil de las
teclas, granate de la etiqueta y oro del eje son la representación, no roles de
tema: un tocadiscos no se vuelve azul porque el tema lo haga. Los 83 valores del
autor se conservan intactos como literales en la pintura, bajo la excepción que
`recipe-css-contract.test.ts` ya reserva para `natural-time-picker`,
`color-picker` y `proof-of-human` (§Audit exceptions).
- **Tres estados, no dos** (`geometry.ts` → `engaged`). `provider.started` se
enciende con la primera reproducción y **no se apaga nunca**, así que por sí
solo dejaba el brazo apoyado sobre un disco parado. El enganche es
`started && !ended && (currentTime > 0 || !paused)`, y de ahí salen los tres
estados de una máquina: parada (brazo retirado), sonando, y en pausa (brazo en
su surco, elevador arriba, plato congelado).
- **STOP se compone, porque el contrato no lo tiene.** El provider expone
`togglePlay`, `seek` y `seekBy`; no hay `stop` ni `pause`. PARO y STOP hacen
`togglePlay()` (si suena) + `seek(0)`. Es coordinación en eidos, y por eso está
escrita aquí: una pletina cuyo STOP no para miente más de lo que la
coordinación cuesta.
- **MARCHA y REPROD sólo arrancan; PAUSA sólo pausa.** Un `PlayButton` pelado es
un toggle, así que una tecla ya enclavada habría **pausado** al pulsarla. La
costura es la de `RateFloat`: se compone la parte real y su handler se anula
con `preventDefault()` cuando no toca.
- **Las r.p.m. son decorativas.** 33/45 cambian la velocidad del plato y de las
marcas estroboscópicas; **no tocan `playbackRate`**. Hacerlo exigiría saber a
qué velocidad se grabó el disco (dato que no existe en el modelo) y sonaría
mal: el navegador conserva el tono por defecto, así que un single de 45 puesto
a 33 saldría lento pero igual de agudo, que es justo lo que un tocadiscos no
hace. Decisión del usuario, 2026-08-15.
- **El contador de cinta lee el medio.** En el boceto lo movía un `setInterval`
propio; ahora sale de `progress`, así que el mando de puesta a cero sobra —
poner el contador a cero ES volver al principio, que es lo que hace STOP. La
ruedecilla se queda pintada, inerte.
- **El ajuste de tipografía lee ANTES de escribir.** `<text>` no salta de línea
ni se encoge, así que hay que medir; el boceto escribía `fontSize` y leía
`getComputedTextLength()` a continuación, que es el reflujo forzado que la casa
prohíbe. La lectura va diferida por `eidos.dom.measure(...)` y la longitud
natural se recupera de la escala aplicada, así que nunca hay lectura
sincrónica tras escritura.
- **Los estados discretos salen del CSS, no de JS.** `data-paused`, `data-ended`
y compañía ya están en `[data-media-player]`. Sólo el ángulo del brazo, los
radios de las bobinas y el contador necesitan valores continuos.
## Gaps
| Gap | Disposición | Detalle |
| ------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| GRAB (grabar) | **descartar → pintado** | El puerto `MediaProvider` es play/pause/seek/volume/muted/rate. Grabar es captura, no reproducción, y no entra aquí. La tecla se pinta **deshabilitada** — la misma lectura que da un `SkipButton` sin manejador. Firmado por el usuario 2026-08-15. |
| 33/45 → `playbackRate` | **descartar** | Ver §Decisiones. La puerta queda abierta: un prop `rpm` numérico del disco + `preservesPitch = false` sobre el elemento nativo. |
| Enclavamiento de REBOB / AVANCE | **diferir** | En la pletina real las dos teclas se **enclavan** y bobinan de forma continua. Aquí son momentáneas y hacen un `seek` de `seekStep`: un bobinado continuo exigiría repetición mantenida, que es comportamiento nuevo y nadie lo pidió. |
| Textos de STOP y GRAB sin traducir | **deuda declarada** | El catálogo `components.media-player.*` no tiene `stop` ni `record`, y no se puede ampliar bajo el alcance acordado. Se usa un idlangref con `\|fallback`, que en lookup de primer nivel **no avisa** ([engine-langs.ts:140]) — pero sale siempre en inglés. |
| Las dos fuentes del dibujo | **deuda declarada** | `Bodoni Moda` y `Cormorant Garamond` las cargaba el banco de pruebas desde Google Fonts. El marco no las sirve: sin ellas la pintura cae a `Georgia, serif`, declarado como fallback en cada `font-family`. |
| Demo canónica (`web/routes/uix/…`) | **fuera de alcance** | El alcance acordado limitaba los cambios a la carpeta del componente. Sin demo no hay reglas `D-*`, ni `perm:check`, ni fila en el audit. |
| `Poster`, `Captions`, PiP, fullscreen | **descartar** | Son superficie de vídeo; la piel es de audio (`media="audio"`). |
| Aguja del VU ligada a la señal real | **diferir** | Oscila con una animación, como en el boceto. Leer amplitud real exige un `AnalyserNode`, que es del eje de sonido y no se toca fuera de su sesión. |
## Audit exceptions
- **Tono fijo (`recipe-css-contract`)** — `skin-media-player` está en
`FIXED_TONE_COMPONENTS`. Es la cuarta entrada, y por el mismo motivo que las
tres anteriores: el componente **dibuja un objeto físico** cuyo aspecto debe
leerse igual en cualquier tema. Alcance: los literales de color de la pintura.
La excepción está acotada a esta carpeta a propósito — meterla en
`media-player/` habría eximido al recipe del player real.
- **R-1.1 exception:** el recipe no declara `[data-skin-media-player]` como raíz
con layout propio; la raíz visual sigue siendo `[data-media-player]` (esto es
una piel sobre él, no un componente con superficie propia). El recipe engancha
en `[data-skin-media-player-skin]` y en cada cuerpo.
- **R-1.2 / R-1.5 exception:** el estado disabled y el anillo de foco los aportan
los primitivos compuestos y la plataforma — las teclas son `<button>` nativos
con `:focus-visible` sobre `--focus-ring-*`, y `disabled` es el atributo real.
- **Sin tokens públicos `--skin-media-player-*`.** Requerirían una entrada en
`EidosConfig.recipes` (`lib/recipes/base.ts`), fuera del alcance acordado. Todo
lo retocable vive como privado `--_smp-*` en el recipe.
- **F-1.2** — la comparativa cita cinco referencias, dos de ellas internas
(`AudioPlayer`, `ProofOfHuman.Clock`): no existe un referente externo headless
de piel figurativa para un media player.
[engine-langs.ts:140]: ../../../arts/langs/engine-langs.ts

@ -0,0 +1,126 @@
// Pure geometry for the depicted objects. No runes, no DOM: every function maps
// playback progress (0..1) to a coordinate the SVG can paint. Kept out of the
// components because both vinyl skins share the tonearm solution and both tape
// skins share the reel solution.
/* ── Engagement ──────────────────────────────────────────────────────────── */
/**
* Is the medium ENGAGED — needle down, tape threaded?
*
* Not simply `started`: that latches on the first play and never clears, so a
* STOP (pause + seek to 0) would leave the tonearm sitting on a record nobody
* is playing. The three states a machine actually has fall out of this:
*
* · stopped — at 0 and paused → arm parked, reels at rest
* · playing — engaged and running
* · paused — engaged, frozen where it was (the cueing lever is up, but the
* arm stays over its groove)
*/
export function engaged(state: {
started: boolean;
ended: boolean;
paused: boolean;
currentTime: number;
}): boolean {
return state.started && !state.ended && (state.currentTime > 0 || !state.paused);
}
/* ── Tonearm ─────────────────────────────────────────────────────────────── */
/** Disc centre, in the record's viewBox (0 0 525 570). */
const DISC = { x: 240, y: 308 };
/** Arm pivot — also the spindle cap drawn over it. */
const PIVOT = { x: 450, y: 100 };
/** Stylus tip with the arm unrotated. */
const STYLUS = { x: 452.63, y: 354.16 };
/** Lead-in groove radius, where the needle lands. */
const GROOVE_IN = 185;
/** Run-out groove radius, where the side ends. */
const GROOVE_OUT = 88;
/** Arm angle at rest, parked off the record. */
export const ARM_PARKED = -9;
// The arm has a TRAVEL, not two positions. Groove pitch is constant, so the
// radius falls linearly with time; inverting radius → angle has a closed form,
// because r² = K + A·cos θ + B·sin θ with the pivot and the disc centre fixed.
const ax = STYLUS.x - PIVOT.x;
const ay = STYLUS.y - PIVOT.y;
const dx = PIVOT.x - DISC.x;
const dy = PIVOT.y - DISC.y;
const K = dx * dx + dy * dy + ax * ax + ay * ay;
const A = 2 * (dx * ax + dy * ay);
const B = 2 * (dy * ax - dx * ay);
const AMP = Math.hypot(A, B);
const PHASE = Math.atan2(B, A);
/** Arm degrees that land the stylus in the groove of radius `r`. */
function angleForRadius(r: number): number {
const c = Math.max(-1, Math.min(1, (r * r - K) / AMP));
return ((PHASE + Math.acos(c)) * 180) / Math.PI;
}
/**
* Arm degrees for a playback fraction. `active` is false before the needle is
* down, which parks the arm off the record.
*/
export function armAngle(progress: number, active: boolean): number {
if (!active) return ARM_PARKED;
const clamped = Math.min(1, Math.max(0, progress));
return angleForRadius(GROOVE_IN + (GROOVE_OUT - GROOVE_IN) * clamped);
}
/** Seconds per platter revolution. 33⅓ rpm → 1.8 s, 45 rpm → 1.333 s. */
export function turnSeconds(rpm: 33 | 45): number {
return rpm === 33 ? 1.8 : 1.333;
}
/* ── Tape reels ──────────────────────────────────────────────────────────── */
/** Bare hub radius, in the cassette's viewBox (0 0 1000 629). */
const HUB = 27;
/** Radius of a full tape pack. */
const PACK = 54;
const SPAN = PACK * PACK - HUB * HUB;
/** Turns a reel makes going from empty to full. A matter of taste. */
const TURNS = 42;
const PER_TURN = (360 * TURNS) / (PACK - HUB);
export type Reels = {
/** Supply-reel pack radius — starts full. */
left: number;
/** Take-up-reel pack radius — starts empty. */
right: number;
/** Accumulated rotation of each reel, in degrees. */
spinLeft: number;
spinRight: number;
};
/**
* Reel packs for a playback fraction. Tape AREA is conserved, so
* `left² + right²` stays constant: the radius moves fast while a pack is small
* and slow while it is big, which is what a real cassette does.
*
* Each turn adds one tape thickness to the pack, so turns taken are
* proportional to how much the radius grew — the correct speed falls out on its
* own, and the small reel spins faster than the big one. Expressed as an ANGLE
* rather than an `animation-duration`, because retuning a running animation's
* duration makes it jump.
*/
export function reels(progress: number): Reels {
const p = Math.min(1, Math.max(0, progress));
const left = Math.sqrt(PACK * PACK - p * SPAN);
const right = Math.sqrt(HUB * HUB + p * SPAN);
return {
left,
right,
spinLeft: (PACK - left) * PER_TURN,
spinRight: (right - HUB) * PER_TURN
};
}
/** Four-digit tape counter, the way a deck derives it from tape consumed. */
export function tapeCounter(progress: number): string[] {
const n = Math.round(Math.min(1, Math.max(0, progress)) * 9999);
return String(n).padStart(4, '0').split('');
}

@ -0,0 +1,29 @@
// SkinMediaPlayer — a depicted surface for the MediaPlayer.
//
// import { SkinMediaPlayer } from '$uix/eidos/components/skin-media-player';
// <SkinMediaPlayer skin="turntable" src="/track.mp3" title="…" artist="…" />
//
// The 10% that arranges the parts by hand mounts the depiction alone inside any
// player:
//
// import { Skin } from '$uix/eidos/components/skin-media-player';
// <MediaPlayer media="audio">
// <MediaPlayer.Media src="…" />
// <Skin skin="deck" title="…" />
// <MediaPlayer.DefaultControls />
// </MediaPlayer>
import SkinMediaPlayerComponent from './skin-media-player.svelte';
import Skin from './skin-media-player-skin.svelte';
const SkinMediaPlayer = SkinMediaPlayerComponent;
export { SkinMediaPlayer, Skin };
export default SkinMediaPlayer;
export type {
SkinMediaPlayerProps,
SkinMediaPlayerSkin,
SkinMediaPlayerRpm,
SkinProps,
SkinIdentity
} from './types';

@ -0,0 +1,309 @@
<script lang="ts">
/**
* The cassette — shell, inlay, window and the two tape packs. Pure paint.
*
* viewBox 0 0 1000 629; the paper inlay occupies x 112–888, y 74–430.
* Mounted on its own it is the `cassette` skin; the `deck` nests it as an
* inner `<svg>` behind the smoked window, so everything but the identity
* rides `...rest` onto the root element.
*/
import { ActiveEidos } from '$uix/eidos';
import { MediaPlayerProvider } from '$soma/components/media-player';
import { engaged, reels } from './geometry';
import type { SkinIdentity } from './types';
let {
title = '',
artist = '',
year = '',
publisher = '',
image = '',
side = 'A',
tapeType = 'C-60',
...rest
}: SkinIdentity & { [key: string]: unknown } = $props();
const uid = $props.id();
const eidos = ActiveEidos.require();
const provider = MediaPlayerProvider.require();
const spinning = $derived(!provider.paused);
const active = $derived(engaged(provider));
// Before metadata lands there is no duration, so the packs cannot be placed:
// the reels fall back to a constant spin instead of lying about the tape.
const timed = $derived(provider.duration > 0);
const pack = $derived(reels(provider.progress));
const name = $derived([title, artist].filter(Boolean).join(' — '));
/** With a photo the type has less room before it collides with it. */
const textWidth = $derived(image ? 436 : 626);
// Deferred read, taken BEFORE any write — see the record skin for why.
let texts: (SVGTextElement | undefined)[] = $state([]);
const INLAY_FIT = [{ base: 56 }, { base: 24 }, { base: 20 }] as const;
function fit(el: SVGTextElement | undefined, max: number, base: number) {
if (!el?.getComputedTextLength) return;
const applied = parseFloat(el.style.fontSize) || base;
const measured = el.getComputedTextLength();
if (!measured) return;
const natural = (measured * base) / applied;
const next = natural > max ? Math.max(base * 0.45, (base * max) / natural) : base;
if (next !== applied) el.style.fontSize = `${next}px`;
}
$effect(() => {
void [title, artist, year, publisher, tapeType, textWidth];
// Spread, not a bare reference: reading each index is what makes the
// effect depend on the `bind:this` writes, which land after it first runs.
const els = [...texts];
const max = textWidth;
const anchor = els.find(Boolean);
if (!anchor) return;
return eidos.dom.measure(() => {
INLAY_FIT.forEach((slot, i) => fit(els[i], max, slot.base));
}, anchor);
});
</script>
<svg
data-skin-media-player-cassette
data-active={active ? '' : undefined}
data-spinning={spinning ? '' : undefined}
data-timed={timed ? '' : undefined}
viewBox="0 0 1000 629"
style="--_smp-reel-left:{pack.spinLeft}deg; --_smp-reel-right:{pack.spinRight}deg"
role={name ? 'img' : undefined}
aria-label={name || undefined}
aria-hidden={name ? undefined : 'true'}
{...rest}
>
<defs>
<radialGradient id="smp-screw-{uid}" cx="0.36" cy="0.32" r="0.75">
<stop offset="0" stop-color="#8a8175" />
<stop offset="1" stop-color="#3a3227" />
</radialGradient>
<linearGradient id="smp-shell-{uid}" x1="0" y1="0" x2="0.35" y2="1">
<stop offset="0" stop-color="#2c2318" />
<stop offset="1" stop-color="#171009" />
</linearGradient>
<linearGradient id="smp-sheen-{uid}" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.13" />
<stop offset="0.45" stop-color="#ffffff" stop-opacity="0" />
</linearGradient>
<radialGradient id="smp-pack-{uid}" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#57381a" />
<stop offset="1" stop-color="#33200d" />
</radialGradient>
<clipPath id="smp-window-{uid}">
<rect x="278" y="240" width="444" height="142" rx="22" />
</clipPath>
<clipPath id="smp-photo-{uid}">
<rect x="700" y="92" width="150" height="150" rx="6" />
</clipPath>
</defs>
<!-- Shell and screws -->
<rect x="4" y="4" width="992" height="621" rx="34" fill="url(#smp-shell-{uid})" />
<rect x="4" y="4" width="992" height="621" rx="34" fill="url(#smp-sheen-{uid})" />
<rect
x="16"
y="16"
width="968"
height="597"
rx="26"
fill="none"
stroke="#3f3527"
stroke-width="2"
/>
{#each [[60, 60], [940, 60], [60, 569], [940, 569]] as [cx, cy] (cx + '-' + cy)}
<circle {cx} {cy} r="17" fill="url(#smp-screw-{uid})" />
<path
d="M{cx} {cy - 11}v22M{cx - 11} {cy}h22"
stroke="#1b140d"
stroke-width="4"
stroke-linecap="round"
/>
{/each}
<!-- Inlay -->
<rect x="112" y="74" width="776" height="356" fill="#f2e8d0" />
<rect x="112" y="252" width="776" height="178" fill="#6e1f2a" />
<rect
x="112"
y="74"
width="776"
height="356"
fill="none"
stroke="#1b140d"
stroke-opacity="0.25"
stroke-width="2"
/>
{#if image}
<image
href={image}
x="700"
y="92"
width="150"
height="150"
preserveAspectRatio="xMidYMid slice"
clip-path="url(#smp-photo-{uid})"
/>
<rect
x="700"
y="92"
width="150"
height="150"
rx="6"
fill="none"
stroke="#1b140d"
stroke-opacity="0.35"
stroke-width="2"
/>
{/if}
<text
x="150"
y="176"
font-family="'Bodoni Moda',Georgia,serif"
font-weight="700"
font-size="74"
fill="#231b13">{side}</text
>
<text
bind:this={texts[0]}
x="244"
y="130"
font-family="'Bodoni Moda',Georgia,serif"
font-style="italic"
font-size="56"
fill="#231b13">{title}</text
>
<text
bind:this={texts[1]}
x="246"
y="166"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="24"
letter-spacing="4"
fill="#6e1f2a">{artist.toUpperCase()}{year ? ` · ${year}` : ''}</text
>
<text
bind:this={texts[2]}
x="246"
y="196"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="20"
letter-spacing="4"
fill="#a8853f">{publisher.toUpperCase()} · {tapeType}</text
>
<!-- Window: tape packs and hubs -->
<rect x="266" y="228" width="468" height="166" rx="30" fill="#1b140d" />
<rect x="278" y="240" width="444" height="142" rx="22" fill="#0f0a06" />
<g clip-path="url(#smp-window-{uid})">
<rect x="278" y="240" width="444" height="142" fill="#0f0a06" />
<circle cx="366" cy="311" r="56" fill="#0b0805" />
<circle cx="634" cy="311" r="56" fill="#0b0805" />
<!-- The stretch of tape between the two packs -->
<rect
class="smp-tape"
x={366 + pack.left}
y="304"
width={268 - pack.left - pack.right}
height="14"
fill="#3a240f"
/>
<!-- The packs do not spin: they are plain discs, and spinning them would
not read while inviting the same temporal aliasing as the platter. -->
{#each [{ cx: 366, r: pack.left }, { cx: 634, r: pack.right }] as p (p.cx)}
<circle
cx={p.cx}
cy="311"
r={p.r}
fill="url(#smp-pack-{uid})"
stroke="#6b6355"
stroke-opacity="0.45"
stroke-width="2"
/>
<circle
cx={p.cx}
cy="311"
r={(p.r + 27) / 2}
fill="none"
stroke="#1b140d"
stroke-opacity="0.4"
stroke-width="2"
/>
{/each}
<g class="smp-reel">
<g transform="translate(366 311) scale(0.565) translate(-366 -311)">
<circle cx="366" cy="311" r="46" fill="#e9e4d8" stroke="#8d8577" stroke-width="6" />
<g stroke="#8d8577" stroke-width="10" stroke-linecap="round">
<path
d="M366 275v20M366 327v20M330 311h20M382 311h20M340 285l14 14M378 323l14 14M392 285l-14 14M354 323l-14 14"
/>
</g>
<circle cx="366" cy="311" r="16" fill="#8d8577" />
</g>
</g>
<g class="smp-reel smp-reel-right">
<g transform="translate(634 311) scale(0.565) translate(-634 -311)">
<circle cx="634" cy="311" r="46" fill="#e9e4d8" stroke="#8d8577" stroke-width="6" />
<g stroke="#8d8577" stroke-width="10" stroke-linecap="round">
<path
d="M634 275v20M634 327v20M598 311h20M650 311h20M608 285l14 14M646 323l14 14M660 285l-14 14M622 323l-14 14"
/>
</g>
<circle cx="634" cy="311" r="16" fill="#8d8577" />
</g>
</g>
<circle
cx="366"
cy="311"
r="56"
fill="none"
stroke="#6b6355"
stroke-opacity="0.55"
stroke-width="3"
/>
<circle
cx="634"
cy="311"
r="56"
fill="none"
stroke="#6b6355"
stroke-opacity="0.55"
stroke-width="3"
/>
</g>
<rect
x="266"
y="228"
width="468"
height="166"
rx="30"
fill="none"
stroke="#000000"
stroke-opacity="0.4"
stroke-width="2"
/>
<!-- Lower apron -->
<path
d="M262 629 L286 452 Q288 440 302 440 L698 440 Q712 440 714 452 L738 629"
fill="none"
stroke="#6b6355"
stroke-width="4"
/>
<circle cx="352" cy="546" r="17" fill="#f7f2e6" />
<rect x="416" y="532" width="42" height="28" rx="5" fill="#f7f2e6" />
<rect x="542" y="532" width="42" height="28" rx="5" fill="#f7f2e6" />
<circle cx="648" cy="546" r="17" fill="#f7f2e6" />
</svg>

@ -0,0 +1,398 @@
<script lang="ts">
/**
* The cassette deck — speaker, window, VU meter, tape counter and the piano
* keys. The cassette nests behind the smoked glass as an inner `<svg>`.
*
* viewBox 0 0 1000 780. The keyboard maps onto the player:
* REPROD → play · PAUSA → pause · STOP → pause + back to the start ·
* REBOB / AVANCE → seek ∓ `seekStep` · GRAB → disabled, always.
*
* GRAB has no counterpart and never will: the `MediaProvider` port is
* play/pause/seek/volume/muted/rate, and recording is capture, not playback.
* It is painted, and painted UNAVAILABLE — the same read the `SkipButton`
* gives a direction with no handler.
*/
import { ActiveEidos } from '$uix/eidos';
import * as MediaPlayer from '$soma/components/media-player';
import { MediaPlayerProvider } from '$soma/components/media-player';
import Key from './skin-media-player-key.svelte';
import Cassette from './skin-media-player-cassette.svelte';
import { engaged, tapeCounter } from './geometry';
import type { SkinIdentity } from './types';
let { brand = 'Levante', model = 'CR-77', ...identity }: SkinIdentity = $props();
const uid = $props.id();
const eidos = ActiveEidos.require();
const provider = MediaPlayerProvider.require();
const active = $derived(engaged(provider));
const playing = $derived(!provider.paused);
const digits = $derived(tapeCounter(provider.progress));
const stopLabel = $derived(eidos.langs.ts('#?components.media-player.stop|Stop'));
const recordLabel = $derived(eidos.langs.ts('#?components.media-player.record|Record'));
/** STOP: the contract has no `stop`, so the two calls are composed here. */
function stop() {
if (!provider.paused) provider.togglePlay();
provider.seek(0);
}
let held = $state<string | null>(null);
const hold = (id: string) => ({
onpointerdown: () => (held = id),
onpointerup: () => (held = null),
onpointerleave: () => (held = null),
onblur: () => (held = null)
});
const GLYPHS = {
rec: 'M0,-13 A13,13 0 1,0 0,13 A13,13 0 1,0 0,-13 Z',
rew: 'M-2,-12 L-19,0 L-2,12 Z M16,-12 L-1,0 L16,12 Z',
play: 'M-9,-13 L14,0 L-9,13 Z',
ff: 'M-16,-12 L1,0 L-16,12 Z M2,-12 L19,0 L2,12 Z',
stop: 'M-11,-11 H11 V11 H-11 Z',
pause: 'M-11,-12 H-3 V12 H-11 Z M3,-12 H11 V12 H3 Z'
} as const;
const KEYS = [
{ id: 'rec', x: 80, text: 'GRAB' },
{ id: 'rew', x: 222, text: 'REBOB' },
{ id: 'play', x: 364, text: 'REPROD' },
{ id: 'ff', x: 506, text: 'AVANCE' },
{ id: 'stop', x: 648, text: 'STOP' },
{ id: 'pause', x: 790, text: 'PAUSA' }
] as const;
const latched = $derived<Record<string, boolean>>({
rec: false,
rew: false,
play: playing,
ff: false,
stop: false,
pause: active && provider.paused
});
/** VU dial geometry. */
const VU = { cx: 216, cy: 574, r: 80 };
const TICKS = [-26, -13, 0, 13, 26].map((deg) => {
const a = (deg * Math.PI) / 180;
const at = (rr: number) => [
(VU.cx + rr * Math.sin(a)).toFixed(1),
(VU.cy - rr * Math.cos(a)).toFixed(1)
];
const [x1, y1] = at(VU.r - 9);
const [x2, y2] = at(VU.r);
return { d: `M${x1} ${y1} L${x2} ${y2}`, zero: deg === 0 };
});
</script>
<svg
data-skin-media-player-deck
data-active={active ? '' : undefined}
data-spinning={playing ? '' : undefined}
viewBox="0 0 1000 780"
aria-hidden="true"
>
<defs>
<linearGradient id="smp-body-{uid}" x1="0" y1="0" x2="0.4" y2="1">
<stop offset="0" stop-color="#2c2318" />
<stop offset="1" stop-color="#140e08" />
</linearGradient>
<linearGradient id="smp-alu-{uid}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#d7d0c0" />
<stop offset="0.18" stop-color="#b3ab9b" />
<stop offset="0.5" stop-color="#c9c1b0" />
<stop offset="0.82" stop-color="#a49c8c" />
<stop offset="1" stop-color="#c2baa9" />
</linearGradient>
<linearGradient id="smp-key-{uid}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fbf7ec" />
<stop offset="0.62" stop-color="#eee5cf" />
<stop offset="1" stop-color="#cfc5ac" />
</linearGradient>
<linearGradient id="smp-keyfront-{uid}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#bdb39a" />
<stop offset="1" stop-color="#8f8672" />
</linearGradient>
<radialGradient id="smp-bolt-{uid}" cx="0.36" cy="0.32" r="0.75">
<stop offset="0" stop-color="#8a8175" />
<stop offset="1" stop-color="#3a3227" />
</radialGradient>
<radialGradient id="smp-red-{uid}" cx="0.4" cy="0.35" r="0.7">
<stop offset="0" stop-color="#ff8a72" />
<stop offset="0.5" stop-color="#c8352a" />
<stop offset="1" stop-color="#5e120d" />
</radialGradient>
<!-- Speaker cloth: a pattern of holes, not 300 loose circles -->
<pattern id="smp-grille-{uid}" width="21" height="21" patternUnits="userSpaceOnUse">
<circle cx="10.5" cy="10.5" r="5.2" fill="#1b140d" fill-opacity="0.9" />
</pattern>
<clipPath id="smp-vu-{uid}"><rect x="76" y="470" width="280" height="88" rx="6" /></clipPath>
<clipPath id="smp-glass-{uid}"
><rect x="456" y="128" width="452" height="304" rx="6" /></clipPath
>
<filter id="smp-deck-shadow-{uid}" x="-10%" y="-10%" width="120%" height="130%">
<feDropShadow dx="0" dy="16" stdDeviation="14" flood-color="#3a2e24" flood-opacity="0.45" />
</filter>
</defs>
<!-- Carry handle -->
<path
d="M336 96 Q336 30 500 30 Q664 30 664 96"
fill="none"
stroke="#1b140d"
stroke-width="22"
stroke-linecap="round"
/>
<!-- Body and aluminium fascia -->
<g filter="url(#smp-deck-shadow-{uid})">
<rect x="20" y="64" width="960" height="692" rx="28" fill="url(#smp-body-{uid})" />
<rect x="44" y="88" width="912" height="644" rx="16" fill="url(#smp-alu-{uid})" />
<rect
x="44"
y="88"
width="912"
height="644"
rx="16"
fill="none"
stroke="#6f6757"
stroke-opacity="0.6"
stroke-width="2"
/>
</g>
{#each [[32, 140], [968, 140], [32, 680], [968, 680]] as [cx, cy] (cx + '-' + cy)}
<circle {cx} {cy} r="8" fill="url(#smp-bolt-{uid})" />
<path d="M{cx - 5} {cy}h10" stroke="#12100a" stroke-width="2.5" stroke-linecap="round" />
{/each}
<!-- Speaker -->
<rect x="76" y="112" width="330" height="336" rx="10" fill="#8f8672" />
<rect x="82" y="118" width="318" height="324" rx="7" fill="url(#smp-grille-{uid})" />
<rect
x="76"
y="112"
width="330"
height="336"
rx="10"
fill="none"
stroke="#4f4838"
stroke-width="2"
/>
<!-- Cassette window -->
<rect x="440" y="112" width="484" height="336" rx="12" fill="#241c15" />
<rect x="456" y="128" width="452" height="304" rx="6" fill="#0f0a06" />
<Cassette
{...identity}
x="464"
y="143"
width="436"
height="274"
preserveAspectRatio="xMidYMid meet"
aria-hidden="true"
role={undefined}
aria-label={undefined}
/>
<!-- Smoked glass and reflection -->
<g clip-path="url(#smp-glass-{uid})">
<rect x="456" y="128" width="452" height="304" fill="#0f0a06" opacity="0.26" />
<path d="M456 432 L640 128 L760 128 L576 432 Z" fill="#ffffff" opacity="0.06" />
</g>
<rect
x="456"
y="128"
width="452"
height="304"
rx="6"
fill="none"
stroke="#000000"
stroke-opacity="0.5"
stroke-width="3"
/>
<!-- VU meter -->
<rect x="76" y="470" width="280" height="88" rx="6" fill="#f2e8d0" />
<g clip-path="url(#smp-vu-{uid})">
<path
d="M180.9 502.1 A80 80 0 0 1 251.1 502.1"
fill="none"
stroke="#3a2e24"
stroke-width="1.5"
/>
<path d="M227.1 494.8 A80 80 0 0 1 251.1 502.1" fill="none" stroke="#6e1f2a" stroke-width="4" />
{#each TICKS as t, i (i)}
<path d={t.d} stroke={t.zero ? '#6e1f2a' : '#3a2e24'} stroke-width={t.zero ? 2.5 : 1.5} />
{/each}
<g class="smp-needle">
<path d="M216 574 L216 492" stroke="#231b13" stroke-width="2.5" stroke-linecap="round" />
</g>
<circle cx="216" cy="558" r="9" fill="#8f8672" />
</g>
<text
x="216"
y="548"
text-anchor="middle"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="15"
letter-spacing="5"
fill="#7a6a52">VU</text
>
<rect
x="76"
y="470"
width="280"
height="88"
rx="6"
fill="none"
stroke="#4f4838"
stroke-width="2"
/>
<!-- Tape counter — derived from the tape actually consumed -->
<rect x="386" y="482" width="180" height="66" rx="6" fill="#241c15" />
{#each digits as d, i (i)}
<rect x={399 + i * 40} y="494" width="34" height="42" rx="3" fill="#f2e8d0" />
<text
x={416 + i * 40}
y="528"
text-anchor="middle"
font-family="'Bodoni Moda',Georgia,serif"
font-size="30"
fill="#231b13">{d}</text
>
{/each}
<!-- Counter reset knob: paint only. The counter now reads the medium, so
zeroing it IS returning to the start, which is what STOP does. -->
<circle cx="590" cy="515" r="14" fill="url(#smp-bolt-{uid})" />
<circle cx="590" cy="515" r="7" fill="#1b140d" />
<circle cx="640" cy="515" r="11" fill="#3a3227" />
<circle class="smp-lamp" cx="640" cy="515" r="8" fill="url(#smp-red-{uid})" />
<text
x="660"
y="521"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="15"
letter-spacing="4"
fill="#5c5445"
>
REPROD
</text>
<text
x="930"
y="512"
text-anchor="end"
font-family="'Bodoni Moda',Georgia,serif"
font-weight="700"
font-size="34"
fill="#3a2e24">{brand}</text
>
<text
x="930"
y="540"
text-anchor="end"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="17"
letter-spacing="6"
fill="#6f6757">MODELO {model}</text
>
<!-- ── Keyboard paint ─────────────────────────────────────────────────── -->
{#each KEYS as k (k.id)}
<text
x={k.x + 65}
y="590"
text-anchor="middle"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="15"
letter-spacing="3"
fill="#5c5445">{k.text}</text
>
{/each}
<rect x="60" y="596" width="880" height="150" rx="10" fill="#1b140d" />
{#each KEYS as k (k.id)}
<g
class="smp-piano-key"
data-pressed={latched[k.id] || held === k.id ? '' : undefined}
data-unavailable={k.id === 'rec' ? '' : undefined}
>
<rect x={k.x} y="604" width="130" height="128" rx="7" fill="#0d0906" />
<g class="smp-piano-key-body">
<rect x={k.x} y="604" width="130" height="118" rx="7" fill="url(#smp-key-{uid})" />
<rect x={k.x} y="700" width="130" height="22" rx="6" fill="url(#smp-keyfront-{uid})" />
<path
d="M{k.x + 7} 611 H{k.x + 123}"
stroke="#ffffff"
stroke-opacity="0.75"
stroke-width="2"
/>
<path
d={GLYPHS[k.id]}
transform="translate({k.x + 65} 652)"
fill={k.id === 'rec' ? '#6e1f2a' : '#3a2e24'}
/>
</g>
</g>
{/each}
</svg>
<!-- ── The affordances ────────────────────────────────────────────────────── -->
<MediaPlayer.SeekButton direction="backward" onclick={() => (held = null)}>
{#snippet child({ props }: { props: Record<string, unknown> })}
<Key {...props} x={222} y={604} w={130} h={128} vw={1000} vh={780} {...hold('rew')} />
{/snippet}
</MediaPlayer.SeekButton>
<MediaPlayer.PlayButton
onclick={(e: MouseEvent) => {
// REPROD only starts — a latched key must not pause the machine.
if (!provider.paused) e.preventDefault();
}}
>
{#snippet child({ props }: { props: Record<string, unknown> })}
<Key {...props} x={364} y={604} w={130} h={128} vw={1000} vh={780} {...hold('play')} />
{/snippet}
</MediaPlayer.PlayButton>
<MediaPlayer.SeekButton direction="forward" onclick={() => (held = null)}>
{#snippet child({ props }: { props: Record<string, unknown> })}
<Key {...props} x={506} y={604} w={130} h={128} vw={1000} vh={780} {...hold('ff')} />
{/snippet}
</MediaPlayer.SeekButton>
<Key
x={648}
y={604}
w={130}
h={128}
vw={1000}
vh={780}
aria-label={stopLabel}
onclick={stop}
{...hold('stop')}
/>
<MediaPlayer.PlayButton
onclick={(e: MouseEvent) => {
// PAUSA only pauses.
if (provider.paused) e.preventDefault();
}}
>
{#snippet child({ props }: { props: Record<string, unknown> })}
<Key {...props} x={790} y={604} w={130} h={128} vw={1000} vh={780} {...hold('pause')} />
{/snippet}
</MediaPlayer.PlayButton>
<Key x={80} y={604} w={130} h={128} vw={1000} vh={780} aria-label={recordLabel} disabled />

@ -0,0 +1,49 @@
<script lang="ts">
/**
* The affordance of a depicted control: a transparent `<button>` laid over
* the SVG, positioned from the same viewBox coordinates the paint uses.
*
* Why not `role="button"` on the `<g>` itself — which is what the source
* sketches did: nothing in eidos puts a role or a tabindex on an SVG node,
* and `onion-menu` says why in its own markup («a real HTML button overlaid
* at the SVG centre… which an SVG element can't do»). A `<g>` gets no native
* Enter/Space, no `disabled`, and no outline the focus-ring system can draw.
*
* The paint stays in the SVG; only the hit area lives here.
*/
import type { Snippet } from 'svelte';
let {
x,
y,
w,
h,
vw,
vh,
children,
...rest
}: {
/** Hit-area box, in the host SVG's viewBox units. */
x: number;
y: number;
w: number;
h: number;
/** Host viewBox size, so the box resolves to percentages. */
vw: number;
vh: number;
children?: Snippet;
[key: string]: unknown;
} = $props();
// Physical `left` / `top`, deliberately: this is a DEPICTED object, and a
// depiction does not mirror — the tonearm sits on the right of every
// turntable ever built. The logical grid is for flow, not for machines.
const style = $derived(
`left:${(x / vw) * 100}%;top:${(y / vh) * 100}%;` +
`inline-size:${(w / vw) * 100}%;block-size:${(h / vh) * 100}%`
);
</script>
<button {...rest} type="button" data-skin-media-player-key {style}>
{@render children?.()}
</button>

@ -0,0 +1,353 @@
<script lang="ts">
/**
* The record — disc, grooves, label and the tonearm that reads it. Pure
* paint: it reflects the player and drives nothing.
*
* viewBox 0 0 525 570 — disc centred at (240, 308) with radius 200, spindle
* at (450, 100), which is also the arm's pivot. `width` is the TOTAL width,
* arm included: the disc takes 400 of those 525 units.
*
* Mounted on its own it is the `record` skin; the `turntable` nests it as an
* inner `<svg>` in the platter well, which is why every prop but the
* identity rides `...rest` onto the root element.
*/
import { ActiveEidos } from '$uix/eidos';
import { MediaPlayerProvider } from '$soma/components/media-player';
import { armAngle, engaged, turnSeconds } from './geometry';
import type { SkinIdentity, SkinMediaPlayerRpm } from './types';
let {
title = '',
artist = '',
year = '',
publisher = '',
image = '',
imageVeil = 0.55,
rpm = 45,
...rest
}: SkinIdentity & { rpm?: SkinMediaPlayerRpm; [key: string]: unknown } = $props();
const uid = $props.id();
const eidos = ActiveEidos.require();
const provider = MediaPlayerProvider.require();
// Needle down — see `engaged`: a STOP parks the arm again.
const active = $derived(engaged(provider));
const spinning = $derived(!provider.paused);
const angle = $derived(armAngle(provider.progress, active));
const rpmLabel = $derived(rpm === 33 ? '33⅓ R.P.M.' : '45 R.P.M.');
const name = $derived([title, artist].filter(Boolean).join(' — '));
/**
* Lowering and lifting the arm is the cueing lever's slow gesture; following
* the groove is a stream of small corrections. Two different transitions,
* and this flag says which one is due.
*/
let cueing = $state(false);
$effect(() => {
void active;
cueing = true;
const handle = eidos.timers.schedule(null, 1700, () => {
cueing = false;
});
return () => {
handle?.cancel();
};
});
/* ── Type fitting ───────────────────────────────────────────────────────
`<text>` neither wraps nor shrinks: a long title runs off the label. The
read is deferred and happens BEFORE the write — a layout read taken right
after a style write forces a reflow mid-turn, which this house forbids.
`getComputedTextLength` scales linearly with the font size, so the natural
length is recoverable from whatever size is currently applied. */
const LABEL_FIT = [
{ max: 128, base: 8 },
{ max: 132, base: 22 },
{ max: 130, base: 8 },
{ max: 130, base: 8 }
] as const;
let texts: (SVGTextElement | undefined)[] = $state([]);
function fit(el: SVGTextElement | undefined, max: number, base: number) {
if (!el?.getComputedTextLength) return;
const applied = parseFloat(el.style.fontSize) || base;
const measured = el.getComputedTextLength();
if (!measured) return;
const natural = (measured * base) / applied;
const next = natural > max ? Math.max(base * 0.45, (base * max) / natural) : base;
if (next !== applied) el.style.fontSize = `${next}px`;
}
$effect(() => {
// Explicit dependencies: any of these means the type must be re-fitted.
void [publisher, title, artist, year, rpmLabel];
// Spread, not a bare reference: reading each index is what makes the
// effect depend on the `bind:this` writes, which land after it first runs.
const els = [...texts];
// The node anchors the read to the right window (iframe / popup safe) —
// the bare form resolves nothing to schedule against.
const anchor = els.find(Boolean);
if (!anchor) return;
return eidos.dom.measure(() => {
LABEL_FIT.forEach((slot, i) => fit(els[i], slot.max, slot.base));
}, anchor);
});
const GROOVES = Array.from({ length: Math.floor((200 - 76 - 6) / 4) + 1 }, (_, i) => 78 + i * 4);
/* ── Spindle cone ───────────────────────────────────────────────────────
SVG 1.1 has no conic gradient, so it is synthesised as 36 wedges that
interpolate the stops of the original `conic-gradient`. */
const STOPS: [number, string][] = [
[0, '#6f5426'],
[0.24, '#d8b45c'],
[0.5, '#5e4620'],
[0.74, '#c9a24b'],
[1, '#5a4420']
];
function toRgb(c: string) {
const n = parseInt(c.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function blend(a: string, b: string, t: number) {
const A = toRgb(a);
const B = toRgb(b);
return `rgb(${A.map((v, i) => Math.round(v + (B[i] - v) * t)).join(',')})`;
}
function conic(t: number) {
for (let i = 1; i < STOPS.length; i++) {
if (t <= STOPS[i][0]) {
const [p0, c0] = STOPS[i - 1];
const [p1, c1] = STOPS[i];
return blend(c0, c1, (t - p0) / (p1 - p0));
}
}
return STOPS[STOPS.length - 1][1];
}
const WEDGES = Array.from({ length: 36 }, (_, i) => {
// `conic-gradient` starts at the top and runs clockwise; the original
// began at 30deg.
const point = (t: number) => {
const a = ((30 + t * 360) * Math.PI) / 180;
return [(450 + 18 * Math.sin(a)).toFixed(2), (100 - 18 * Math.cos(a)).toFixed(2)];
};
const [x0, y0] = point(i / 36);
const [x1, y1] = point((i + 1) / 36);
return {
d: `M450 100 L${x0} ${y0} A18 18 0 0 1 ${x1} ${y1} Z`,
color: conic((i + 0.5) / 36)
};
});
</script>
<svg
data-skin-media-player-record
data-active={active ? '' : undefined}
data-spinning={spinning ? '' : undefined}
data-cueing={cueing ? '' : undefined}
viewBox="0 0 525 570"
style="--_smp-turn:{turnSeconds(rpm)}s; --_smp-arm:{angle}deg"
role={name ? 'img' : undefined}
aria-label={name || undefined}
aria-hidden={name ? undefined : 'true'}
{...rest}
>
<defs>
<clipPath id="smp-label-{uid}">
<circle cx="240" cy="308" r="76" />
</clipPath>
<radialGradient id="smp-spindle-{uid}" cx="0.34" cy="0.28" r="0.98">
<stop offset="0.38" stop-color="#4a3a26" />
<stop offset="0.62" stop-color="#2a2016" />
<stop offset="1" stop-color="#100c08" />
</radialGradient>
<!-- CSS angles translated to x1/y1→x2/y2 vectors:
90deg → (0,.5)-(1,.5) · 100deg → (.007,.413)-(.993,.587)
150deg → (.25,.067)-(.75,.933) · 160deg → (.329,.03)-(.671,.97) -->
<linearGradient id="smp-counterweight-{uid}" x1="0.007" y1="0.413" x2="0.993" y2="0.587">
<stop offset="0" stop-color="#0e0a06" />
<stop offset="0.42" stop-color="#3d3124" />
<stop offset="1" stop-color="#181108" />
</linearGradient>
<linearGradient id="smp-pivot-{uid}" x1="0.007" y1="0.413" x2="0.993" y2="0.587">
<stop offset="0" stop-color="#5e4620" />
<stop offset="0.46" stop-color="#e0c078" />
<stop offset="1" stop-color="#6f5426" />
</linearGradient>
<linearGradient id="smp-collar-{uid}" x1="0" y1="0.5" x2="1" y2="0.5">
<stop offset="0" stop-color="#6f5426" />
<stop offset="0.5" stop-color="#c9a24b" />
<stop offset="1" stop-color="#5e4620" />
</linearGradient>
<linearGradient id="smp-tube-{uid}" x1="0" y1="0.5" x2="1" y2="0.5">
<stop offset="0" stop-color="#6b5225" />
<stop offset="0.22" stop-color="#a8853f" />
<stop offset="0.46" stop-color="#f0dda8" />
<stop offset="0.62" stop-color="#b9954a" />
<stop offset="1" stop-color="#5c451f" />
</linearGradient>
<linearGradient id="smp-clamp-{uid}" x1="0" y1="0.5" x2="1" y2="0.5">
<stop offset="0" stop-color="#5c451f" />
<stop offset="0.5" stop-color="#d8b45c" />
<stop offset="1" stop-color="#5c451f" />
</linearGradient>
<linearGradient id="smp-cartridge-{uid}" x1="0.329" y1="0.03" x2="0.671" y2="0.97">
<stop offset="0" stop-color="#2b2118" />
<stop offset="0.55" stop-color="#0d0906" />
<stop offset="1" stop-color="#241a10" />
</linearGradient>
<linearGradient id="smp-clip-{uid}" x1="0" y1="0.5" x2="1" y2="0.5">
<stop offset="0" stop-color="#8a6b2f" />
<stop offset="0.5" stop-color="#e0c078" />
<stop offset="1" stop-color="#6f5426" />
</linearGradient>
<linearGradient id="smp-headshell-{uid}" x1="0.25" y1="0.067" x2="0.75" y2="0.933">
<stop offset="0" stop-color="#3a2a18" />
<stop offset="1" stop-color="#120c07" />
</linearGradient>
<filter id="smp-disc-shadow-{uid}" x="-25%" y="-25%" width="150%" height="150%">
<feDropShadow dx="0" dy="18" stdDeviation="14" flood-color="#3a2e24" flood-opacity="0.4" />
</filter>
<filter id="smp-arm-shadow-{uid}" x="-40%" y="-20%" width="180%" height="140%">
<feDropShadow dx="1" dy="3" stdDeviation="4" flood-color="#140e09" flood-opacity="0.5" />
</filter>
<filter id="smp-spindle-shadow-{uid}" x="-60%" y="-60%" width="220%" height="220%">
<feDropShadow dx="0" dy="6" stdDeviation="6" flood-color="#1c120a" flood-opacity="0.55" />
</filter>
</defs>
<!-- ── Disc ───────────────────────────────────────────────────────────── -->
<g class="smp-disc" filter="url(#smp-disc-shadow-{uid})">
<circle cx="240" cy="308" r="200" fill="#17110c" />
<g fill="none" stroke="#241c15" stroke-width="1">
{#each GROOVES as r (r)}
<circle cx="240" cy="308" {r} />
{/each}
</g>
<circle cx="240" cy="308" r="199.5" fill="none" stroke="#e9dcbf" stroke-opacity="0.15" />
<!-- Label -->
<circle cx="240" cy="308" r="76" fill="#6e1f2a" />
{#if image}
<image
href={image}
x="164"
y="232"
width="152"
height="152"
preserveAspectRatio="xMidYMid slice"
clip-path="url(#smp-label-{uid})"
/>
<circle cx="240" cy="308" r="76" fill="#4a0f18" opacity={imageVeil} />
{/if}
<circle cx="240" cy="308" r="75.5" fill="none" stroke="#f4ecdc" stroke-opacity="0.35" />
<g text-anchor="middle" fill="#e0c7ac" font-family="'Cormorant Garamond',Georgia,serif">
<text bind:this={texts[0]} x="240" y="282" font-size="8" letter-spacing="2"
>{publisher.toUpperCase()}</text
>
<text
bind:this={texts[1]}
x="240"
y="304"
font-family="'Bodoni Moda',Georgia,serif"
font-style="italic"
font-size="22"
fill="#f4ecdc">{title}</text
>
<text bind:this={texts[2]} x="240" y="318" font-size="8" letter-spacing="1.6"
>{artist.toUpperCase()}</text
>
<text bind:this={texts[3]} x="240" y="328" font-size="8" letter-spacing="1.6"
>{year} · {rpmLabel}</text
>
</g>
<circle cx="240" cy="338" r="4.5" fill="#f4ecdc" />
</g>
<!-- ── Spindle base (UNDER the arm, as on the machine) ────────────────── -->
<g filter="url(#smp-spindle-shadow-{uid})">
<circle cx="450" cy="100" r="28" fill="url(#smp-spindle-{uid})" />
<path
d="M425.75 86 A28 28 0 0 1 474.25 86"
fill="none"
stroke="#e9dcbf"
stroke-opacity="0.28"
stroke-width="1.5"
/>
</g>
<!-- ── Tonearm ────────────────────────────────────────────────────────── -->
<g class="smp-arm" filter="url(#smp-arm-shadow-{uid})">
<rect
x="419"
y="32"
width="54"
height="34.3"
rx="23.8"
ry="15.1"
fill="url(#smp-counterweight-{uid})"
/>
<rect x="439.8" y="63.2" width="12.5" height="21.8" fill="url(#smp-collar-{uid})" />
<!-- border-radius 36% 36% 30% 30% does not fit a <rect>: it goes as a path -->
<path
d="M441.9 80.4 H450 A10.5 10.1 0 0 1 460.5 90.5 V100.1 A8.7 8.4 0 0 1 451.8 108.5
H440.1 A8.7 8.4 0 0 1 431.4 100.1 V90.5 A10.5 10.1 0 0 1 441.9 80.4 Z"
fill="url(#smp-pivot-{uid})"
/>
<rect x="440.8" y="106.9" width="10.4" height="174.7" fill="url(#smp-tube-{uid})" />
<rect x="437.2" y="275.4" width="17.7" height="15.6" rx="2" fill="url(#smp-clamp-{uid})" />
<!-- cartridge, tilted 16° on its mount -->
<g transform="rotate(-16 450.4 287.8)">
<rect
x="424.2"
y="287.8"
width="43.7"
height="37.4"
rx="2.5"
fill="url(#smp-cartridge-{uid})"
/>
<path
d="M424.2 290.3 A2.5 2.5 0 0 1 426.7 287.8 H465.4 A2.5 2.5 0 0 1 467.9 290.3"
fill="none"
stroke="#e9dcbf"
stroke-opacity="0.18"
/>
<rect x="447.8" y="275.1" width="22.7" height="14.2" rx="2" fill="url(#smp-clip-{uid})" />
<rect
x="427.7"
y="321.5"
width="14.9"
height="23.2"
rx="1.5"
fill="url(#smp-headshell-{uid})"
/>
<rect x="432.5" y="343.2" width="3.5" height="9" fill="#e9dcbf" />
</g>
</g>
<!-- ── Gold cap and screw, OVER the arm ───────────────────────────────── -->
<g>
{#each WEDGES as wedge, i (i)}
<path d={wedge.d} fill={wedge.color} stroke={wedge.color} stroke-width="0.5" />
{/each}
</g>
<circle
cx="450"
cy="100"
r="18"
fill="none"
stroke="#140e09"
stroke-opacity="0.55"
stroke-width="1"
/>
<circle cx="450" cy="100" r="4.4" fill="#2a2016" />
</svg>

@ -0,0 +1,37 @@
<script lang="ts">
/**
* `<Skin>` — the depicted object, mounted inside any `<MediaPlayer>`.
*
* It is a COMPLEMENT, not a player: none of the four objects has a volume
* knob and none can seek freely, so the canonical control bar stays. What
* the object DOES depict — the transport keys — is wired to the real parts.
*
* An eidos-only part by the four rules: it creates no behaviour, is no
* event target, owns no ARIA relationships, and emits only structure plus
* visual `data-*` the recipe reads.
*/
import './skin-media-player.css';
import { ActiveEidos } from '$uix/eidos';
import RecordSkin from './skin-media-player-record.svelte';
import Cassette from './skin-media-player-cassette.svelte';
import Turntable from './skin-media-player-turntable.svelte';
import Deck from './skin-media-player-deck.svelte';
import type { SkinProps } from './types';
let { skin = 'turntable', rpm = 45, ...identity }: SkinProps = $props();
const eidos = ActiveEidos.require();
const resolved = $derived(eidos.resolve(skin, 'turntable'));
</script>
<div data-skin-media-player-skin data-skin={resolved}>
{#if resolved === 'record'}
<RecordSkin {...identity} {rpm} />
{:else if resolved === 'cassette'}
<Cassette {...identity} />
{:else if resolved === 'deck'}
<Deck {...identity} />
{:else}
<Turntable {...identity} {rpm} />
{/if}
</div>

@ -0,0 +1,362 @@
<script lang="ts">
/**
* The turntable — cabinet, platter, strobe ring and the controls. The record
* nests in the platter well as an inner `<svg>`, so the `record` skin is
* reused whole rather than redrawn.
*
* viewBox 0 0 1000 880. The controls map onto the machine's own meanings:
* MARCHA → play · PARO → stop (pause + return to the start) ·
* ELEVADOR → the cueing lever, which lifts the stylus without stopping the
* platter, and that is exactly pause.
*
* The keys are real HTML `<button>`s laid over the paint — see
* `skin-media-player-key.svelte` for why an SVG `<g>` cannot be one.
*/
import { ActiveEidos } from '$uix/eidos';
import * as MediaPlayer from '$soma/components/media-player';
import { MediaPlayerProvider } from '$soma/components/media-player';
import Key from './skin-media-player-key.svelte';
import { engaged } from './geometry';
import RecordSkin from './skin-media-player-record.svelte';
import type { SkinIdentity, SkinMediaPlayerRpm } from './types';
let {
brand = 'Levante',
model = 'TD-45',
rpm = 45,
...identity
}: SkinIdentity & { rpm?: SkinMediaPlayerRpm } = $props();
const uid = $props.id();
const eidos = ActiveEidos.require();
const provider = MediaPlayerProvider.require();
const active = $derived(engaged(provider));
const playing = $derived(!provider.paused);
/** Platter speed belongs to the MACHINE, not to the record. Decorative. */
// svelte-ignore state_referenced_locally
let speed = $state<SkinMediaPlayerRpm>(rpm);
/**
* STOP has no part in the contract — the player exposes `togglePlay`, `seek`
* and `seekBy`, with no `stop`. Composing the two calls here is a deliberate
* decision (recorded in the README): a deck whose STOP does not stop is a
* worse lie than the coordination.
*/
function stop() {
if (!provider.paused) provider.togglePlay();
provider.seek(0);
}
/** Momentary key held down — the paint reads it; `:active` cannot cross from
* the overlay button back to the `<g>` it covers. */
let held = $state<string | null>(null);
const hold = (id: string) => ({
onpointerdown: () => (held = id),
onpointerup: () => (held = null),
onpointerleave: () => (held = null),
onblur: () => (held = null)
});
const stopLabel = $derived(eidos.langs.ts('#?components.media-player.stop|Stop'));
/**
* Strobe marks printed on the platter rim: they spin with it, so they share
* the record's animation and duration.
*
* They come in four groups rather than one continuous ring, because of
* temporal aliasing: at 60 fps and 45 rpm the platter advances 4.5° per
* frame, and with identical marks every 6° the shortest reading is −1.5° —
* backwards. Four groups repeat the pattern every 90° instead of every 6°,
* so 4.5° per frame is nowhere near half the period and the direction is
* determined. The long mark in each group gives an unambiguous reference.
*/
const STROBE = Array.from({ length: 4 }, (_, g) =>
Array.from({ length: 9 }, (_, i) => ({ a: g * 90 + i * 4, index: i === 0 }))
).flat();
/** Wood grain. */
const GRAIN = [190, 268, 352, 470, 588, 664, 742];
</script>
<svg
data-skin-media-player-turntable
data-active={active ? '' : undefined}
data-spinning={playing ? '' : undefined}
viewBox="0 0 1000 880"
aria-hidden="true"
>
<defs>
<linearGradient id="smp-wood-{uid}" x1="0" y1="0" x2="0.25" y2="1">
<stop offset="0" stop-color="#7a5430" />
<stop offset="0.45" stop-color="#5a3d22" />
<stop offset="1" stop-color="#3d2916" />
</linearGradient>
<linearGradient id="smp-plate-{uid}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#d7d0c0" />
<stop offset="0.2" stop-color="#b3ab9b" />
<stop offset="0.55" stop-color="#c9c1b0" />
<stop offset="0.85" stop-color="#a49c8c" />
<stop offset="1" stop-color="#c2baa9" />
</linearGradient>
<radialGradient id="smp-platter-{uid}" cx="0.4" cy="0.34" r="0.72">
<stop offset="0" stop-color="#cfc8ba" />
<stop offset="0.7" stop-color="#a49c8c" />
<stop offset="1" stop-color="#7e7666" />
</radialGradient>
<linearGradient id="smp-lid-{uid}" x1="0" y1="0" x2="0.2" y2="1">
<stop offset="0" stop-color="#4a4032" stop-opacity="0.5" />
<stop offset="1" stop-color="#140e09" stop-opacity="0.72" />
</linearGradient>
<linearGradient id="smp-button-{uid}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fbf7ec" />
<stop offset="0.66" stop-color="#eee5cf" />
<stop offset="1" stop-color="#cfc5ac" />
</linearGradient>
<linearGradient id="smp-chrome-{uid}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#efeae0" />
<stop offset="0.5" stop-color="#a49c8c" />
<stop offset="1" stop-color="#d7d0c0" />
</linearGradient>
<radialGradient id="smp-lamp-{uid}" cx="0.4" cy="0.35" r="0.7">
<stop offset="0" stop-color="#ffd08a" />
<stop offset="0.5" stop-color="#c8852a" />
<stop offset="1" stop-color="#5e3a0d" />
</radialGradient>
<filter id="smp-cabinet-shadow-{uid}" x="-10%" y="-10%" width="120%" height="130%">
<feDropShadow dx="0" dy="18" stdDeviation="16" flood-color="#3a2e24" flood-opacity="0.45" />
</filter>
</defs>
<!-- Dust lid, open, seen from above -->
<path
d="M186 124 L814 124 L786 40 Q784 32 774 32 L226 32 Q216 32 214 40 Z"
fill="url(#smp-lid-{uid})"
/>
<path d="M186 124 L814 124" stroke="#0f0a06" stroke-opacity="0.5" stroke-width="3" />
<path d="M300 124 L360 32 L430 32 L370 124 Z" fill="#ffffff" opacity="0.07" />
<rect x="232" y="112" width="66" height="20" rx="4" fill="#2a2016" />
<rect x="702" y="112" width="66" height="20" rx="4" fill="#2a2016" />
<!-- Cabinet and top plate -->
<g filter="url(#smp-cabinet-shadow-{uid})">
<rect x="50" y="115" width="900" height="720" rx="16" fill="url(#smp-wood-{uid})" />
<g stroke="#2e1f11" stroke-opacity="0.25" stroke-width="2">
{#each GRAIN as y (y)}
<path d="M50 {y} Q500 {y - 7} 950 {y}" fill="none" />
{/each}
</g>
<rect x="74" y="139" width="852" height="648" rx="10" fill="url(#smp-plate-{uid})" />
<rect
x="74"
y="139"
width="852"
height="648"
rx="10"
fill="none"
stroke="#6f6757"
stroke-opacity="0.6"
stroke-width="2"
/>
</g>
<!-- Platter, mat and strobe marks -->
<circle cx="406" cy="468.5" r="250" fill="url(#smp-platter-{uid})" />
<circle
cx="406"
cy="468.5"
r="250"
fill="none"
stroke="#6f6757"
stroke-opacity="0.7"
stroke-width="2"
/>
<g class="smp-strobe" style="--_smp-turn:{speed === 33 ? 1.8 : 1.333}s">
{#each STROBE as m (m.a)}
<rect
x={406 - (m.index ? 3.5 : 2)}
y={468.5 - (m.index ? 243 : 240)}
width={m.index ? 7 : 4}
height={m.index ? 19 : 13}
fill="#2a2016"
opacity={m.index ? 0.75 : 0.5}
transform="rotate({m.a} 406 468.5)"
/>
{/each}
</g>
<circle cx="406" cy="468.5" r="220" fill="#2a2620" />
<!-- Arm rest: sits under the arm, which parks on it when stopped -->
<rect x="642" y="500" width="26" height="52" rx="8" fill="url(#smp-chrome-{uid})" />
<path
d="M640 508 Q655 496 670 508"
fill="none"
stroke="#8d8577"
stroke-width="6"
stroke-linecap="round"
/>
<!-- The platter well: the record with its arm goes in here -->
<RecordSkin
{...identity}
rpm={speed}
x="150"
y="140"
width="560"
height="608"
preserveAspectRatio="xMidYMid meet"
aria-hidden="true"
role={undefined}
aria-label={undefined}
/>
<!-- The spindle passes through the record, so it paints over it -->
<circle cx="406" cy="468.5" r="9" fill="url(#smp-chrome-{uid})" />
<circle cx="406" cy="468.5" r="9" fill="none" stroke="#6f6757" stroke-width="1" />
<!-- ── Control paint (the hit areas are the buttons outside this svg) ──── -->
<text
x="831"
y="178"
text-anchor="middle"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="15"
letter-spacing="4"
fill="#5c5445">VELOCIDAD</text
>
{#each [{ v: 33 as const, x: 736 }, { v: 45 as const, x: 836 }] as b (b.v)}
<g class="smp-button" data-pressed={speed === b.v || held === `rpm-${b.v}` ? '' : undefined}>
<rect x={b.x} y="190" width="90" height="72" rx="6" fill="#0d0906" />
<g class="smp-button-face">
<rect x={b.x} y="190" width="90" height="64" rx="6" fill="url(#smp-button-{uid})" />
<text
x={b.x + 45}
y="234"
text-anchor="middle"
font-family="'Bodoni Moda',Georgia,serif"
font-weight="700"
font-size="30"
fill="#3a2e24">{b.v}</text
>
</g>
</g>
{/each}
{#each [{ id: 'run', y: 300, text: 'MARCHA', on: playing }, { id: 'stop', y: 390, text: 'PARO', on: false }] as b (b.id)}
<g class="smp-button" data-pressed={b.on || held === b.id ? '' : undefined}>
<rect x="736" y={b.y} width="190" height="78" rx="8" fill="#0d0906" />
<g class="smp-button-face">
<rect x="736" y={b.y} width="190" height="70" rx="8" fill="url(#smp-button-{uid})" />
<text
x="831"
y={b.y + 44}
text-anchor="middle"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="22"
letter-spacing="5"
fill={b.id === 'stop' ? '#6e1f2a' : '#3a2e24'}>{b.text}</text
>
</g>
</g>
{/each}
<!-- Cueing lever: lifts the stylus without stopping the platter = pause -->
<text
x="831"
y="512"
text-anchor="middle"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="15"
letter-spacing="4"
fill="#5c5445">ELEVADOR</text
>
<rect x="800" y="524" width="62" height="128" rx="10" fill="#241c15" />
<g class="smp-lever" data-raised={active && provider.paused ? '' : undefined}>
<rect x="792" y="582" width="78" height="52" rx="10" fill="url(#smp-chrome-{uid})" />
</g>
<circle cx="760" cy="700" r="11" fill="#3a3227" />
<circle class="smp-lamp" cx="760" cy="700" r="8" fill="url(#smp-lamp-{uid})" />
<text
x="782"
y="706"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="15"
letter-spacing="4"
fill="#5c5445"
>
RED
</text>
<!-- Brand plate -->
<rect x="96" y="728" width="240" height="50" rx="4" fill="#241c15" />
<text
x="216"
y="762"
text-anchor="middle"
font-family="'Bodoni Moda',Georgia,serif"
font-weight="700"
font-size="28"
fill="#e0c7ac">{brand}</text
>
<text
x="916"
y="762"
text-anchor="end"
font-family="'Cormorant Garamond',Georgia,serif"
font-size="16"
letter-spacing="5"
fill="#6f6757">MODELO {model}</text
>
</svg>
<!-- ── The affordances ──────────────────────────────────────────────────────
Speed is the machine's own visual state, so those two are plain buttons.
The rest go through the player's real parts, except STOP, which the
contract does not declare. -->
{#each [{ v: 33 as const, x: 736 }, { v: 45 as const, x: 836 }] as b (b.v)}
<Key
x={b.x}
y={190}
w={90}
h={72}
vw={1000}
vh={880}
aria-label="{b.v} rpm"
aria-pressed={speed === b.v}
onclick={() => (speed = b.v)}
{...hold(`rpm-${b.v}`)}
/>
{/each}
<MediaPlayer.PlayButton
onclick={(e: MouseEvent) => {
// MARCHA only starts. A bare toggle here would PAUSE a machine whose key
// is already latched down — the opposite of what the key means.
if (!provider.paused) e.preventDefault();
}}
>
{#snippet child({ props }: { props: Record<string, unknown> })}
<Key {...props} x={736} y={300} w={190} h={78} vw={1000} vh={880} {...hold('run')} />
{/snippet}
</MediaPlayer.PlayButton>
<Key
x={736}
y={390}
w={190}
h={78}
vw={1000}
vh={880}
aria-label={stopLabel}
onclick={stop}
{...hold('stop')}
/>
<MediaPlayer.PlayButton>
{#snippet child({ props }: { props: Record<string, unknown> })}
<!-- The lever travels 56 units when raised: the hit area covers both ends. -->
<Key {...props} x={792} y={526} w={78} h={108} vw={1000} vh={880} />
{/snippet}
</MediaPlayer.PlayButton>

@ -0,0 +1,303 @@
/* SkinMediaPlayer — a DEPICTED surface for the MediaPlayer.
*
* FIXED-TONE COMPONENT. The four objects are physical things: mahogany,
* brushed aluminium, chrome, ivory keys, a wine label, a gold spindle. Those
* hues are the depiction, not theme roles — a turntable does not turn blue
* because the theme does. They are declared as annotated literals in the SVG
* paint, under the exception `recipe-css-contract.test.ts` already carves for
* `natural-time-picker`, `color-picker` and `proof-of-human`. This file holds
* the geometry, the states and the movement; the palette stays with the paint.
*
* The keys are real HTML buttons laid over the SVG. Everything about them here
* is positioning and feedback: their focus ring, hit area and disabled
* semantics come from the platform + the focus-ring system.
*/
[data-skin-media-player-skin] {
--_smp-aspect: 1000 / 880;
/* A depicted object has a natural size. Left at `100%` the machines are
nearly square, so they eat the viewport and push the player's own control
bar below the fold — the sketches capped them for the same reason
(`clamp(…, 34vw, 400px)` scaled per object). */
--_smp-max: 760px;
position: relative;
display: block;
inline-size: 100%;
max-inline-size: var(--_smp-max);
aspect-ratio: var(--_smp-aspect);
margin-inline: auto;
}
[data-skin-media-player-skin][data-skin='record'] {
--_smp-aspect: 525 / 570;
--_smp-max: 525px;
}
[data-skin-media-player-skin][data-skin='cassette'] {
--_smp-aspect: 1000 / 629;
--_smp-max: 424px;
}
[data-skin-media-player-skin][data-skin='deck'] {
--_smp-aspect: 1000 / 780;
--_smp-max: 760px;
}
[data-skin-media-player-skin] > svg {
display: block;
inline-size: 100%;
block-size: 100%;
overflow: visible;
}
/* ── The record ───────────────────────────────────────────────────────────── */
[data-skin-media-player-record] .smp-disc {
transform-box: view-box;
transform-origin: 240px 308px;
}
/* The animation is only declared while a side is playing, so a stopped medium
returns the disc to 0° and a paused one freezes it where it was. */
[data-skin-media-player-record][data-active] .smp-disc {
animation: smp-turn var(--_smp-turn, 1.333s) linear infinite;
animation-play-state: paused;
}
[data-skin-media-player-record][data-spinning] .smp-disc {
animation-play-state: running;
}
[data-skin-media-player-record] .smp-arm {
transform-box: view-box;
/* The pivot. An explicit `transform-box` matters: without it the origin
depends on whatever initial value the browser applies and the arm swings
from somewhere else. */
transform-origin: 450px 100px;
transform: rotate(var(--_smp-arm, -9deg));
/* Following the groove: small, frequent steps. */
transition: transform 0.45s linear;
}
/* Lowering or lifting the arm: the cueing lever's slow, heavy gesture. */
[data-skin-media-player-record][data-cueing] .smp-arm {
transition: transform 1.7s cubic-bezier(0.35, 0.05, 0.2, 1);
}
/* ── The cassette ─────────────────────────────────────────────────────────── */
[data-skin-media-player-cassette] {
filter: drop-shadow(
0 20px 34px rgba(58, 46, 36, 0.45)
); /* literal: depicted object shadow, theme-independent */
}
/* The cassette's own shadow paints nothing inside the deck's window. */
[data-skin-media-player-deck] [data-skin-media-player-cassette] {
filter: none;
}
[data-skin-media-player-cassette] .smp-reel {
transform-box: fill-box;
transform-origin: center;
}
/* Free-running: with no duration the tape position is unknown, so the reels
turn at a constant rate instead of lying about where the tape is. */
[data-skin-media-player-cassette][data-active]:not([data-timed]) .smp-reel {
animation: smp-turn 3.4s linear infinite;
animation-play-state: paused;
}
[data-skin-media-player-cassette][data-active]:not([data-timed]) .smp-reel-right {
animation-duration: 2.4s;
}
[data-skin-media-player-cassette][data-spinning]:not([data-timed]) .smp-reel {
animation-play-state: running;
}
/* Timed: the angle comes from the elapsed time, and each reel's speed varies on
its own with the radius of its pack. The transition only smooths between the
updates the player pushes. */
[data-skin-media-player-cassette][data-timed] .smp-reel {
transform: rotate(var(--_smp-reel-left, 0deg));
transition: transform 0.35s linear;
}
[data-skin-media-player-cassette][data-timed] .smp-reel-right {
transform: rotate(var(--_smp-reel-right, 0deg));
}
/* Back to the start means back to zero: do not animate the rewind. */
[data-skin-media-player-cassette][data-timed]:not([data-active]) .smp-reel {
transition: none;
}
[data-skin-media-player-cassette][data-spinning] .smp-tape {
animation: smp-tape-pulse 1.6s ease-in-out infinite;
}
/* ── The turntable ────────────────────────────────────────────────────────── */
[data-skin-media-player-turntable] .smp-strobe {
transform-box: view-box;
transform-origin: 406px 468.5px;
}
[data-skin-media-player-turntable][data-active] .smp-strobe {
animation: smp-turn var(--_smp-turn, 1.333s) linear infinite;
animation-play-state: paused;
}
[data-skin-media-player-turntable][data-spinning] .smp-strobe {
animation-play-state: running;
}
.smp-button-face {
transform-box: view-box;
transform: translateY(0);
transition: transform 0.11s cubic-bezier(0.3, 0.7, 0.4, 1);
}
.smp-button[data-pressed] .smp-button-face {
transform: translateY(8px);
}
.smp-lever > rect {
transform-box: view-box;
transform: translateY(0);
transition: transform 0.35s cubic-bezier(0.35, 0.05, 0.2, 1);
}
.smp-lever[data-raised] > rect {
transform: translateY(-56px);
}
.smp-lamp {
opacity: 0.18; /* literal: an unlit bulb still shows its glass */
transition: opacity 0.2s;
}
[data-active] .smp-lamp {
opacity: 0.55; /* literal: powered, not playing */
}
[data-spinning] .smp-lamp {
opacity: 1;
}
[data-skin-media-player-turntable][data-active] .smp-lamp {
opacity: 1;
}
/* ── The deck ─────────────────────────────────────────────────────────────── */
.smp-piano-key-body {
transform-box: view-box;
transform: translateY(0);
transition: transform 0.11s cubic-bezier(0.3, 0.7, 0.4, 1);
}
.smp-piano-key[data-pressed] .smp-piano-key-body {
transform: translateY(14px);
}
.smp-piano-key[data-unavailable] .smp-piano-key-body {
opacity: var(--opacity-disabled);
}
.smp-needle {
transform-box: view-box;
transform-origin: 216px 574px;
transform: rotate(-26deg);
transition: transform 0.7s ease-out;
}
[data-skin-media-player-deck][data-spinning] .smp-needle {
animation: smp-vu 2.3s ease-in-out infinite;
}
/* ── The affordances ──────────────────────────────────────────────────────── */
[data-skin-media-player-key] {
/* Physical `left` / `top`: a depicted machine does not mirror — the tonearm
is on the right of every turntable ever built. The logical grid governs
flow, not objects. */
position: absolute;
padding: 0;
border: 0;
background: none;
color: inherit;
cursor: pointer;
border-radius: var(--radius-sm);
/* The paint lives in the SVG underneath; this is only the hit area. */
appearance: none;
}
[data-skin-media-player-key]:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
[data-skin-media-player-key]:disabled {
cursor: not-allowed;
}
/* ── Movement ─────────────────────────────────────────────────────────────── */
/* functional: the platter and the reels turn — this is the depicted object's
own mechanics, not a perceptual signature the motion channel could carry. */
@keyframes smp-turn {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* functional: the tape between the packs breathes while it runs. */
@keyframes smp-tape-pulse {
0%,
100% {
opacity: 0.82;
}
50% {
opacity: 1;
}
}
/* functional: a VU needle's swing is the instrument's behaviour, not a UI
transition — it is a depicted meter, and it never reads the real signal. */
@keyframes smp-vu {
0% {
transform: rotate(-17deg);
}
12% {
transform: rotate(11deg);
}
25% {
transform: rotate(-5deg);
}
38% {
transform: rotate(17deg);
}
52% {
transform: rotate(-12deg);
}
66% {
transform: rotate(7deg);
}
80% {
transform: rotate(-15deg);
}
92% {
transform: rotate(13deg);
}
100% {
transform: rotate(-17deg);
}
}
@media (prefers-reduced-motion: reduce) {
[data-skin-media-player-record][data-active] .smp-disc,
[data-skin-media-player-turntable][data-active] .smp-strobe,
[data-skin-media-player-cassette][data-active] .smp-reel,
[data-skin-media-player-cassette][data-spinning] .smp-tape,
[data-skin-media-player-deck][data-spinning] .smp-needle {
animation: none;
}
[data-skin-media-player-record] .smp-arm,
[data-skin-media-player-cassette][data-timed] .smp-reel,
.smp-button-face,
.smp-piano-key-body,
.smp-lever > rect {
transition: none;
}
[data-skin-media-player-deck][data-spinning] .smp-needle {
transform: rotate(4deg);
}
}

@ -0,0 +1,77 @@
<script lang="ts">
/**
* `<SkinMediaPlayer>` — the one-tag door: an `<audio>` player whose face is a
* depicted object. A COMPETING ROOT over the same soma contract (the
* Toast/Toaster precedent the `AudioPlayer` already set), deliberately not
* `MediaPlayer.Skin`.
*
* The bar below is not decoration and not optional by accident: the four
* objects have no volume knob and no free seek, so the depiction alone would
* be an incomplete player. `controls={false}` is for a consumer who supplies
* those two axes elsewhere.
*/
import './skin-media-player.css';
import { MediaPlayer } from '../media-player';
import Skin from './skin-media-player-skin.svelte';
import type { SkinMediaPlayerProps } from './types';
let {
skin = 'turntable',
rpm = 45,
src,
loop,
preload = 'metadata',
controls = true,
title,
artist,
year,
publisher,
image,
imageVeil,
side,
tapeType,
brand,
model,
metadata,
children,
...rest
}: SkinMediaPlayerProps = $props();
// The identity you SEE is the identity the OS shows — the same derivation
// `AudioPlayer` makes (D-AP2.5), from the label the object prints.
const resolvedMetadata = $derived(
metadata ??
(title || artist || image
? { title, artist, artwork: image ? [{ src: image }] : undefined }
: undefined)
);
</script>
<MediaPlayer media="audio" metadata={resolvedMetadata} data-skin-media-player {...rest}>
<MediaPlayer.Media {src} {loop} {preload} />
<Skin
{skin}
{rpm}
{title}
{artist}
{year}
{publisher}
{image}
{imageVeil}
{side}
{tapeType}
{brand}
{model}
/>
{#if controls}
<MediaPlayer.Controls>
<MediaPlayer.PlayButton />
<MediaPlayer.Time type="current" />
<MediaPlayer.TimeSlider />
<MediaPlayer.Time type="duration" />
<MediaPlayer.MuteButton />
<MediaPlayer.VolumeSlider />
</MediaPlayer.Controls>
{/if}
{@render children?.()}
</MediaPlayer>

@ -0,0 +1,76 @@
// SkinMediaPlayer — a DEPICTED surface for the MediaPlayer. The four skins are
// physical objects, so this file names them and the identity they print on
// themselves. No behaviour lives here: the player owns it all.
import type { MediaPlayerProps } from '../media-player/types';
import type { ResponsiveProp } from '$uix/eidos/lib/types';
/**
* The four depicted objects. Two are the bare medium (`record`, `cassette`) and
* two are the machine that plays it (`turntable` nests the record, `deck` nests
* the cassette) — the same pairing the source sketches had as
* `vinilo · giradiscos · casete · pletina`.
*/
export type SkinMediaPlayerSkin = 'record' | 'turntable' | 'cassette' | 'deck';
/** Platter speed. DECORATIVE: it drives the spin and the strobe, never the audio. */
export type SkinMediaPlayerRpm = 33 | 45;
/**
* What the object PRINTS on itself. These are content, not chrome: a record
* label and a cassette inlay carry the title the same way an `Artwork` does.
*/
export type SkinIdentity = {
/** Work title — the largest line on the label / inlay. */
title?: string;
/** Performer line. */
artist?: string;
/** Release year. */
year?: string;
/** Record label / publisher. */
publisher?: string;
/** Cover image printed on the record label or the inlay. */
image?: string;
/** Opacity of the wine veil over the image so the type stays legible. @default 0.55 */
imageVeil?: number;
/** Cassette side. @default 'A' */
side?: string;
/** Cassette length marking. @default 'C-60' */
tapeType?: string;
/** Machine brand, printed on the badge. @default 'Levante' */
brand?: string;
/** Machine model number. */
model?: string;
};
/** The depiction itself, mounted INSIDE any `<MediaPlayer>`. */
export type SkinProps = SkinIdentity & {
/** Which object to depict. @default 'turntable' */
skin?: ResponsiveProp<SkinMediaPlayerSkin>;
/**
* Platter speed for the two vinyl skins. Decorative — it sets the spin
* duration and the strobe, and never touches `playbackRate`. @default 45
*/
rpm?: SkinMediaPlayerRpm;
};
/**
* `<SkinMediaPlayer>` — the one-tag door: mounts `<MediaPlayer media="audio">`
* + the depiction + a canonical control bar. A COMPETING ROOT over the same
* soma contract (the Toast/Toaster precedent the `AudioPlayer` already set),
* deliberately not `MediaPlayer.Skin`.
*
* The depiction is a COMPLEMENT, never the whole player: none of the four
* objects has a volume knob and none can seek freely, so the canonical bar
* rides below and owns those two axes.
*/
export type SkinMediaPlayerProps = Omit<MediaPlayerProps, 'media'> &
SkinProps & {
/** Audio source URL (forwarded to the native `<audio>`). */
src?: string;
/** Native loop. */
loop?: boolean;
/** Native preload hint. @default 'metadata' */
preload?: 'none' | 'metadata' | 'auto';
/** Render the canonical control bar below the depiction. @default true */
controls?: boolean;
};

@ -223,8 +223,17 @@ describe('Eidos recipe CSS contract', () => {
// (color-picker-spectrum.css) are the wheel, annotated per line.
// `proof-of-human` depicts the messenger pigeon + letter (identity hues)
// and sizes its SVG text in viewBox user units — annotated per line.
// `skin-media-player` depicts four physical objects (record, turntable,
// cassette, deck): mahogany, brushed aluminium, chrome, ivory keys and a
// wine label are the depiction, not theme roles — a turntable does not turn
// blue because the theme does.
// The exemption covers BOTH depiction hues and viewBox-unit font sizes.
const FIXED_TONE_COMPONENTS = new Set(['natural-time-picker', 'color-picker', 'proof-of-human'])
const FIXED_TONE_COMPONENTS = new Set([
'natural-time-picker',
'color-picker',
'proof-of-human',
'skin-media-player'
])
it('keeps raw color values out of component CSS', () => {
const violations = readComponentCssFiles()

@ -0,0 +1,108 @@
<script lang="ts">
// Verification bench for the media-player skins. The MediaPlayer is composed
// HERE, in the open: the skin is one child among the player's own parts, and
// the shape of the player is chosen on the player, never by the skin.
import { onDestroy } from 'svelte';
import { createActiveUix, setActiveUix } from '$active-uix';
import { Soma } from '$soma/core/soma.svelte';
import { ActiveEidos } from '$uix/eidos';
import { MediaPlayer } from '$uix/eidos/components/media-player';
import { AudioPlayer } from '$uix/eidos/components/audio-player';
import { Skin } from '$uix/eidos/components/skin-media-player';
import type { SkinMediaPlayerSkin } from '$uix/eidos/components/skin-media-player';
import type { AudioPlayerVariant } from '$uix/eidos/components/audio-player';
const uix = createActiveUix({ langs: { schema: {}, defaultLocale: 'es' as const } });
setActiveUix(uix);
Soma.create();
const eidos = ActiveEidos.create({ applyDom: true, theme: 'base' });
onDestroy(() => {
eidos.dispose();
uix.dispose();
});
const SKINS: SkinMediaPlayerSkin[] = ['record', 'turntable', 'cassette', 'deck'];
const VARIANTS: AudioPlayerVariant[] = ['card', 'row', 'bar', 'inline'];
let skin = $state<SkinMediaPlayerSkin>('turntable');
let variant = $state<AudioPlayerVariant>('card');
const TRACK = {
src: '/music/t2.wav',
title: 'Quédate',
artist: 'Juan Luis Alarcón',
year: '1973',
publisher: 'Discos Levante',
image: '/images/298.jpg'
};
</script>
<div style="padding:2rem;max-width:960px;margin-inline:auto;display:grid;gap:2.5rem">
<div style="display:flex;gap:1.5rem;flex-wrap:wrap">
<div style="display:flex;gap:.35rem;flex-wrap:wrap">
{#each SKINS as s (s)}
<button type="button" data-bench-skin={s} aria-pressed={skin === s} onclick={() => (skin = s)}
>{s}</button
>
{/each}
</div>
<div style="display:flex;gap:.35rem;flex-wrap:wrap">
{#each VARIANTS as v (v)}
<button
type="button"
data-bench-variant={v}
aria-pressed={variant === v}
onclick={() => (variant = v)}>{v}</button
>
{/each}
</div>
</div>
<!-- A · the player composed by hand: every part is visible in this file. -->
<section data-bench="compose">
<h2>MediaPlayer compuesto a mano</h2>
<MediaPlayer media="audio">
<MediaPlayer.Media src={TRACK.src} preload="metadata" />
<Skin
{skin}
title={TRACK.title}
artist={TRACK.artist}
year={TRACK.year}
publisher={TRACK.publisher}
image={TRACK.image}
/>
<MediaPlayer.Controls>
<MediaPlayer.PlayButton />
<MediaPlayer.SeekButton direction="backward" />
<MediaPlayer.SeekButton direction="forward" />
<MediaPlayer.Time type="current" />
<MediaPlayer.TimeSlider />
<MediaPlayer.Time type="duration" />
<MediaPlayer.MuteButton />
<MediaPlayer.VolumeSlider />
</MediaPlayer.Controls>
</MediaPlayer>
</section>
<!-- B · the same skin inside the shaped audio player. The shape (card / row /
bar / inline) is the PLAYER's axis; the skin rides along as a child. -->
<section data-bench="audio-player">
<h2>AudioPlayer variant="{variant}"</h2>
<AudioPlayer
{variant}
src={TRACK.src}
title={TRACK.title}
artist={TRACK.artist}
artworkSrc={TRACK.image}
>
<Skin
{skin}
title={TRACK.title}
artist={TRACK.artist}
year={TRACK.year}
publisher={TRACK.publisher}
image={TRACK.image}
/>
</AudioPlayer>
</section>
</div>

@ -0,0 +1,314 @@
<script>
/**
* Casete C-60: carcasa, etiqueta, ventana con la cinta y dos bobinas.
* viewBox 1000 × 629. La etiqueta de papel ocupa x 112–888, y 74–430.
*
* @typedef {'parado' | 'sonando' | 'pausado'} EstadoRepro
*/
let {
/** @type {EstadoRepro} */
estado = 'parado',
cara = 'A',
titulo = 'Quédate',
artista = 'Juan Luis Alarcón',
anio = '1973',
sello = 'Discos Levante',
tipo = 'C-60',
/** URL o data-URL de la foto que va en la etiqueta. Vacío = solo texto. */
imagen = '',
/** Segundo actual de reproducción. */
tiempo = 0,
/** Duración total en segundos. Con 0, giro constante y cinta sin empezar. */
duracion = 0,
/** Vueltas que da cada bobina de vacía a llena. Es un ajuste de gusto. */
vueltas = 42,
ancho = 'clamp(260px, 34vw, 400px)'
} = $props();
/**
* Los id de un <defs> viven en el espacio global del documento, así que dos
* casetes en la misma página se pisarían los gradientes. $props.id() da un id
* estable y único por instancia (y coherente entre SSR e hidratación).
* Requiere Svelte >= 5.20. Alternativa: `const uid = crypto.randomUUID()`.
*/
const uid = $props.id();
let sonando = $derived(estado === 'sonando');
let activo = $derived(estado !== 'parado');
// ── Cinta ────────────────────────────────────────────────────
// Izquierda es la suministradora y empieza llena; derecha es la receptora y
// empieza vacía. El área de cinta se conserva, así que R_izq² + R_der² es
// constante: el radio cambia deprisa cuando el paquete es pequeño y despacio
// cuando es grande, que es justo lo que se ve en un casete de verdad.
const R_NUCLEO = 27;
const R_LLENO = 54;
const _SALTO = R_LLENO * R_LLENO - R_NUCLEO * R_NUCLEO;
let cronometrado = $derived(duracion > 0);
let progreso = $derived(cronometrado ? Math.min(1, Math.max(0, tiempo / duracion)) : 0);
let radioIzq = $derived(Math.sqrt(R_LLENO * R_LLENO - progreso * _SALTO));
let radioDer = $derived(Math.sqrt(R_NUCLEO * R_NUCLEO + progreso * _SALTO));
// Cada vuelta añade un grosor de cinta al paquete, así que las vueltas dadas
// son proporcionales a lo que ha crecido el radio. De ahí sale sola la
// velocidad correcta: la bobina pequeña gira más deprisa que la grande.
// Se hace por ángulo y no cambiando animation-duration porque tocar la
// duración de una animación en marcha la hace saltar.
const _porVuelta = (360 * vueltas) / (R_LLENO - R_NUCLEO);
let giroIzq = $derived((R_LLENO - radioIzq) * _porVuelta);
let giroDer = $derived((radioDer - R_NUCLEO) * _porVuelta);
/** Con foto, el texto dispone de menos sitio antes de chocar con ella. */
let anchoTexto = $derived(imagen ? 436 : 626);
// <text> no salta de línea ni se encoge solo: medimos y bajamos el cuerpo.
/** @type {SVGTextElement|undefined} */ let refTitulo;
/** @type {SVGTextElement|undefined} */ let refArtista;
/** @type {SVGTextElement|undefined} */ let refSello;
/**
* @param {SVGTextElement|undefined} el
* @param {number} max
* @param {number} base
*/
function encajar(el, max, base) {
if (!el?.getComputedTextLength) return;
el.style.fontSize = `${base}px`;
const w = el.getComputedTextLength();
if (w > max) el.style.fontSize = `${Math.max(base * 0.45, (base * max) / w)}px`;
}
$effect(() => {
[titulo, artista, anio, sello, tipo, anchoTexto];
encajar(refTitulo, anchoTexto, 56);
encajar(refArtista, anchoTexto, 24);
encajar(refSello, anchoTexto, 20);
});
</script>
<svg
class="casete"
class:activo
class:sonando
class:crono={cronometrado}
class:libre={!cronometrado}
viewBox="0 0 1000 629"
style="width:{ancho}; --giro-izq:{giroIzq}deg; --giro-der:{giroDer}deg"
role="img"
aria-label="Casete de «{titulo}», de {artista}"
>
<defs>
<radialGradient id="tornillo-{uid}" cx="0.36" cy="0.32" r="0.75">
<stop offset="0" stop-color="#8a8175" />
<stop offset="1" stop-color="#3a3227" />
</radialGradient>
<linearGradient id="carcasa-{uid}" x1="0" y1="0" x2="0.35" y2="1">
<stop offset="0" stop-color="#2c2318" />
<stop offset="1" stop-color="#171009" />
</linearGradient>
<linearGradient id="brillo-{uid}" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.13" />
<stop offset="0.45" stop-color="#ffffff" stop-opacity="0" />
</linearGradient>
<radialGradient id="pack-{uid}" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#57381a" />
<stop offset="1" stop-color="#33200d" />
</radialGradient>
<clipPath id="ventana-{uid}">
<rect x="278" y="240" width="444" height="142" rx="22" />
</clipPath>
<clipPath id="foto-{uid}">
<rect x="700" y="92" width="150" height="150" rx="6" />
</clipPath>
</defs>
<!-- Carcasa y tornillos -->
<rect x="4" y="4" width="992" height="621" rx="34" fill="url(#carcasa-{uid})" />
<rect x="4" y="4" width="992" height="621" rx="34" fill="url(#brillo-{uid})" />
<rect x="16" y="16" width="968" height="597" rx="26" fill="none" stroke="#3f3527" stroke-width="2" />
<circle cx="60" cy="60" r="17" fill="url(#tornillo-{uid})" />
<path d="M60 49v22M49 60h22" stroke="#1b140d" stroke-width="4" stroke-linecap="round" />
<circle cx="940" cy="60" r="17" fill="url(#tornillo-{uid})" />
<path d="M940 49v22M929 60h22" stroke="#1b140d" stroke-width="4" stroke-linecap="round" />
<circle cx="60" cy="569" r="17" fill="url(#tornillo-{uid})" />
<path d="M60 558v22M49 569h22" stroke="#1b140d" stroke-width="4" stroke-linecap="round" />
<circle cx="940" cy="569" r="17" fill="url(#tornillo-{uid})" />
<path d="M940 558v22M929 569h22" stroke="#1b140d" stroke-width="4" stroke-linecap="round" />
<!-- Etiqueta -->
<rect x="112" y="74" width="776" height="356" fill="#f2e8d0" />
<rect x="112" y="252" width="776" height="178" fill="#6e1f2a" />
<rect x="112" y="74" width="776" height="356" fill="none" stroke="#1b140d" stroke-opacity="0.25" stroke-width="2" />
{#if imagen}
<image
href={imagen}
x="700"
y="92"
width="150"
height="150"
preserveAspectRatio="xMidYMid slice"
clip-path="url(#foto-{uid})"
/>
<rect
x="700"
y="92"
width="150"
height="150"
rx="6"
fill="none"
stroke="#1b140d"
stroke-opacity="0.35"
stroke-width="2"
/>
{/if}
<text x="150" y="176" font-family="'Bodoni Moda',serif" font-weight="700" font-size="74" fill="#231b13">{cara}</text>
<text bind:this={refTitulo} x="244" y="130" font-family="'Bodoni Moda',serif" font-style="italic" font-size="56" fill="#231b13">{titulo}</text>
<text bind:this={refArtista} x="246" y="166" font-family="'Cormorant Garamond',serif" font-size="24" letter-spacing="4" fill="#6e1f2a">{artista.toUpperCase()} · {anio}</text>
<text bind:this={refSello} x="246" y="196" font-family="'Cormorant Garamond',serif" font-size="20" letter-spacing="4" fill="#a8853f">{sello.toUpperCase()} · {tipo}</text>
<!-- Ventana: paquetes de cinta y ejes -->
<rect x="266" y="228" width="468" height="166" rx="30" fill="#1b140d" />
<rect x="278" y="240" width="444" height="142" rx="22" fill="#0f0a06" />
<g clip-path="url(#ventana-{uid})">
<rect x="278" y="240" width="444" height="142" fill="#0f0a06" />
<circle cx="366" cy="311" r="56" fill="#0b0805" />
<circle cx="634" cy="311" r="56" fill="#0b0805" />
<!-- Tramo de cinta entre los dos paquetes -->
<rect
class="cinta"
x={366 + radioIzq}
y="304"
width={268 - radioIzq - radioDer}
height="14"
fill="#3a240f"
/>
<!-- Los paquetes no giran: son discos lisos y girarlos no se vería,
además de invitar al mismo aliasing que las marcas del plato. -->
{#each [{ cx: 366, r: radioIzq }, { cx: 634, r: radioDer }] as paq (paq.cx)}
<circle cx={paq.cx} cy="311" r={paq.r} fill="url(#pack-{uid})" stroke="#6b6355" stroke-opacity="0.45" stroke-width="2" />
<circle cx={paq.cx} cy="311" r={(paq.r + R_NUCLEO) / 2} fill="none" stroke="#1b140d" stroke-opacity="0.4" stroke-width="2" />
{/each}
<g class="bobina">
<g transform="translate(366 311) scale(0.565) translate(-366 -311)">
<circle cx="366" cy="311" r="46" fill="#e9e4d8" stroke="#8d8577" stroke-width="6" />
<g stroke="#8d8577" stroke-width="10" stroke-linecap="round">
<path d="M366 275v20M366 327v20M330 311h20M382 311h20M340 285l14 14M378 323l14 14M392 285l-14 14M354 323l-14 14" />
</g>
<circle cx="366" cy="311" r="16" fill="#8d8577" />
</g>
</g>
<g class="bobina der">
<g transform="translate(634 311) scale(0.565) translate(-634 -311)">
<circle cx="634" cy="311" r="46" fill="#e9e4d8" stroke="#8d8577" stroke-width="6" />
<g stroke="#8d8577" stroke-width="10" stroke-linecap="round">
<path d="M634 275v20M634 327v20M598 311h20M650 311h20M608 285l14 14M646 323l14 14M660 285l-14 14M622 323l-14 14" />
</g>
<circle cx="634" cy="311" r="16" fill="#8d8577" />
</g>
</g>
<circle cx="366" cy="311" r="56" fill="none" stroke="#6b6355" stroke-opacity="0.55" stroke-width="3" />
<circle cx="634" cy="311" r="56" fill="none" stroke="#6b6355" stroke-opacity="0.55" stroke-width="3" />
</g>
<rect x="266" y="228" width="468" height="166" rx="30" fill="none" stroke="#000000" stroke-opacity="0.4" stroke-width="2" />
<!-- Faldón inferior -->
<path
d="M262 629 L286 452 Q288 440 302 440 L698 440 Q712 440 714 452 L738 629"
fill="none"
stroke="#6b6355"
stroke-width="4"
/>
<circle cx="352" cy="546" r="17" fill="#f7f2e6" />
<rect x="416" y="532" width="42" height="28" rx="5" fill="#f7f2e6" />
<rect x="542" y="532" width="42" height="28" rx="5" fill="#f7f2e6" />
<circle cx="648" cy="546" r="17" fill="#f7f2e6" />
</svg>
<style>
.casete {
display: block;
height: auto;
filter: drop-shadow(0 20px 34px rgba(58, 46, 36, 0.45));
}
.bobina {
transform-box: fill-box;
transform-origin: center;
}
/* Modo libre: sin duración no sabemos por dónde va la cinta, así que giro
constante con keyframes, como antes. */
.libre.activo .bobina {
animation: girar 3.4s linear infinite;
animation-play-state: paused;
}
.libre.activo .der {
animation-duration: 2.4s;
}
.libre.sonando .bobina {
animation-play-state: running;
}
/* Modo cronometrado: el ángulo sale del tiempo, y la velocidad de cada
bobina varía sola con el radio de su paquete. La transición solo suaviza
entre las actualizaciones que manda quien reproduce (unas 4 por segundo
si vienen de un timeupdate). */
.crono .bobina {
transform: rotate(var(--giro-izq));
transition: transform 0.35s linear;
}
.crono .der {
transform: rotate(var(--giro-der));
}
/* Al parar, el tiempo vuelve a 0: que no rebobine con animación. */
.crono:not(.activo) .bobina {
transition: none;
}
/* El original definía este keyframe pero ninguna clase .jla-cinta existía
en el marcado, así que nunca llegaba a verse. */
.cinta {
opacity: 1;
}
.sonando .cinta {
animation: latido 1.6s ease-in-out infinite;
}
@keyframes girar {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes latido {
0%,
100% {
opacity: 0.82;
}
50% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.libre.activo .bobina,
.sonando .cinta {
animation: none;
}
.crono .bobina {
transition: none;
}
}
</style>

@ -0,0 +1,439 @@
<script>
/**
* Giradiscos de mueble, 100 % SVG (viewBox 0 0 1000 880).
*
* Mismo patrón que Pletina/Casete: el aparato es este componente y el disco
* con su brazo entra como snippet, así que el Tocadiscos se reutiliza tal
* cual dentro del hueco del plato.
*
* <Giradiscos {estado} onplay={…} onpause={…} onstop={…}>
* {#snippet disco({ vuelta })}
* <Tocadiscos {estado} {vuelta} {titulo} {artista} ancho="100%" />
* {/snippet}
* </Giradiscos>
*
* El snippet recibe la velocidad porque la elige el aparato, no el disco:
* 33⅓ r.p.m. son 1,8 s por vuelta y 45 r.p.m. son 1,333 s.
*
* Los mandos mapean exactamente sobre los tres estados:
* MARCHA → sonando · PARO → parado · ELEVADOR → pausado
* que es justo lo que hacen en el aparato de verdad: la palanca levanta la
* aguja sin detener el plato.
*
* @typedef {'parado' | 'sonando' | 'pausado'} EstadoRepro
*/
let {
/** @type {EstadoRepro} */
estado = 'parado',
velocidadInicial = 45,
marca = 'Levante',
modelo = 'TD-45',
/** @type {import('svelte').Snippet<[{ velocidad: number, vuelta: number }]> | undefined} */
disco = undefined,
ancho = 'clamp(320px, 62vw, 760px)',
onplay = () => {},
onpause = () => {},
onstop = () => {},
onvelocidad = (/** @type {number} */ v) => {}
} = $props();
const uid = $props.id();
let sonando = $derived(estado === 'sonando');
let activo = $derived(estado !== 'parado');
/** La velocidad es del aparato, no del disco. */
let velocidad = $state(velocidadInicial);
let vuelta = $derived(velocidad === 33 ? 1.8 : 1.333);
/** @param {number} v */
function cambiarVelocidad(v) {
velocidad = v;
onvelocidad(v);
}
function elevador() {
if (estado === 'pausado' || estado === 'parado') onplay();
else onpause();
}
/** @param {KeyboardEvent} e @param {() => void} fn */
function teclado(e, fn) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
fn();
}
}
// ── Geometría ────────────────────────────────────────────────
const PLATO = { cx: 406, cy: 468.5, r: 250 };
/**
* Marcas estroboscópicas del borde del plato: van impresas en él, así que
* giran con él y comparten animación y duración con el disco.
*
* Van en cuatro grupos, no en un anillo continuo, por aliasing temporal: a
* 60 fps y 45 r.p.m. el plato avanza 4,5° por fotograma, y con marcas
* idénticas cada 6° la lectura de menor recorrido es -1,5°, o sea hacia
* atrás. Con cuatro grupos el patrón se repite cada 90° en vez de cada 6°,
* así que 4,5° por fotograma no llega ni de lejos a la mitad del periodo y
* el sentido queda determinado. La marca larga de cada grupo da además una
* referencia inequívoca.
*/
const GRUPOS = 4;
const estrobo = Array.from({ length: GRUPOS }, (_, g) =>
Array.from({ length: 9 }, (_, i) => ({
a: g * (360 / GRUPOS) + i * 4,
indice: i === 0
}))
).flat();
/** Vetas de la madera. */
const vetas = [190, 268, 352, 470, 588, 664, 742];
</script>
<svg
class="giradiscos"
class:activo
class:sonando
viewBox="0 0 1000 880"
style="width:{ancho}; --vuelta:{vuelta}s"
role="group"
aria-label="Giradiscos {marca} {modelo}"
>
<defs>
<linearGradient id="madera-{uid}" x1="0" y1="0" x2="0.25" y2="1">
<stop offset="0" stop-color="#7a5430" />
<stop offset="0.45" stop-color="#5a3d22" />
<stop offset="1" stop-color="#3d2916" />
</linearGradient>
<linearGradient id="chapa-{uid}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#d7d0c0" />
<stop offset="0.2" stop-color="#b3ab9b" />
<stop offset="0.55" stop-color="#c9c1b0" />
<stop offset="0.85" stop-color="#a49c8c" />
<stop offset="1" stop-color="#c2baa9" />
</linearGradient>
<radialGradient id="plato-{uid}" cx="0.4" cy="0.34" r="0.72">
<stop offset="0" stop-color="#cfc8ba" />
<stop offset="0.7" stop-color="#a49c8c" />
<stop offset="1" stop-color="#7e7666" />
</radialGradient>
<linearGradient id="tapa-{uid}" x1="0" y1="0" x2="0.2" y2="1">
<stop offset="0" stop-color="#4a4032" stop-opacity="0.5" />
<stop offset="1" stop-color="#140e09" stop-opacity="0.72" />
</linearGradient>
<linearGradient id="boton-{uid}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fbf7ec" />
<stop offset="0.66" stop-color="#eee5cf" />
<stop offset="1" stop-color="#cfc5ac" />
</linearGradient>
<linearGradient id="cromo-{uid}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#efeae0" />
<stop offset="0.5" stop-color="#a49c8c" />
<stop offset="1" stop-color="#d7d0c0" />
</linearGradient>
<radialGradient id="piloto-{uid}" cx="0.4" cy="0.35" r="0.7">
<stop offset="0" stop-color="#ffd08a" />
<stop offset="0.5" stop-color="#c8852a" />
<stop offset="1" stop-color="#5e3a0d" />
</radialGradient>
<filter id="sombra-{uid}" x="-10%" y="-10%" width="120%" height="130%">
<feDropShadow dx="0" dy="18" stdDeviation="16" flood-color="#3a2e24" flood-opacity="0.45" />
</filter>
</defs>
<!-- Tapa antipolvo abierta, vista desde arriba -->
<path
d="M186 124 L814 124 L786 40 Q784 32 774 32 L226 32 Q216 32 214 40 Z"
fill="url(#tapa-{uid})"
/>
<path d="M186 124 L814 124" stroke="#0f0a06" stroke-opacity="0.5" stroke-width="3" />
<path d="M300 124 L360 32 L430 32 L370 124 Z" fill="#ffffff" opacity="0.07" />
<rect x="232" y="112" width="66" height="20" rx="4" fill="#2a2016" />
<rect x="702" y="112" width="66" height="20" rx="4" fill="#2a2016" />
<!-- Mueble y chapa superior -->
<g filter="url(#sombra-{uid})">
<rect x="50" y="115" width="900" height="720" rx="16" fill="url(#madera-{uid})" />
<g stroke="#2e1f11" stroke-opacity="0.25" stroke-width="2">
{#each vetas as y (y)}
<path d="M50 {y} Q500 {y - 7} 950 {y}" fill="none" />
{/each}
</g>
<rect x="74" y="139" width="852" height="648" rx="10" fill="url(#chapa-{uid})" />
<rect
x="74"
y="139"
width="852"
height="648"
rx="10"
fill="none"
stroke="#6f6757"
stroke-opacity="0.6"
stroke-width="2"
/>
</g>
<!-- Plato, tapete y marcas estroboscópicas -->
<circle cx={PLATO.cx} cy={PLATO.cy} r={PLATO.r} fill="url(#plato-{uid})" />
<circle
cx={PLATO.cx}
cy={PLATO.cy}
r={PLATO.r}
fill="none"
stroke="#6f6757"
stroke-opacity="0.7"
stroke-width="2"
/>
<g class="estrobo">
{#each estrobo as m (m.a)}
<rect
x={PLATO.cx - (m.indice ? 3.5 : 2)}
y={PLATO.cy - (m.indice ? 243 : 240)}
width={m.indice ? 7 : 4}
height={m.indice ? 19 : 13}
fill="#2a2016"
opacity={m.indice ? 0.75 : 0.5}
transform="rotate({m.a} {PLATO.cx} {PLATO.cy})"
/>
{/each}
</g>
<circle cx={PLATO.cx} cy={PLATO.cy} r="220" fill="#2a2620" />
<!-- Apoyabrazos: queda debajo del brazo, que descansa encima al parar -->
<rect x="642" y="500" width="26" height="52" rx="8" fill="url(#cromo-{uid})" />
<path
d="M640 508 Q655 496 670 508"
fill="none"
stroke="#8d8577"
stroke-width="6"
stroke-linecap="round"
/>
<!-- Hueco del disco: aquí entra el Tocadiscos con su brazo -->
<svg
class="hueco"
x="150"
y="140"
width="560"
height="608"
viewBox="0 0 525 570"
preserveAspectRatio="xMidYMid meet"
>
{#if disco}
{@render disco({ velocidad, vuelta })}
{:else}
<circle cx="240" cy="308" r="200" fill="#2a2620" />
<text
x="240"
y="316"
text-anchor="middle"
font-family="'Cormorant Garamond',serif"
font-size="28"
letter-spacing="8"
fill="#5c5445">SIN DISCO</text
>
{/if}
</svg>
<!-- El eje atraviesa el disco, así que va por encima -->
<circle cx={PLATO.cx} cy={PLATO.cy} r="9" fill="url(#cromo-{uid})" />
<circle cx={PLATO.cx} cy={PLATO.cy} r="9" fill="none" stroke="#6f6757" stroke-width="1" />
<!-- ── Mandos ─────────────────────────────────────────────── -->
<text
x="831"
y="178"
text-anchor="middle"
font-family="'Cormorant Garamond',serif"
font-size="15"
letter-spacing="4"
fill="#5c5445">VELOCIDAD</text
>
{#each [{ v: 33, x: 736 }, { v: 45, x: 836 }] as b (b.v)}
<g
class="boton"
class:hundido={velocidad === b.v}
role="button"
tabindex="0"
aria-label="{b.v} revoluciones por minuto"
aria-pressed={velocidad === b.v}
onclick={() => cambiarVelocidad(b.v)}
onkeydown={(e) => teclado(e, () => cambiarVelocidad(b.v))}
>
<rect x={b.x} y="190" width="90" height="72" rx="6" fill="#0d0906" />
<g class="cara">
<rect x={b.x} y="190" width="90" height="64" rx="6" fill="url(#boton-{uid})" />
<text
x={b.x + 45}
y="234"
text-anchor="middle"
font-family="'Bodoni Moda',serif"
font-weight="700"
font-size="30"
fill="#3a2e24">{b.v}</text
>
</g>
</g>
{/each}
{#each [{ id: 'marcha', y: 300, texto: 'MARCHA', fn: onplay }, { id: 'paro', y: 390, texto: 'PARO', fn: onstop }] as b (b.id)}
<g
class="boton"
class:hundido={b.id === 'marcha' ? activo : false}
role="button"
tabindex="0"
aria-label={b.texto}
onclick={b.fn}
onkeydown={(e) => teclado(e, b.fn)}
>
<rect x="736" y={b.y} width="190" height="78" rx="8" fill="#0d0906" />
<g class="cara">
<rect x="736" y={b.y} width="190" height="70" rx="8" fill="url(#boton-{uid})" />
<text
x="831"
y={b.y + 44}
text-anchor="middle"
font-family="'Cormorant Garamond',serif"
font-size="22"
letter-spacing="5"
fill={b.id === 'paro' ? '#6e1f2a' : '#3a2e24'}>{b.texto}</text
>
</g>
</g>
{/each}
<!-- Elevador: levanta la aguja sin parar el plato = pausa -->
<text
x="831"
y="512"
text-anchor="middle"
font-family="'Cormorant Garamond',serif"
font-size="15"
letter-spacing="4"
fill="#5c5445">ELEVADOR</text
>
<rect x="800" y="524" width="62" height="128" rx="10" fill="#241c15" />
<g
class="palanca"
class:arriba={estado === 'pausado'}
role="button"
tabindex="0"
aria-label="Elevador del brazo"
aria-pressed={estado === 'pausado'}
onclick={elevador}
onkeydown={(e) => teclado(e, elevador)}
>
<rect class="puno" x="792" y="582" width="78" height="52" rx="10" fill="url(#cromo-{uid})" />
</g>
<circle cx="760" cy="700" r="11" fill="#3a3227" />
<circle class="piloto" cx="760" cy="700" r="8" fill="url(#piloto-{uid})" />
<text x="782" y="706" font-family="'Cormorant Garamond',serif" font-size="15" letter-spacing="4" fill="#5c5445">
RED
</text>
<!-- Placa de marca -->
<rect x="96" y="728" width="240" height="50" rx="4" fill="#241c15" />
<text
x="216"
y="762"
text-anchor="middle"
font-family="'Bodoni Moda',serif"
font-weight="700"
font-size="28"
fill="#e0c7ac">{marca}</text
>
<text
x="916"
y="762"
text-anchor="end"
font-family="'Cormorant Garamond',serif"
font-size="16"
letter-spacing="5"
fill="#6f6757">MODELO {modelo}</text
>
</svg>
<style>
.giradiscos {
display: block;
height: auto;
}
/* Las marcas del borde están impresas en el plato: giran con él y a su
misma velocidad, por eso comparten keyframe y --vuelta con el disco. */
.estrobo {
transform-box: view-box;
transform-origin: 406px 468.5px;
}
.activo .estrobo {
animation: girar var(--vuelta) linear infinite;
animation-play-state: paused;
}
.sonando .estrobo {
animation-play-state: running;
}
@keyframes girar {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* ── Mandos ── */
.boton,
.palanca {
cursor: pointer;
}
.boton .cara {
transform-box: view-box;
transform: translateY(0);
transition: transform 0.11s cubic-bezier(0.3, 0.7, 0.4, 1);
}
.boton.hundido .cara,
.boton:active .cara {
transform: translateY(8px);
}
.boton:focus,
.palanca:focus {
outline: none;
}
.boton:focus-visible .cara rect,
.palanca:focus-visible .puno {
stroke: #c9a24b;
stroke-width: 3;
}
.palanca .puno {
transform-box: view-box;
transform: translateY(0);
transition: transform 0.35s cubic-bezier(0.35, 0.05, 0.2, 1);
}
.palanca.arriba .puno {
transform: translateY(-56px);
}
.piloto {
opacity: 0.18;
transition: opacity 0.2s;
}
.activo .piloto {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.activo .estrobo {
animation: none;
}
.boton .cara,
.palanca .puno {
transition: none;
}
}
</style>

@ -0,0 +1,519 @@
<script>
/**
* Pletina portátil de casete, 100 % SVG (viewBox 0 0 1000 780).
*
* La gracia está en la botonera: teclas de piano con enclavamiento mecánico,
* como las de verdad.
* · REPROD se queda hundida mientras suena o está en pausa
* · PAUSA se hunde ADEMÁS de REPROD, no en su lugar
* · REBOB y AVANCE se enclavan y levantan la de reproducción
* · STOP no se enclava: es momentánea y levanta todo lo demás
*
* El casete se pasa como snippet, así se reutiliza el componente Casete tal
* cual dentro de la ventana:
*
* <Pletina {estado} onplay={…} onpause={…} onstop={…}>
* {#snippet casete()}
* <Casete {estado} {titulo} {artista} ancho="100%" />
* {/snippet}
* </Pletina>
*
* @typedef {'parado' | 'sonando' | 'pausado'} EstadoRepro
*/
let {
/** @type {EstadoRepro} */
estado = 'parado',
marca = 'Levante',
modelo = 'CR-77',
/** @type {import('svelte').Snippet | undefined} */
casete = undefined,
ancho = 'clamp(320px, 62vw, 760px)',
/** Callbacks de la botonera. */
onplay = () => {},
onpause = () => {},
onstop = () => {},
onrebobinar = () => {},
onavanzar = () => {},
ongrabar = () => {}
} = $props();
const uid = $props.id();
let sonando = $derived(estado === 'sonando');
let activo = $derived(estado !== 'parado');
/** El bobinado es estado propio del aparato, no del reproductor de audio. */
let bobinando = $state(/** @type {'atras'|'adelante'|null} */ (null));
let hundidas = $derived({
rec: false,
rew: bobinando === 'atras',
play: activo && !bobinando,
ff: bobinando === 'adelante',
stop: false, // momentánea: se hunde con :active
pausa: estado === 'pausado'
});
/** @param {string} id */
function pulsar(id) {
if (id === 'stop') {
bobinando = null;
onstop();
} else if (id === 'play') {
bobinando = null;
onplay();
} else if (id === 'pausa') {
estado === 'pausado' ? onplay() : onpause();
} else if (id === 'rew' || id === 'ff') {
const destino = id === 'rew' ? 'atras' : 'adelante';
bobinando = bobinando === destino ? null : destino;
if (bobinando) onpause();
(id === 'rew' ? onrebobinar : onavanzar)();
} else if (id === 'rec') {
ongrabar();
}
}
/** @param {KeyboardEvent} e @param {string} id */
function teclado(e, id) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
pulsar(id);
}
}
// ── Contador de cinta ────────────────────────────────────────
let cuenta = $state(0);
$effect(() => {
if (estado !== 'sonando' && !bobinando) return;
const paso = bobinando ? 37 : 1;
const ms = bobinando ? 55 : 340;
const id = setInterval(() => {
const d = bobinando === 'atras' ? -paso : paso;
cuenta = (cuenta + d + 10000) % 10000;
}, ms);
return () => clearInterval(id); // $effect limpia solo al re-ejecutarse
});
$effect(() => {
if (estado === 'parado') cuenta = 0;
});
let digitos = $derived(String(cuenta).padStart(4, '0').split(''));
// ── Botonera ─────────────────────────────────────────────────
const SIMBOLOS = {
rec: 'M0,-13 A13,13 0 1,0 0,13 A13,13 0 1,0 0,-13 Z',
rew: 'M-2,-12 L-19,0 L-2,12 Z M16,-12 L-1,0 L16,12 Z',
play: 'M-9,-13 L14,0 L-9,13 Z',
ff: 'M-16,-12 L1,0 L-16,12 Z M2,-12 L19,0 L2,12 Z',
stop: 'M-11,-11 H11 V11 H-11 Z',
pausa: 'M-11,-12 H-3 V12 H-11 Z M3,-12 H11 V12 H3 Z'
};
const TECLAS = [
{ id: 'rec', x: 80, texto: 'GRAB', aria: 'Grabar' },
{ id: 'rew', x: 222, texto: 'REBOB', aria: 'Rebobinar' },
{ id: 'play', x: 364, texto: 'REPROD', aria: 'Reproducir' },
{ id: 'ff', x: 506, texto: 'AVANCE', aria: 'Avance rápido' },
{ id: 'stop', x: 648, texto: 'STOP', aria: 'Parar' },
{ id: 'pausa', x: 790, texto: 'PAUSA', aria: 'Pausa' }
];
// ── VU de aguja ──────────────────────────────────────────────
const VU = { cx: 216, cy: 574, r: 80 };
const marcas = [-26, -13, 0, 13, 26].map((g) => {
const a = (g * Math.PI) / 180;
const p = (/** @type {number} */ rr) => [
(VU.cx + rr * Math.sin(a)).toFixed(1),
(VU.cy - rr * Math.cos(a)).toFixed(1)
];
const [x1, y1] = p(VU.r - 9);
const [x2, y2] = p(VU.r);
return { d: `M${x1} ${y1} L${x2} ${y2}`, cero: g === 0 };
});
</script>
<svg
class="pletina"
class:activo
class:sonando
class:bobinando
viewBox="0 0 1000 780"
style="width:{ancho}"
role="group"
aria-label="Pletina de casete {marca} {modelo}"
>
<defs>
<linearGradient id="cuerpo-{uid}" x1="0" y1="0" x2="0.4" y2="1">
<stop offset="0" stop-color="#2c2318" />
<stop offset="1" stop-color="#140e08" />
</linearGradient>
<linearGradient id="aluminio-{uid}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#d7d0c0" />
<stop offset="0.18" stop-color="#b3ab9b" />
<stop offset="0.5" stop-color="#c9c1b0" />
<stop offset="0.82" stop-color="#a49c8c" />
<stop offset="1" stop-color="#c2baa9" />
</linearGradient>
<linearGradient id="tecla-{uid}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fbf7ec" />
<stop offset="0.62" stop-color="#eee5cf" />
<stop offset="1" stop-color="#cfc5ac" />
</linearGradient>
<linearGradient id="frente-{uid}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#bdb39a" />
<stop offset="1" stop-color="#8f8672" />
</linearGradient>
<radialGradient id="tornillo-{uid}" cx="0.36" cy="0.32" r="0.75">
<stop offset="0" stop-color="#8a8175" />
<stop offset="1" stop-color="#3a3227" />
</radialGradient>
<radialGradient id="piloto-{uid}" cx="0.4" cy="0.35" r="0.7">
<stop offset="0" stop-color="#ff8a72" />
<stop offset="0.5" stop-color="#c8352a" />
<stop offset="1" stop-color="#5e120d" />
</radialGradient>
<!-- Rejilla del altavoz: un patrón de agujeros, no 300 círculos sueltos -->
<pattern id="rejilla-{uid}" width="21" height="21" patternUnits="userSpaceOnUse">
<circle cx="10.5" cy="10.5" r="5.2" fill="#1b140d" fill-opacity="0.9" />
</pattern>
<clipPath id="vu-{uid}"><rect x="76" y="470" width="280" height="88" rx="6" /></clipPath>
<clipPath id="cristal-{uid}"><rect x="456" y="128" width="452" height="304" rx="6" /></clipPath>
<filter id="sombra-{uid}" x="-10%" y="-10%" width="120%" height="130%">
<feDropShadow dx="0" dy="16" stdDeviation="14" flood-color="#3a2e24" flood-opacity="0.45" />
</filter>
</defs>
<!-- Asa de transporte -->
<path
d="M336 96 Q336 30 500 30 Q664 30 664 96"
fill="none"
stroke="#1b140d"
stroke-width="22"
stroke-linecap="round"
/>
<!-- Cuerpo y frontal de aluminio -->
<g filter="url(#sombra-{uid})">
<rect x="20" y="64" width="960" height="692" rx="28" fill="url(#cuerpo-{uid})" />
<rect x="44" y="88" width="912" height="644" rx="16" fill="url(#aluminio-{uid})" />
<rect
x="44"
y="88"
width="912"
height="644"
rx="16"
fill="none"
stroke="#6f6757"
stroke-opacity="0.6"
stroke-width="2"
/>
</g>
{#each [[32, 140], [968, 140], [32, 680], [968, 680]] as [cx, cy] (cx + '-' + cy)}
<circle {cx} {cy} r="8" fill="url(#tornillo-{uid})" />
<path
d="M{cx - 5} {cy}h10"
stroke="#12100a"
stroke-width="2.5"
stroke-linecap="round"
/>
{/each}
<!-- Altavoz -->
<rect x="76" y="112" width="330" height="336" rx="10" fill="#8f8672" />
<rect x="82" y="118" width="318" height="324" rx="7" fill="url(#rejilla-{uid})" />
<rect
x="76"
y="112"
width="330"
height="336"
rx="10"
fill="none"
stroke="#4f4838"
stroke-width="2"
/>
<!-- Ventana del casete -->
<rect x="440" y="112" width="484" height="336" rx="12" fill="#241c15" />
<rect x="456" y="128" width="452" height="304" rx="6" fill="#0f0a06" />
<svg
class="hueco"
x="464"
y="143"
width="436"
height="274"
viewBox="0 0 1000 629"
preserveAspectRatio="xMidYMid meet"
>
{#if casete}
{@render casete()}
{:else}
<!-- Sin cinta: se ve el mecanismo -->
<rect width="1000" height="629" fill="#0f0a06" />
<circle cx="366" cy="311" r="52" fill="none" stroke="#3a3227" stroke-width="8" />
<circle cx="634" cy="311" r="52" fill="none" stroke="#3a3227" stroke-width="8" />
<rect x="470" y="380" width="60" height="120" rx="8" fill="#2a2016" />
<text
x="500"
y="180"
text-anchor="middle"
font-family="'Cormorant Garamond',serif"
font-size="46"
letter-spacing="10"
fill="#4f4838">SIN CINTA</text
>
{/if}
</svg>
<!-- Cristal ahumado y reflejo -->
<g clip-path="url(#cristal-{uid})">
<rect x="456" y="128" width="452" height="304" fill="#0f0a06" opacity="0.26" />
<path d="M456 432 L640 128 L760 128 L576 432 Z" fill="#ffffff" opacity="0.06" />
</g>
<rect
x="456"
y="128"
width="452"
height="304"
rx="6"
fill="none"
stroke="#000000"
stroke-opacity="0.5"
stroke-width="3"
/>
<!-- Vúmetro -->
<rect x="76" y="470" width="280" height="88" rx="6" fill="#f2e8d0" />
<g clip-path="url(#vu-{uid})">
<path
d="M180.9 502.1 A80 80 0 0 1 251.1 502.1"
fill="none"
stroke="#3a2e24"
stroke-width="1.5"
/>
<path
d="M227.1 494.8 A80 80 0 0 1 251.1 502.1"
fill="none"
stroke="#6e1f2a"
stroke-width="4"
/>
{#each marcas as m, i (i)}
<path d={m.d} stroke={m.cero ? '#6e1f2a' : '#3a2e24'} stroke-width={m.cero ? 2.5 : 1.5} />
{/each}
<g class="aguja">
<path d="M216 574 L216 492" stroke="#231b13" stroke-width="2.5" stroke-linecap="round" />
</g>
<circle cx={VU.cx} cy="558" r="9" fill="#8f8672" />
</g>
<text
x="216"
y="548"
text-anchor="middle"
font-family="'Cormorant Garamond',serif"
font-size="15"
letter-spacing="5"
fill="#7a6a52">VU</text
>
<rect x="76" y="470" width="280" height="88" rx="6" fill="none" stroke="#4f4838" stroke-width="2" />
<!-- Contador de cinta -->
<rect x="386" y="482" width="180" height="66" rx="6" fill="#241c15" />
{#each digitos as d, i (i)}
<rect x={399 + i * 40} y="494" width="34" height="42" rx="3" fill="#f2e8d0" />
<text
x={416 + i * 40}
y="528"
text-anchor="middle"
font-family="'Bodoni Moda',serif"
font-size="30"
fill="#231b13">{d}</text
>
{/each}
<!-- Puesta a cero del contador -->
<g
class="reset"
role="button"
tabindex="0"
aria-label="Poner el contador a cero"
onclick={() => (cuenta = 0)}
onkeydown={(e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
cuenta = 0;
}
}}
>
<circle cx="590" cy="515" r="14" fill="url(#tornillo-{uid})" />
<circle cx="590" cy="515" r="7" fill="#1b140d" />
</g>
<!-- Piloto -->
<circle cx="640" cy="515" r="11" fill="#3a3227" />
<circle class="piloto" cx="640" cy="515" r="8" fill="url(#piloto-{uid})" />
<text x="660" y="521" font-family="'Cormorant Garamond',serif" font-size="15" letter-spacing="4" fill="#5c5445">
REPROD
</text>
<!-- Marca -->
<text
x="930"
y="512"
text-anchor="end"
font-family="'Bodoni Moda',serif"
font-weight="700"
font-size="34"
fill="#3a2e24">{marca}</text
>
<text
x="930"
y="540"
text-anchor="end"
font-family="'Cormorant Garamond',serif"
font-size="17"
letter-spacing="6"
fill="#6f6757">MODELO {modelo}</text
>
<!-- ── Botonera ─────────────────────────────────────────────── -->
{#each TECLAS as t (t.id)}
<text
x={t.x + 65}
y="590"
text-anchor="middle"
font-family="'Cormorant Garamond',serif"
font-size="15"
letter-spacing="3"
fill="#5c5445">{t.texto}</text
>
{/each}
<rect x="60" y="596" width="880" height="150" rx="10" fill="#1b140d" />
{#each TECLAS as t (t.id)}
<g
class="tecla {t.id}"
class:hundida={hundidas[/** @type {keyof typeof hundidas} */ (t.id)]}
role="button"
tabindex="0"
aria-label={t.aria}
aria-pressed={hundidas[/** @type {keyof typeof hundidas} */ (t.id)]}
onclick={() => pulsar(t.id)}
onkeydown={(e) => teclado(e, t.id)}
>
<!-- hueco de la tecla -->
<rect x={t.x} y="604" width="130" height="128" rx="7" fill="#0d0906" />
<g class="cuerpo">
<rect x={t.x} y="604" width="130" height="118" rx="7" fill="url(#tecla-{uid})" />
<rect x={t.x} y="700" width="130" height="22" rx="6" fill="url(#frente-{uid})" />
<path
d="M{t.x + 7} 611 H{t.x + 123}"
stroke="#ffffff"
stroke-opacity="0.75"
stroke-width="2"
/>
<path
d={SIMBOLOS[/** @type {keyof typeof SIMBOLOS} */ (t.id)]}
transform="translate({t.x + 65} 652)"
fill={t.id === 'rec' ? '#6e1f2a' : '#3a2e24'}
/>
</g>
</g>
{/each}
</svg>
<style>
.pletina {
display: block;
height: auto;
}
/* La sombra propia del casete no pinta nada dentro del hueco de la pletina. */
.hueco :global(.casete) {
filter: none;
}
/* ── Teclas ── */
.tecla {
cursor: pointer;
}
.tecla .cuerpo {
transform-box: view-box;
transform: translateY(0);
transition: transform 0.11s cubic-bezier(0.3, 0.7, 0.4, 1);
}
.tecla.hundida .cuerpo,
.tecla:active .cuerpo {
transform: translateY(14px);
}
.tecla:focus {
outline: none;
}
.tecla:focus-visible .cuerpo rect:first-child {
stroke: #c9a24b;
stroke-width: 3;
}
.reset {
cursor: pointer;
}
.reset:focus-visible circle:first-child {
stroke: #c9a24b;
stroke-width: 3;
}
/* ── Aguja del vúmetro ── */
.aguja {
transform-box: view-box;
transform-origin: 216px 574px;
transform: rotate(-26deg);
transition: transform 0.7s ease-out;
}
.sonando .aguja {
animation: vu 2.3s ease-in-out infinite;
}
.bobinando .aguja {
transform: rotate(-22deg);
}
@keyframes vu {
0% { transform: rotate(-17deg); }
12% { transform: rotate(11deg); }
25% { transform: rotate(-5deg); }
38% { transform: rotate(17deg); }
52% { transform: rotate(-12deg); }
66% { transform: rotate(7deg); }
80% { transform: rotate(-15deg); }
92% { transform: rotate(13deg); }
100% { transform: rotate(-17deg); }
}
/* ── Piloto ── */
.piloto {
opacity: 0.18;
transition: opacity 0.2s;
}
.activo .piloto {
opacity: 0.55;
}
.sonando .piloto {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.sonando .aguja {
animation: none;
transform: rotate(4deg);
}
.tecla .cuerpo {
transition: none;
}
}
</style>

@ -0,0 +1,158 @@
<script>
/**
* Portada del single. Cuatro formatos en dos parejas:
*
* soporte aparato
* ───────────────────────────────
* Tocadiscos → Giradiscos
* Casete → Pletina
*
* Los soportes solo reflejan `estado`. Los aparatos además lo cambian, así
* que reciben callbacks, y llevan el soporte dentro como snippet.
*/
import Tocadiscos from './Tocadiscos.svelte';
import Giradiscos from './Giradiscos.svelte';
import Casete from './Casete.svelte';
import Pletina from './Pletina.svelte';
import { reproductor } from './reproductor.svelte.js';
let {
pista = 0,
titulo = 'Quédate',
artista = 'Juan Luis Alarcón',
anio = '1973',
sello = 'Discos Levante',
imagen = '',
tam = 'clamp(260px, 34vw, 400px)'
} = $props();
let estado = $derived(reproductor.estadoDe(pista));
let etiquetaPlay = $derived(estado === 'sonando' ? '❚❚\u00a0 Pausar' : '▶\u00a0 Escuchar');
// El reloj va con los datos: de él salen la posición del brazo y el reparto
// de cinta entre las dos bobinas.
let datos = $derived({
titulo,
artista,
anio,
sello,
imagen,
tiempo: reproductor.tiempo,
duracion: reproductor.duracion
});
const FORMATOS = [
{ id: 'vinilo', texto: 'Vinilo' },
{ id: 'giradiscos', texto: 'Giradiscos' },
{ id: 'casete', texto: 'Casete' },
{ id: 'pletina', texto: 'Pletina' }
];
/** Los aparatos traen su propia botonera: el botón suelto sobra. */
let esAparato = $derived(['giradiscos', 'pletina'].includes(reproductor.soporte));
const mandos = {
onplay: () => reproductor.play(pista),
onpause: () => reproductor.pause(),
onstop: () => reproductor.stop()
};
</script>
<div class="soporte" style="--tam:{tam}">
<div class="escena">
{#if reproductor.soporte === 'vinilo'}
<Tocadiscos {estado} {...datos} ancho="calc(var(--tam) * 1.3125)" />
{:else if reproductor.soporte === 'giradiscos'}
<Giradiscos {estado} {...mandos} ancho="calc(var(--tam) * 1.9)">
{#snippet disco({ vuelta })}
<Tocadiscos {estado} {vuelta} {...datos} ancho="100%" />
{/snippet}
</Giradiscos>
{:else if reproductor.soporte === 'casete'}
<Casete {estado} {...datos} ancho="calc(var(--tam) * 1.06)" />
{:else}
<Pletina {estado} {...mandos} ancho="calc(var(--tam) * 1.9)">
{#snippet casete()}
<Casete {estado} {...datos} ancho="100%" />
{/snippet}
</Pletina>
{/if}
</div>
<div class="conmutador" role="group" aria-label="Formato">
{#each FORMATOS as f (f.id)}
<button
class:activo={reproductor.soporte === f.id}
aria-pressed={reproductor.soporte === f.id}
onclick={() => (reproductor.soporte = f.id)}>{f.texto}</button
>
{/each}
</div>
{#if !esAparato}
<button class="play" onclick={() => reproductor.toggle(pista)}>
{etiquetaPlay} «{titulo}»
</button>
{/if}
</div>
<style>
.soporte {
font-family: 'Cormorant Garamond', Georgia, serif;
}
/* Altura fija para que cambiar de formato no dé un salto de layout. */
.escena {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(var(--tam) * 1.7);
}
.conmutador {
display: flex;
justify-content: center;
flex-wrap: wrap;
margin-top: 26px;
}
.conmutador button {
font: inherit;
cursor: pointer;
font-size: 14px;
letter-spacing: 0.18em;
text-transform: uppercase;
padding: 9px 18px;
border: 1px solid #a8853f;
background: transparent;
color: #6e1f2a;
}
.conmutador button + button {
border-left: none;
}
.conmutador button.activo {
background: #6e1f2a;
color: #f4ecdc;
}
.play {
display: block;
margin: 18px auto 0;
font: inherit;
cursor: pointer;
font-size: 16px;
letter-spacing: 0.16em;
text-transform: uppercase;
background: #6e1f2a;
color: #f4ecdc;
border: none;
padding: 13px 28px;
}
.play:hover {
background: #571722;
}
button:focus-visible {
outline: 2px solid #c9a24b;
outline-offset: 2px;
}
</style>

@ -0,0 +1,414 @@
<script>
/**
* Tocadiscos, 100 % SVG: disco con surcos, eje de pivote y brazo con cápsula.
*
* Sistema de coordenadas (viewBox 0 0 525 570):
* · disco → centro (240, 308), radio 200
* · eje → centro (450, 100) ← también es el pivote del brazo
* · el brazo gira de -9° (retirado) a 7.5° (apoyado) alrededor del eje
*
* OJO: `ancho` es el ancho TOTAL, brazo incluido. El disco ocupa 400/525 de
* ese ancho, así que para un disco de 400px hay que pasar unos 525px.
*
* @typedef {'parado' | 'sonando' | 'pausado'} EstadoRepro
*/
let {
/** @type {EstadoRepro} */
estado = 'parado',
sello = 'Discos Levante',
titulo = 'Quédate',
artista = 'Juan Luis Alarcón',
anio = '1973',
rpm = '45 R.P.M.',
/** URL o data-URL de la foto que va en la etiqueta. Vacío = solo texto. */
imagen = '',
/** Opacidad del velo granate que va sobre la foto para que se lea el texto. */
veloFoto = 0.55,
/** Segundo actual de reproducción. */
tiempo = 0,
/** Duración total en segundos. Con 0, el brazo se queda en el surco de entrada. */
duracion = 0,
ancho = 'clamp(340px, 45vw, 525px)',
vuelta = 2.2
} = $props();
const uid = $props.id();
let sonando = $derived(estado === 'sonando');
let activo = $derived(estado !== 'parado');
// ── Geometría ────────────────────────────────────────────────
const CX = 240;
const CY = 308;
const R = 200; // radio del disco
const RE = 76; // radio de la etiqueta
const EJE = { x: 450, y: 100, base: 28, oro: 18, punto: 4.4 };
const surcos = Array.from({ length: Math.floor((R - RE - 6) / 4) + 1 }, (_, i) => RE + 2 + i * 4);
// ── Seguimiento del surco ────────────────────────────────────
// El brazo no tiene dos posiciones: tiene un recorrido. La aguja entra por
// r=185 y sale por r=88, y como el paso de surco es constante, el radio baja
// linealmente con el tiempo. Invertir radio → ángulo tiene forma cerrada,
// porque r² = K + A·cos θ + B·sin θ con el pivote y el centro del disco fijos.
const PIVOTE = { x: 450, y: 100 };
const AGUJA = { x: 452.63, y: 354.16 }; // punta de la aguja, brazo sin girar
const R_ENTRADA = 185;
const R_SALIDA = 88;
const REPOSO = -9;
const _a = AGUJA.x - PIVOTE.x;
const _b = AGUJA.y - PIVOTE.y;
const _dx = PIVOTE.x - CX;
const _dy = PIVOTE.y - CY;
const _K = _dx * _dx + _dy * _dy + _a * _a + _b * _b;
const _A = 2 * (_dx * _a + _dy * _b);
const _B = 2 * (_dy * _a - _dx * _b);
const _R = Math.hypot(_A, _B);
const _delta = Math.atan2(_B, _A);
/** Grados de brazo para que la aguja caiga en el surco de radio `r`. */
function anguloPara(/** @type {number} */ r) {
const c = Math.max(-1, Math.min(1, (r * r - _K) / _R));
return ((_delta + Math.acos(c)) * 180) / Math.PI;
}
let progreso = $derived(duracion > 0 ? Math.min(1, Math.max(0, tiempo / duracion)) : 0);
let radioAguja = $derived(R_ENTRADA + (R_SALIDA - R_ENTRADA) * progreso);
let anguloBrazo = $derived(activo ? anguloPara(radioAguja) : REPOSO);
/**
* Bajar y subir el brazo es el gesto lento del elevador; seguir el surco son
* ajustes pequeños y continuos. Dos transiciones distintas, y este efecto
* marca cuál toca.
*/
let posando = $state(false);
$effect(() => {
[activo];
posando = true;
const id = setTimeout(() => (posando = false), 1700);
return () => clearTimeout(id);
});
// ── Ajuste del texto ─────────────────────────────────────────
// <text> no salta de línea ni se encoge solo: si el título es largo, se sale
// de la etiqueta. Medimos con getComputedTextLength() y bajamos el cuerpo.
/** @type {SVGTextElement|undefined} */ let refSello;
/** @type {SVGTextElement|undefined} */ let refTitulo;
/** @type {SVGTextElement|undefined} */ let refArtista;
/** @type {SVGTextElement|undefined} */ let refCreditos;
/**
* @param {SVGTextElement|undefined} el
* @param {number} max ancho disponible en unidades del viewBox
* @param {number} base cuerpo de partida
*/
function encajar(el, max, base) {
if (!el?.getComputedTextLength) return;
el.style.fontSize = `${base}px`;
const w = el.getComputedTextLength();
if (w > max) el.style.fontSize = `${Math.max(base * 0.45, (base * max) / w)}px`;
}
$effect(() => {
// dependencias explícitas: al cambiar cualquiera hay que volver a medir
[sello, titulo, artista, anio, rpm];
encajar(refSello, 128, 8);
encajar(refTitulo, 132, 22);
encajar(refArtista, 130, 8);
encajar(refCreditos, 130, 8);
});
// ── Gradiente cónico del eje ─────────────────────────────────
// SVG 1.1 no tiene gradiente cónico: lo sintetizamos en 36 cuñas
// interpolando las paradas del conic-gradient original.
const PARADAS = [
[0, '#6f5426'],
[0.24, '#d8b45c'],
[0.5, '#5e4620'],
[0.74, '#c9a24b'],
[1, '#5a4420']
];
/** @param {string} c */
function aRgb(c) {
const n = parseInt(c.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
/** @param {string} a @param {string} b @param {number} t */
function mezcla(a, b, t) {
const A = aRgb(a);
const B = aRgb(b);
return `rgb(${A.map((v, i) => Math.round(v + (B[i] - v) * t)).join(',')})`;
}
/** @param {number} t */
function colorConico(t) {
for (let i = 1; i < PARADAS.length; i++) {
if (t <= Number(PARADAS[i][0])) {
const [p0, c0] = PARADAS[i - 1];
const [p1, c1] = PARADAS[i];
return mezcla(String(c0), String(c1), (t - Number(p0)) / (Number(p1) - Number(p0)));
}
}
return String(PARADAS[PARADAS.length - 1][1]);
}
const N_CUNAS = 36;
const cunas = Array.from({ length: N_CUNAS }, (_, i) => {
// conic-gradient arranca arriba y avanza en sentido horario; el original
// empezaba en 30deg.
const punto = (/** @type {number} */ t) => {
const a = ((30 + t * 360) * Math.PI) / 180;
return [
(EJE.x + EJE.oro * Math.sin(a)).toFixed(2),
(EJE.y - EJE.oro * Math.cos(a)).toFixed(2)
];
};
const [x0, y0] = punto(i / N_CUNAS);
const [x1, y1] = punto((i + 1) / N_CUNAS);
return {
d: `M${EJE.x} ${EJE.y} L${x0} ${y0} A${EJE.oro} ${EJE.oro} 0 0 1 ${x1} ${y1} Z`,
color: colorConico((i + 0.5) / N_CUNAS)
};
});
</script>
<svg
class="tocadiscos"
class:activo
class:sonando
class:posando
viewBox="0 0 525 570"
style="width:{ancho}; --vuelta:{vuelta}s; --brazo:{anguloBrazo}deg"
role="img"
aria-label="Tocadiscos con el single «{titulo}» de {artista}"
>
<defs>
<clipPath id="etiqueta-{uid}">
<circle cx={CX} cy={CY} r={RE} />
</clipPath>
<radialGradient id="ejeBase-{uid}" cx="0.34" cy="0.28" r="0.98">
<stop offset="0.38" stop-color="#4a3a26" />
<stop offset="0.62" stop-color="#2a2016" />
<stop offset="1" stop-color="#100c08" />
</radialGradient>
<!-- Los ángulos CSS se traducen a vectores x1/y1→x2/y2:
90deg → (0,.5)-(1,.5) · 100deg → (.007,.413)-(.993,.587)
150deg → (.25,.067)-(.75,.933) · 160deg → (.329,.03)-(.671,.97) -->
<linearGradient id="contrapeso-{uid}" x1="0.007" y1="0.413" x2="0.993" y2="0.587">
<stop offset="0" stop-color="#0e0a06" />
<stop offset="0.42" stop-color="#3d3124" />
<stop offset="1" stop-color="#181108" />
</linearGradient>
<linearGradient id="pivote-{uid}" x1="0.007" y1="0.413" x2="0.993" y2="0.587">
<stop offset="0" stop-color="#5e4620" />
<stop offset="0.46" stop-color="#e0c078" />
<stop offset="1" stop-color="#6f5426" />
</linearGradient>
<linearGradient id="anillo-{uid}" x1="0" y1="0.5" x2="1" y2="0.5">
<stop offset="0" stop-color="#6f5426" />
<stop offset="0.5" stop-color="#c9a24b" />
<stop offset="1" stop-color="#5e4620" />
</linearGradient>
<linearGradient id="tubo-{uid}" x1="0" y1="0.5" x2="1" y2="0.5">
<stop offset="0" stop-color="#6b5225" />
<stop offset="0.22" stop-color="#a8853f" />
<stop offset="0.46" stop-color="#f0dda8" />
<stop offset="0.62" stop-color="#b9954a" />
<stop offset="1" stop-color="#5c451f" />
</linearGradient>
<linearGradient id="abrazadera-{uid}" x1="0" y1="0.5" x2="1" y2="0.5">
<stop offset="0" stop-color="#5c451f" />
<stop offset="0.5" stop-color="#d8b45c" />
<stop offset="1" stop-color="#5c451f" />
</linearGradient>
<linearGradient id="capsula-{uid}" x1="0.329" y1="0.03" x2="0.671" y2="0.97">
<stop offset="0" stop-color="#2b2118" />
<stop offset="0.55" stop-color="#0d0906" />
<stop offset="1" stop-color="#241a10" />
</linearGradient>
<linearGradient id="clip-{uid}" x1="0" y1="0.5" x2="1" y2="0.5">
<stop offset="0" stop-color="#8a6b2f" />
<stop offset="0.5" stop-color="#e0c078" />
<stop offset="1" stop-color="#6f5426" />
</linearGradient>
<linearGradient id="porta-{uid}" x1="0.25" y1="0.067" x2="0.75" y2="0.933">
<stop offset="0" stop-color="#3a2a18" />
<stop offset="1" stop-color="#120c07" />
</linearGradient>
<filter id="sombraDisco-{uid}" x="-25%" y="-25%" width="150%" height="150%">
<feDropShadow dx="0" dy="18" stdDeviation="14" flood-color="#3a2e24" flood-opacity="0.4" />
</filter>
<filter id="sombraBrazo-{uid}" x="-40%" y="-20%" width="180%" height="140%">
<feDropShadow dx="1" dy="3" stdDeviation="4" flood-color="#140e09" flood-opacity="0.5" />
</filter>
<filter id="sombraEje-{uid}" x="-60%" y="-60%" width="220%" height="220%">
<feDropShadow dx="0" dy="6" stdDeviation="6" flood-color="#1c120a" flood-opacity="0.55" />
</filter>
</defs>
<!-- ── Disco ────────────────────────────────────────────────── -->
<g class="disco" filter="url(#sombraDisco-{uid})">
<circle cx={CX} cy={CY} r={R} fill="#17110c" />
<g fill="none" stroke="#241c15" stroke-width="1">
{#each surcos as r (r)}
<circle cx={CX} cy={CY} {r} />
{/each}
</g>
<circle cx={CX} cy={CY} r={R - 0.5} fill="none" stroke="#e9dcbf" stroke-opacity="0.15" />
<!-- Etiqueta -->
<circle cx={CX} cy={CY} r={RE} fill="#6e1f2a" />
{#if imagen}
<image
href={imagen}
x={CX - RE}
y={CY - RE}
width={RE * 2}
height={RE * 2}
preserveAspectRatio="xMidYMid slice"
clip-path="url(#etiqueta-{uid})"
/>
<circle cx={CX} cy={CY} r={RE} fill="#4a0f18" opacity={veloFoto} />
{/if}
<circle cx={CX} cy={CY} r={RE - 0.5} fill="none" stroke="#f4ecdc" stroke-opacity="0.35" />
<g text-anchor="middle" fill="#e0c7ac" font-family="'Cormorant Garamond',serif">
<text bind:this={refSello} x={CX} y={CY - 26} font-size="8" letter-spacing="2"
>{sello.toUpperCase()}</text
>
<text
bind:this={refTitulo}
x={CX}
y={CY - 4}
font-family="'Bodoni Moda',serif"
font-style="italic"
font-size="22"
fill="#f4ecdc">{titulo}</text
>
<text bind:this={refArtista} x={CX} y={CY + 10} font-size="8" letter-spacing="1.6"
>{artista.toUpperCase()}</text
>
<text bind:this={refCreditos} x={CX} y={CY + 20} font-size="8" letter-spacing="1.6"
>{anio} · {rpm}</text
>
</g>
<circle cx={CX} cy={CY + 30} r="4.5" fill="#f4ecdc" />
</g>
<!-- ── Base del eje (por DEBAJO del brazo, como en el original) ── -->
<g filter="url(#sombraEje-{uid})">
<circle cx={EJE.x} cy={EJE.y} r={EJE.base} fill="url(#ejeBase-{uid})" />
<path
d="M425.75 86 A28 28 0 0 1 474.25 86"
fill="none"
stroke="#e9dcbf"
stroke-opacity="0.28"
stroke-width="1.5"
/>
</g>
<!-- ── Brazo ────────────────────────────────────────────────── -->
<g class="brazo" filter="url(#sombraBrazo-{uid})">
<rect x="419" y="32" width="54" height="34.3" rx="23.8" ry="15.1" fill="url(#contrapeso-{uid})" />
<rect x="439.8" y="63.2" width="12.5" height="21.8" fill="url(#anillo-{uid})" />
<!-- border-radius 36% 36% 30% 30% no cabe en un <rect>: va como path -->
<path
d="M441.9 80.4 H450 A10.5 10.1 0 0 1 460.5 90.5 V100.1 A8.7 8.4 0 0 1 451.8 108.5
H440.1 A8.7 8.4 0 0 1 431.4 100.1 V90.5 A10.5 10.1 0 0 1 441.9 80.4 Z"
fill="url(#pivote-{uid})"
/>
<rect x="440.8" y="106.9" width="10.4" height="174.7" fill="url(#tubo-{uid})" />
<rect x="437.2" y="275.4" width="17.7" height="15.6" rx="2" fill="url(#abrazadera-{uid})" />
<!-- cápsula, inclinada 16° sobre su anclaje -->
<g transform="rotate(-16 450.4 287.8)">
<rect x="424.2" y="287.8" width="43.7" height="37.4" rx="2.5" fill="url(#capsula-{uid})" />
<path
d="M424.2 290.3 A2.5 2.5 0 0 1 426.7 287.8 H465.4 A2.5 2.5 0 0 1 467.9 290.3"
fill="none"
stroke="#e9dcbf"
stroke-opacity="0.18"
/>
<rect x="447.8" y="275.1" width="22.7" height="14.2" rx="2" fill="url(#clip-{uid})" />
<rect x="427.7" y="321.5" width="14.9" height="23.2" rx="1.5" fill="url(#porta-{uid})" />
<rect x="432.5" y="343.2" width="3.5" height="9" fill="#e9dcbf" />
</g>
</g>
<!-- ── Casquillo dorado y tornillo, por ENCIMA del brazo ────── -->
<g class="eje-oro">
{#each cunas as cuna, i (i)}
<path d={cuna.d} fill={cuna.color} stroke={cuna.color} stroke-width="0.5" />
{/each}
</g>
<circle
cx={EJE.x}
cy={EJE.y}
r={EJE.oro}
fill="none"
stroke="#140e09"
stroke-opacity="0.55"
stroke-width="1"
/>
<circle cx={EJE.x} cy={EJE.y} r={EJE.punto} fill="#2a2016" />
</svg>
<style>
.tocadiscos {
display: block;
height: auto;
overflow: visible;
}
/* transform-box explícito: sin él el origen depende del valor inicial que
aplique el navegador y el brazo pivota desde otro sitio. */
.disco {
transform-box: view-box;
transform-origin: 240px 308px;
}
.brazo {
transform-box: view-box;
transform-origin: 450px 100px; /* el eje */
transform: rotate(var(--brazo, -9deg));
/* Seguir el surco: pasos pequeños y frecuentes. */
transition: transform 0.45s linear;
}
/* Bajar o subir el brazo: el gesto lento y pesado del elevador. */
.posando .brazo {
transition: transform 1.7s cubic-bezier(0.35, 0.05, 0.2, 1);
}
/* La animación solo se declara cuando hay pista: así 'parado' devuelve el
disco a 0° y 'pausado' lo congela donde estaba. */
.activo .disco {
animation: girar var(--vuelta) linear infinite;
animation-play-state: paused;
}
.sonando .disco {
animation-play-state: running;
}
@keyframes girar {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.activo .disco {
animation: none;
}
.brazo {
transition: none;
}
}
</style>

@ -0,0 +1,79 @@
/**
* Estado compartido del reproductor.
*
* Sustituye al `window.dispatchEvent('jla:play')` + localStorage del original:
* en modo runas un módulo `.svelte.js` puede contener `$state`, así que el
* estado global es una variable normal y cualquier componente que la lea
* se re-renderiza solo.
*
* @typedef {'parado' | 'sonando' | 'pausado'} EstadoRepro
*/
class Reproductor {
/** @type {EstadoRepro} */
estado = $state('parado');
/** Índice de la pista activa. -1 = ninguna. */
pista = $state(-1);
/** Soporte visible en la portada: 'vinilo' | 'casete' */
soporte = $state('vinilo');
/**
* Reloj de la pista. Lo alimenta quien tenga el <audio>:
* ontimeupdate={(e) => reproductor.tiempo = e.currentTarget.currentTime}
* onloadedmetadata={(e) => reproductor.duracion = e.currentTarget.duration}
* De aquí salen la posición del brazo y el reparto de cinta entre bobinas.
* `timeupdate` dispara unas 4 veces por segundo; si quieres más suavidad,
* actualiza `tiempo` desde un requestAnimationFrame.
*/
tiempo = $state(0);
duracion = $state(0);
/** @param {number} pista */
play(pista = 0) {
this.pista = pista;
this.estado = 'sonando';
}
pause() {
if (this.estado === 'sonando') this.estado = 'pausado';
}
/** Para del todo: el disco vuelve a 0° y el brazo se retira. */
stop() {
this.estado = 'parado';
this.pista = -1;
this.tiempo = 0;
}
/** @param {number} pista */
toggle(pista = 0) {
if (this.pista !== pista) return this.play(pista);
this.estado = this.estado === 'sonando' ? 'pausado' : 'sonando';
}
/**
* Estado que hay que pasarle a la animación de una pista concreta.
* Si suena otra pista, para esta el estado es 'parado'.
* @param {number} pista
* @returns {EstadoRepro}
*/
estadoDe(pista) {
return this.pista === pista ? this.estado : 'parado';
}
}
export const reproductor = new Reproductor();
/**
* Engancha un <audio> real al estado compartido. Úsalo en el componente que
* tenga el elemento de audio:
*
* <audio bind:this={audio} src={pista.src}
* onplay={() => reproductor.play(i)}
* onpause={() => reproductor.pause()}
* onended={() => reproductor.stop()} />
*
* y en un $effect: reproductor.estado === 'sonando' ? audio.play() : audio.pause();
*/

@ -0,0 +1,733 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Archivo Juan Luis Alarcón · banco de pruebas</title>
<link href="https://fonts.googleapis.com/css2?family=Bodoni+Moda:ital,opsz,wght@0,6..96,400;0,6..96,700;0,6..96,800;1,6..96,400;1,6..96,700&family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
<style>
:root {
--papel:#f4ecdc; --papel2:#efe4cc; --ficha:#faf4e6;
--tinta:#3a2e24; --tenue:#7a6a52; --linea:#d8c9a5;
--granate:#6e1f2a; --oro:#a8853f; --oro-claro:#c9a24b;
}
* { box-sizing:border-box; }
body { margin:0; background:var(--papel); color:var(--tinta); font-family:'Cormorant Garamond',Georgia,serif; font-size:18px; line-height:1.5; }
.cabecera { background:#16100b; color:#efe4cc; padding:22px clamp(20px,4vw,40px); border-bottom:1px solid var(--oro); }
.cabecera p { margin:0; font-size:11px; letter-spacing:.34em; text-transform:uppercase; color:var(--oro-claro); }
.cabecera h1 { margin:6px 0 0; font-family:'Bodoni Moda',serif; font-weight:700; font-size:clamp(24px,3.6vw,36px); line-height:1; }
.cabecera h1 em { color:#e6cf9a; }
.taller { display:grid; grid-template-columns:minmax(0,1fr) 330px; gap:clamp(20px,3vw,36px); padding:clamp(20px,4vw,36px); align-items:start; }
@media (max-width:900px) { .taller { grid-template-columns:1fr; } }
.escena { background:var(--papel2); border:1px solid var(--linea); display:flex; align-items:center; justify-content:center; padding:clamp(18px,3vw,44px); min-height:58vh; overflow:hidden; }
.escena > div { display:none; width:100%; justify-content:center; }
.escena > div.visible { display:flex; }
.panel { background:var(--ficha); border:1px solid var(--linea); border-top:3px solid var(--granate); padding:20px; }
.panel h2 { font-family:'Bodoni Moda',serif; font-size:20px; font-weight:600; margin:0 0 4px; }
.panel h2 + p { margin:0 0 16px; font-size:15px; color:var(--tenue); }
fieldset { border:none; border-top:1px solid var(--linea); margin:0 0 16px; padding:14px 0 0; }
legend { font-size:11px; letter-spacing:.28em; text-transform:uppercase; color:var(--oro); padding:0 8px 0 0; }
label { display:block; font-size:12px; letter-spacing:.16em; text-transform:uppercase; color:var(--tenue); margin:10px 0 4px; }
input[type=text] { width:100%; font:inherit; font-size:16px; padding:8px 10px; background:#fff; border:1px solid var(--linea); color:var(--tinta); }
input[type=text]:focus-visible, button:focus-visible { outline:2px solid var(--oro-claro); outline-offset:2px; }
.grupo { display:flex; flex-wrap:wrap; }
.grupo button { flex:1 0 46%; font:inherit; cursor:pointer; font-size:12px; letter-spacing:.12em; text-transform:uppercase; padding:9px 4px; border:1px solid var(--oro); background:transparent; color:var(--granate); margin:-1px 0 0 -1px; }
.grupo button[aria-pressed=true] { background:var(--granate); color:var(--papel); border-color:var(--granate); position:relative; }
.accion { width:100%; font:inherit; cursor:pointer; font-size:14px; letter-spacing:.16em; text-transform:uppercase; padding:10px; margin-top:8px; border:none; background:var(--granate); color:var(--papel); }
.accion.suave { background:transparent; color:var(--granate); border:1px solid var(--oro); }
.accion:hover { background:#571722; color:var(--papel); }
.estado { margin:12px 0 0; font-size:14px; color:var(--tenue); }
.estado code { font-family:ui-monospace,Menlo,monospace; font-size:13px; background:#fff; border:1px solid var(--linea); padding:1px 6px; }
.pista { margin:16px 0 0; font-size:14px; color:var(--tenue); border-top:1px solid var(--linea); padding-top:12px; }
/* ── Animaciones: idénticas a las de los componentes Svelte ── */
.tocadiscos { display:block; height:auto; overflow:visible; width:100%; }
#caja-vinilo .tocadiscos { max-width:525px; }
.tocadiscos .disco { transform-box:view-box; transform-origin:240px 308px; }
.tocadiscos .brazo { transform-box:view-box; transform-origin:450px 100px; transform:rotate(var(--brazo,-9deg)); transition:transform .45s linear; }
.tocadiscos.posando .brazo { transition:transform 1.7s cubic-bezier(.35,.05,.2,1); }
.tocadiscos.activo .disco { animation:girar var(--vuelta,2.2s) linear infinite; animation-play-state:paused; }
.tocadiscos.sonando .disco { animation-play-state:running; }
.casete { display:block; height:auto; width:100%; filter:drop-shadow(0 20px 34px rgba(58,46,36,.45)); }
#caja-casete .casete { max-width:420px; }
.hueco .casete { filter:none; }
.casete .bobina { transform-box:fill-box; transform-origin:center; }
.casete .bobina { transform:rotate(var(--giro-izq)); transition:transform .35s linear; }
.casete .der { transform:rotate(var(--giro-der)); }
.casete:not(.activo) .bobina { transition:none; }
.casete.rapido .bobina { transition-duration:.1s; }
.casete .cinta { opacity:1; }
.casete.sonando .cinta { animation:latido 1.6s ease-in-out infinite; }
.pletina, .giradiscos { display:block; height:auto; width:min(100%,760px); }
.tecla, .boton, .palanca, .reset { cursor:pointer; }
.tecla .cuerpo { transform-box:view-box; transform:translateY(0); transition:transform .11s cubic-bezier(.3,.7,.4,1); }
.tecla.hundida .cuerpo, .tecla:active .cuerpo { transform:translateY(14px); }
.tecla:focus, .boton:focus, .palanca:focus { outline:none; }
.tecla:focus-visible .cuerpo rect:first-child, .boton:focus-visible .cara rect, .palanca:focus-visible .puno { stroke:var(--oro-claro); stroke-width:3; }
.aguja { transform-box:view-box; transform-origin:216px 574px; transform:rotate(-26deg); transition:transform .7s ease-out; }
.pletina.sonando .aguja { animation:vu 2.3s ease-in-out infinite; }
.pletina.bobinando .aguja { transform:rotate(-22deg); }
.piloto { opacity:.18; transition:opacity .2s; }
.pletina.activo .piloto { opacity:.55; }
.pletina.sonando .piloto, .giradiscos.activo .piloto { opacity:1; }
.estrobo { transform-box:view-box; transform-origin:406px 468.5px; }
.giradiscos.activo .estrobo { animation:girar var(--vuelta,1.333s) linear infinite; animation-play-state:paused; }
.giradiscos.sonando .estrobo { animation-play-state:running; }
.boton .cara { transform-box:view-box; transform:translateY(0); transition:transform .11s cubic-bezier(.3,.7,.4,1); }
.boton.hundido .cara, .boton:active .cara { transform:translateY(8px); }
.palanca .puno { transform-box:view-box; transform:translateY(0); transition:transform .35s cubic-bezier(.35,.05,.2,1); }
.palanca.arriba .puno { transform:translateY(-56px); }
@keyframes girar { from { transform:rotate(0deg); } to { transform:rotate(360deg); } }
@keyframes latido { 0%,100% { opacity:.82; } 50% { opacity:1; } }
@keyframes vu {
0% { transform:rotate(-17deg); } 12% { transform:rotate(11deg); }
25% { transform:rotate(-5deg); } 38% { transform:rotate(17deg); }
52% { transform:rotate(-12deg); } 66% { transform:rotate(7deg); }
80% { transform:rotate(-15deg); } 92% { transform:rotate(13deg); }
100% { transform:rotate(-17deg); }
}
@media (prefers-reduced-motion:reduce) {
.tocadiscos.activo .disco, .casete.sonando .cinta,
.pletina.sonando .aguja, .giradiscos.activo .estrobo { animation:none; }
.tocadiscos .brazo, .tecla .cuerpo, .boton .cara, .palanca .puno { transition:none; }
}
</style>
</head>
<body>
<header class="cabecera">
<p>Archivo Juan Luis Alarcón · componentes</p>
<h1>Soportes <em>y</em> aparatos</h1>
</header>
<div class="taller">
<div class="escena">
<div id="caja-vinilo"></div>
<div id="caja-giradiscos" class="visible"></div>
<div id="caja-casete"></div>
<div id="caja-pletina"></div>
</div>
<aside class="panel">
<h2>Propiedades</h2>
<p>Los soportes reflejan <code>estado</code>. Los aparatos, además, lo cambian con sus mandos.</p>
<fieldset>
<legend>Formato</legend>
<div class="grupo">
<button id="btn-vinilo" aria-pressed="false">Vinilo</button>
<button id="btn-giradiscos" aria-pressed="true">Giradiscos</button>
<button id="btn-casete" aria-pressed="false">Casete</button>
<button id="btn-pletina" aria-pressed="false">Pletina</button>
</div>
</fieldset>
<fieldset>
<legend>Transporte</legend>
<button class="accion" id="btn-play">▶&nbsp; Reproducir</button>
<button class="accion suave" id="btn-stop">■&nbsp; Parar</button>
<p class="estado">estado = <code id="eco-estado">'parado'</code></p>
<label for="in-tiempo">Posición <span id="eco-tiempo">0:00 / 2:45</span></label>
<input type="range" id="in-tiempo" min="0" max="165" step="0.25" value="0" style="width:100%">
</fieldset>
<fieldset>
<legend>Single</legend>
<label for="in-titulo">Título</label>
<input type="text" id="in-titulo" value="Quédate">
<label for="in-artista">Artista</label>
<input type="text" id="in-artista" value="Juan Luis Alarcón">
<label for="in-anio">Año</label>
<input type="text" id="in-anio" value="1973">
<label for="in-sello">Sello</label>
<input type="text" id="in-sello" value="Discos Levante">
</fieldset>
<fieldset>
<legend>Imagen</legend>
<label for="in-imagen">URL o data-URL</label>
<input type="text" id="in-imagen" value="" placeholder="vacío = solo texto">
<button class="accion suave" id="btn-foto">Usar foto de ejemplo</button>
<button class="accion suave" id="btn-sinfoto">Quitar la foto</button>
</fieldset>
<p class="pista">En el giradiscos, 33 y 45 cambian de verdad la velocidad: las marcas del borde del plato giran con el disco porque van impresas en él. El elevador levanta la aguja sin parar el plato, que es la pausa de un tocadiscos.</p>
</aside>
</div>
<script>
(() => {
'use strict';
const $ = (id) => document.getElementById(id);
// ═════════ TOCADISCOS (viewBox 0 0 525 570) ═════════
const CX = 240, CY = 308, R = 200, RE = 76;
const EJE = { x:450, y:100, base:28, oro:18, punto:4.4 };
const surcos = Array.from({length: Math.floor((R - RE - 6) / 4) + 1}, (_, i) => RE + 2 + i * 4);
// SVG 1.1 no tiene gradiente cónico: se sintetiza en cuñas.
const PARADAS = [[0,'#6f5426'],[0.24,'#d8b45c'],[0.5,'#5e4620'],[0.74,'#c9a24b'],[1,'#5a4420']];
const aRgb = (c) => { const n = parseInt(c.slice(1), 16); return [n >> 16 & 255, n >> 8 & 255, n & 255]; };
const mezcla = (a, b, t) => 'rgb(' + aRgb(a).map((v, i) => Math.round(v + (aRgb(b)[i] - v) * t)).join(',') + ')';
const colorConico = (t) => {
for (let i = 1; i < PARADAS.length; i++) {
if (t <= PARADAS[i][0]) { const [p0,c0] = PARADAS[i-1], [p1,c1] = PARADAS[i]; return mezcla(c0, c1, (t - p0) / (p1 - p0)); }
}
return PARADAS[PARADAS.length - 1][1];
};
const N = 36;
const cunas = Array.from({length: N}, (_, i) => {
const punto = (t) => { const a = (30 + t * 360) * Math.PI / 180;
return [(EJE.x + EJE.oro * Math.sin(a)).toFixed(2), (EJE.y - EJE.oro * Math.cos(a)).toFixed(2)]; };
const [x0,y0] = punto(i / N), [x1,y1] = punto((i + 1) / N);
return { d:`M${EJE.x} ${EJE.y} L${x0} ${y0} A${EJE.oro} ${EJE.oro} 0 0 1 ${x1} ${y1} Z`, color:colorConico((i + 0.5) / N) };
});
// `p` es el prefijo de ids: hay dos instancias de cada soporte (suelta y
// dentro del aparato) y los <defs> comparten espacio de nombres global.
const svgTocadiscos = (p) => `
<svg class="tocadiscos" id="svg-${p}" viewBox="0 0 525 570" role="img" aria-label="Disco y brazo">
<defs>
<clipPath id="${p}-eti"><circle cx="${CX}" cy="${CY}" r="${RE}"/></clipPath>
<radialGradient id="${p}-ejeBase" cx="0.34" cy="0.28" r="0.98"><stop offset="0.38" stop-color="#4a3a26"/><stop offset="0.62" stop-color="#2a2016"/><stop offset="1" stop-color="#100c08"/></radialGradient>
<linearGradient id="${p}-contra" x1="0.007" y1="0.413" x2="0.993" y2="0.587"><stop offset="0" stop-color="#0e0a06"/><stop offset="0.42" stop-color="#3d3124"/><stop offset="1" stop-color="#181108"/></linearGradient>
<linearGradient id="${p}-piv" x1="0.007" y1="0.413" x2="0.993" y2="0.587"><stop offset="0" stop-color="#5e4620"/><stop offset="0.46" stop-color="#e0c078"/><stop offset="1" stop-color="#6f5426"/></linearGradient>
<linearGradient id="${p}-ani" x1="0" y1="0.5" x2="1" y2="0.5"><stop offset="0" stop-color="#6f5426"/><stop offset="0.5" stop-color="#c9a24b"/><stop offset="1" stop-color="#5e4620"/></linearGradient>
<linearGradient id="${p}-tubo" x1="0" y1="0.5" x2="1" y2="0.5"><stop offset="0" stop-color="#6b5225"/><stop offset="0.22" stop-color="#a8853f"/><stop offset="0.46" stop-color="#f0dda8"/><stop offset="0.62" stop-color="#b9954a"/><stop offset="1" stop-color="#5c451f"/></linearGradient>
<linearGradient id="${p}-abra" x1="0" y1="0.5" x2="1" y2="0.5"><stop offset="0" stop-color="#5c451f"/><stop offset="0.5" stop-color="#d8b45c"/><stop offset="1" stop-color="#5c451f"/></linearGradient>
<linearGradient id="${p}-cap" x1="0.329" y1="0.03" x2="0.671" y2="0.97"><stop offset="0" stop-color="#2b2118"/><stop offset="0.55" stop-color="#0d0906"/><stop offset="1" stop-color="#241a10"/></linearGradient>
<linearGradient id="${p}-clip" x1="0" y1="0.5" x2="1" y2="0.5"><stop offset="0" stop-color="#8a6b2f"/><stop offset="0.5" stop-color="#e0c078"/><stop offset="1" stop-color="#6f5426"/></linearGradient>
<linearGradient id="${p}-porta" x1="0.25" y1="0.067" x2="0.75" y2="0.933"><stop offset="0" stop-color="#3a2a18"/><stop offset="1" stop-color="#120c07"/></linearGradient>
<filter id="${p}-fD" x="-25%" y="-25%" width="150%" height="150%"><feDropShadow dx="0" dy="18" stdDeviation="14" flood-color="#3a2e24" flood-opacity="0.4"/></filter>
<filter id="${p}-fB" x="-40%" y="-20%" width="180%" height="140%"><feDropShadow dx="1" dy="3" stdDeviation="4" flood-color="#140e09" flood-opacity="0.5"/></filter>
<filter id="${p}-fE" x="-60%" y="-60%" width="220%" height="220%"><feDropShadow dx="0" dy="6" stdDeviation="6" flood-color="#1c120a" flood-opacity="0.55"/></filter>
</defs>
<g class="disco" filter="url(#${p}-fD)">
<circle cx="${CX}" cy="${CY}" r="${R}" fill="#17110c"/>
<g fill="none" stroke="#241c15" stroke-width="1">${surcos.map((r) => `<circle cx="${CX}" cy="${CY}" r="${r}"/>`).join('')}</g>
<circle cx="${CX}" cy="${CY}" r="${R-0.5}" fill="none" stroke="#e9dcbf" stroke-opacity="0.15"/>
<circle cx="${CX}" cy="${CY}" r="${RE}" fill="#6e1f2a"/>
<image id="${p}-foto" x="${CX-RE}" y="${CY-RE}" width="${RE*2}" height="${RE*2}" preserveAspectRatio="xMidYMid slice" clip-path="url(#${p}-eti)" style="display:none"/>
<circle id="${p}-velo" cx="${CX}" cy="${CY}" r="${RE}" fill="#4a0f18" opacity="0.55" style="display:none"/>
<circle cx="${CX}" cy="${CY}" r="${RE-0.5}" fill="none" stroke="#f4ecdc" stroke-opacity="0.35"/>
<g text-anchor="middle" fill="#e0c7ac" font-family="'Cormorant Garamond',serif">
<text id="${p}-sello" x="${CX}" y="${CY-26}" font-size="8" letter-spacing="2"></text>
<text id="${p}-titulo" x="${CX}" y="${CY-4}" font-family="'Bodoni Moda',serif" font-style="italic" font-size="22" fill="#f4ecdc"></text>
<text id="${p}-artista" x="${CX}" y="${CY+10}" font-size="8" letter-spacing="1.6"></text>
<text id="${p}-creditos" x="${CX}" y="${CY+20}" font-size="8" letter-spacing="1.6"></text>
</g>
<circle cx="${CX}" cy="${CY+30}" r="4.5" fill="#f4ecdc"/>
</g>
<g filter="url(#${p}-fE)">
<circle cx="${EJE.x}" cy="${EJE.y}" r="${EJE.base}" fill="url(#${p}-ejeBase)"/>
<path d="M425.75 86 A28 28 0 0 1 474.25 86" fill="none" stroke="#e9dcbf" stroke-opacity="0.28" stroke-width="1.5"/>
</g>
<g class="brazo" filter="url(#${p}-fB)">
<rect x="419" y="32" width="54" height="34.3" rx="23.8" ry="15.1" fill="url(#${p}-contra)"/>
<rect x="439.8" y="63.2" width="12.5" height="21.8" fill="url(#${p}-ani)"/>
<path d="M441.9 80.4 H450 A10.5 10.1 0 0 1 460.5 90.5 V100.1 A8.7 8.4 0 0 1 451.8 108.5 H440.1 A8.7 8.4 0 0 1 431.4 100.1 V90.5 A10.5 10.1 0 0 1 441.9 80.4 Z" fill="url(#${p}-piv)"/>
<rect x="440.8" y="106.9" width="10.4" height="174.7" fill="url(#${p}-tubo)"/>
<rect x="437.2" y="275.4" width="17.7" height="15.6" rx="2" fill="url(#${p}-abra)"/>
<g transform="rotate(-16 450.4 287.8)">
<rect x="424.2" y="287.8" width="43.7" height="37.4" rx="2.5" fill="url(#${p}-cap)"/>
<path d="M424.2 290.3 A2.5 2.5 0 0 1 426.7 287.8 H465.4 A2.5 2.5 0 0 1 467.9 290.3" fill="none" stroke="#e9dcbf" stroke-opacity="0.18"/>
<rect x="447.8" y="275.1" width="22.7" height="14.2" rx="2" fill="url(#${p}-clip)"/>
<rect x="427.7" y="321.5" width="14.9" height="23.2" rx="1.5" fill="url(#${p}-porta)"/>
<rect x="432.5" y="343.2" width="3.5" height="9" fill="#e9dcbf"/>
</g>
</g>
<g>${cunas.map((c) => `<path d="${c.d}" fill="${c.color}" stroke="${c.color}" stroke-width="0.5"/>`).join('')}</g>
<circle cx="${EJE.x}" cy="${EJE.y}" r="${EJE.oro}" fill="none" stroke="#140e09" stroke-opacity="0.55" stroke-width="1"/>
<circle cx="${EJE.x}" cy="${EJE.y}" r="${EJE.punto}" fill="#2a2016"/>
</svg>`;
// ═════════ GIRADISCOS (viewBox 0 0 1000 880) ═════════
const PLATO = { cx:406, cy:468.5, r:250 };
// Cuatro grupos, no un anillo continuo: con marcas idénticas cada 6°, a 60 fps
// y 45 r.p.m. el plato avanza 4,5° por fotograma y la lectura de menor
// recorrido es -1,5°, o sea hacia atrás. Agrupadas, el patrón se repite cada
// 90° y el sentido queda determinado.
const estrobo = Array.from({length:4}, (_, g) =>
Array.from({length:9}, (_, i) => {
const a = g * 90 + i * 4, ind = i === 0;
return `<rect x="${PLATO.cx - (ind?3.5:2)}" y="${PLATO.cy - (ind?243:240)}" width="${ind?7:4}" height="${ind?19:13}" fill="#2a2016" opacity="${ind?0.75:0.5}" transform="rotate(${a} ${PLATO.cx} ${PLATO.cy})"/>`;
}).join('')).join('');
const vetas = [190,268,352,470,588,664,742].map((y) => `<path d="M50 ${y} Q500 ${y-7} 950 ${y}" fill="none"/>`).join('');
const svgGiradiscos = () => `
<svg class="giradiscos" id="svg-giradiscos" viewBox="0 0 1000 880" role="group" aria-label="Giradiscos Levante TD-45">
<defs>
<linearGradient id="gd-madera" x1="0" y1="0" x2="0.25" y2="1"><stop offset="0" stop-color="#7a5430"/><stop offset="0.45" stop-color="#5a3d22"/><stop offset="1" stop-color="#3d2916"/></linearGradient>
<linearGradient id="gd-chapa" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#d7d0c0"/><stop offset="0.2" stop-color="#b3ab9b"/><stop offset="0.55" stop-color="#c9c1b0"/><stop offset="0.85" stop-color="#a49c8c"/><stop offset="1" stop-color="#c2baa9"/></linearGradient>
<radialGradient id="gd-plato" cx="0.4" cy="0.34" r="0.72"><stop offset="0" stop-color="#cfc8ba"/><stop offset="0.7" stop-color="#a49c8c"/><stop offset="1" stop-color="#7e7666"/></radialGradient>
<linearGradient id="gd-tapa" x1="0" y1="0" x2="0.2" y2="1"><stop offset="0" stop-color="#4a4032" stop-opacity="0.5"/><stop offset="1" stop-color="#140e09" stop-opacity="0.72"/></linearGradient>
<linearGradient id="gd-boton" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fbf7ec"/><stop offset="0.66" stop-color="#eee5cf"/><stop offset="1" stop-color="#cfc5ac"/></linearGradient>
<linearGradient id="gd-cromo" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#efeae0"/><stop offset="0.5" stop-color="#a49c8c"/><stop offset="1" stop-color="#d7d0c0"/></linearGradient>
<radialGradient id="gd-piloto" cx="0.4" cy="0.35" r="0.7"><stop offset="0" stop-color="#ffd08a"/><stop offset="0.5" stop-color="#c8852a"/><stop offset="1" stop-color="#5e3a0d"/></radialGradient>
<filter id="gd-sombra" x="-10%" y="-10%" width="120%" height="130%"><feDropShadow dx="0" dy="18" stdDeviation="16" flood-color="#3a2e24" flood-opacity="0.45"/></filter>
</defs>
<path d="M186 124 L814 124 L786 40 Q784 32 774 32 L226 32 Q216 32 214 40 Z" fill="url(#gd-tapa)"/>
<path d="M186 124 L814 124" stroke="#0f0a06" stroke-opacity="0.5" stroke-width="3"/>
<path d="M300 124 L360 32 L430 32 L370 124 Z" fill="#ffffff" opacity="0.07"/>
<rect x="232" y="112" width="66" height="20" rx="4" fill="#2a2016"/>
<rect x="702" y="112" width="66" height="20" rx="4" fill="#2a2016"/>
<g filter="url(#gd-sombra)">
<rect x="50" y="115" width="900" height="720" rx="16" fill="url(#gd-madera)"/>
<g stroke="#2e1f11" stroke-opacity="0.25" stroke-width="2">${vetas}</g>
<rect x="74" y="139" width="852" height="648" rx="10" fill="url(#gd-chapa)"/>
<rect x="74" y="139" width="852" height="648" rx="10" fill="none" stroke="#6f6757" stroke-opacity="0.6" stroke-width="2"/>
</g>
<circle cx="${PLATO.cx}" cy="${PLATO.cy}" r="${PLATO.r}" fill="url(#gd-plato)"/>
<circle cx="${PLATO.cx}" cy="${PLATO.cy}" r="${PLATO.r}" fill="none" stroke="#6f6757" stroke-opacity="0.7" stroke-width="2"/>
<g class="estrobo">${estrobo}</g>
<circle cx="${PLATO.cx}" cy="${PLATO.cy}" r="220" fill="#2a2620"/>
<rect x="642" y="500" width="26" height="52" rx="8" fill="url(#gd-cromo)"/>
<path d="M640 508 Q655 496 670 508" fill="none" stroke="#8d8577" stroke-width="6" stroke-linecap="round"/>
<svg class="hueco" x="150" y="140" width="560" height="608" viewBox="0 0 525 570" preserveAspectRatio="xMidYMid meet">${svgTocadiscos('g')}</svg>
<circle cx="${PLATO.cx}" cy="${PLATO.cy}" r="9" fill="url(#gd-cromo)"/>
<circle cx="${PLATO.cx}" cy="${PLATO.cy}" r="9" fill="none" stroke="#6f6757" stroke-width="1"/>
<text x="831" y="178" text-anchor="middle" font-family="'Cormorant Garamond',serif" font-size="15" letter-spacing="4" fill="#5c5445">VELOCIDAD</text>
${[{v:33,x:736},{v:45,x:836}].map((b) => `
<g class="boton" id="vel-${b.v}" role="button" tabindex="0" aria-label="${b.v} revoluciones por minuto" aria-pressed="false">
<rect x="${b.x}" y="190" width="90" height="72" rx="6" fill="#0d0906"/>
<g class="cara">
<rect x="${b.x}" y="190" width="90" height="64" rx="6" fill="url(#gd-boton)"/>
<text x="${b.x+45}" y="234" text-anchor="middle" font-family="'Bodoni Moda',serif" font-weight="700" font-size="30" fill="#3a2e24">${b.v}</text>
</g>
</g>`).join('')}
${[{id:'marcha',y:300,t:'MARCHA',c:'#3a2e24'},{id:'paro',y:390,t:'PARO',c:'#6e1f2a'}].map((b) => `
<g class="boton" id="gd-${b.id}" role="button" tabindex="0" aria-label="${b.t}">
<rect x="736" y="${b.y}" width="190" height="78" rx="8" fill="#0d0906"/>
<g class="cara">
<rect x="736" y="${b.y}" width="190" height="70" rx="8" fill="url(#gd-boton)"/>
<text x="831" y="${b.y+44}" text-anchor="middle" font-family="'Cormorant Garamond',serif" font-size="22" letter-spacing="5" fill="${b.c}">${b.t}</text>
</g>
</g>`).join('')}
<text x="831" y="512" text-anchor="middle" font-family="'Cormorant Garamond',serif" font-size="15" letter-spacing="4" fill="#5c5445">ELEVADOR</text>
<rect x="800" y="524" width="62" height="128" rx="10" fill="#241c15"/>
<g class="palanca" id="gd-elevador" role="button" tabindex="0" aria-label="Elevador del brazo" aria-pressed="false">
<rect class="puno" x="792" y="582" width="78" height="52" rx="10" fill="url(#gd-cromo)"/>
</g>
<circle cx="760" cy="700" r="11" fill="#3a3227"/>
<circle class="piloto" cx="760" cy="700" r="8" fill="url(#gd-piloto)"/>
<text x="782" y="706" font-family="'Cormorant Garamond',serif" font-size="15" letter-spacing="4" fill="#5c5445">RED</text>
<rect x="96" y="728" width="240" height="50" rx="4" fill="#241c15"/>
<text x="216" y="762" text-anchor="middle" font-family="'Bodoni Moda',serif" font-weight="700" font-size="28" fill="#e0c7ac">Levante</text>
<text x="916" y="762" text-anchor="end" font-family="'Cormorant Garamond',serif" font-size="16" letter-spacing="5" fill="#6f6757">MODELO TD-45</text>
</svg>`;
// ═════════ CASETE (viewBox 0 0 1000 629) ═════════
const svgCasete = (p) => `
<svg class="casete" id="svg-${p}" viewBox="0 0 1000 629" role="img" aria-label="Casete">
<defs>
<radialGradient id="${p}-tor" cx="0.36" cy="0.32" r="0.75"><stop offset="0" stop-color="#8a8175"/><stop offset="1" stop-color="#3a3227"/></radialGradient>
<linearGradient id="${p}-car" x1="0" y1="0" x2="0.35" y2="1"><stop offset="0" stop-color="#2c2318"/><stop offset="1" stop-color="#171009"/></linearGradient>
<linearGradient id="${p}-bri" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#ffffff" stop-opacity="0.13"/><stop offset="0.45" stop-color="#ffffff" stop-opacity="0"/></linearGradient>
<radialGradient id="${p}-pack" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stop-color="#57381a"/><stop offset="1" stop-color="#33200d"/></radialGradient>
<clipPath id="${p}-ven"><rect x="278" y="240" width="444" height="142" rx="22"/></clipPath>
<clipPath id="${p}-fot"><rect x="700" y="92" width="150" height="150" rx="6"/></clipPath>
</defs>
<rect x="4" y="4" width="992" height="621" rx="34" fill="url(#${p}-car)"/>
<rect x="4" y="4" width="992" height="621" rx="34" fill="url(#${p}-bri)"/>
<rect x="16" y="16" width="968" height="597" rx="26" fill="none" stroke="#3f3527" stroke-width="2"/>
<circle cx="60" cy="60" r="17" fill="url(#${p}-tor)"/><path d="M60 49v22M49 60h22" stroke="#1b140d" stroke-width="4" stroke-linecap="round"/>
<circle cx="940" cy="60" r="17" fill="url(#${p}-tor)"/><path d="M940 49v22M929 60h22" stroke="#1b140d" stroke-width="4" stroke-linecap="round"/>
<circle cx="60" cy="569" r="17" fill="url(#${p}-tor)"/><path d="M60 558v22M49 569h22" stroke="#1b140d" stroke-width="4" stroke-linecap="round"/>
<circle cx="940" cy="569" r="17" fill="url(#${p}-tor)"/><path d="M940 558v22M929 569h22" stroke="#1b140d" stroke-width="4" stroke-linecap="round"/>
<rect x="112" y="74" width="776" height="356" fill="#f2e8d0"/>
<rect x="112" y="252" width="776" height="178" fill="#6e1f2a"/>
<rect x="112" y="74" width="776" height="356" fill="none" stroke="#1b140d" stroke-opacity="0.25" stroke-width="2"/>
<image id="${p}-foto" x="700" y="92" width="150" height="150" preserveAspectRatio="xMidYMid slice" clip-path="url(#${p}-fot)" style="display:none"/>
<rect id="${p}-marco" x="700" y="92" width="150" height="150" rx="6" fill="none" stroke="#1b140d" stroke-opacity="0.35" stroke-width="2" style="display:none"/>
<text x="150" y="176" font-family="'Bodoni Moda',serif" font-weight="700" font-size="74" fill="#231b13">A</text>
<text id="${p}-titulo" x="244" y="130" font-family="'Bodoni Moda',serif" font-style="italic" font-size="56" fill="#231b13"></text>
<text id="${p}-artista" x="246" y="166" font-family="'Cormorant Garamond',serif" font-size="24" letter-spacing="4" fill="#6e1f2a"></text>
<text id="${p}-sello" x="246" y="196" font-family="'Cormorant Garamond',serif" font-size="20" letter-spacing="4" fill="#a8853f"></text>
<rect x="266" y="228" width="468" height="166" rx="30" fill="#1b140d"/>
<rect x="278" y="240" width="444" height="142" rx="22" fill="#0f0a06"/>
<g clip-path="url(#${p}-ven)">
<rect x="278" y="240" width="444" height="142" fill="#0f0a06"/>
<circle cx="366" cy="311" r="56" fill="#0b0805"/><circle cx="634" cy="311" r="56" fill="#0b0805"/>
<rect class="cinta" id="${p}-tira" x="420" y="304" width="160" height="14" fill="#3a240f"/>
<circle id="${p}-pi" cx="366" cy="311" r="54" fill="url(#${p}-pack)" stroke="#6b6355" stroke-opacity="0.45" stroke-width="2"/>
<circle id="${p}-pi2" cx="366" cy="311" r="40" fill="none" stroke="#1b140d" stroke-opacity="0.4" stroke-width="2"/>
<circle id="${p}-pd" cx="634" cy="311" r="27" fill="url(#${p}-pack)" stroke="#6b6355" stroke-opacity="0.45" stroke-width="2"/>
<circle id="${p}-pd2" cx="634" cy="311" r="27" fill="none" stroke="#1b140d" stroke-opacity="0.4" stroke-width="2"/>
<g class="bobina"><g transform="translate(366 311) scale(0.565) translate(-366 -311)">
<circle cx="366" cy="311" r="46" fill="#e9e4d8" stroke="#8d8577" stroke-width="6"/>
<g stroke="#8d8577" stroke-width="10" stroke-linecap="round"><path d="M366 275v20M366 327v20M330 311h20M382 311h20M340 285l14 14M378 323l14 14M392 285l-14 14M354 323l-14 14"/></g>
<circle cx="366" cy="311" r="16" fill="#8d8577"/>
</g></g>
<g class="bobina der"><g transform="translate(634 311) scale(0.565) translate(-634 -311)">
<circle cx="634" cy="311" r="46" fill="#e9e4d8" stroke="#8d8577" stroke-width="6"/>
<g stroke="#8d8577" stroke-width="10" stroke-linecap="round"><path d="M634 275v20M634 327v20M598 311h20M650 311h20M608 285l14 14M646 323l14 14M660 285l-14 14M622 323l-14 14"/></g>
<circle cx="634" cy="311" r="16" fill="#8d8577"/>
</g></g>
<circle cx="366" cy="311" r="56" fill="none" stroke="#6b6355" stroke-opacity="0.55" stroke-width="3"/>
<circle cx="634" cy="311" r="56" fill="none" stroke="#6b6355" stroke-opacity="0.55" stroke-width="3"/>
</g>
<rect x="266" y="228" width="468" height="166" rx="30" fill="none" stroke="#000000" stroke-opacity="0.4" stroke-width="2"/>
<path d="M262 629 L286 452 Q288 440 302 440 L698 440 Q712 440 714 452 L738 629" fill="none" stroke="#6b6355" stroke-width="4"/>
<circle cx="352" cy="546" r="17" fill="#f7f2e6"/>
<rect x="416" y="532" width="42" height="28" rx="5" fill="#f7f2e6"/>
<rect x="542" y="532" width="42" height="28" rx="5" fill="#f7f2e6"/>
<circle cx="648" cy="546" r="17" fill="#f7f2e6"/>
</svg>`;
// ═════════ PLETINA (viewBox 0 0 1000 780) ═════════
const SIMBOLOS = {
rec:'M0,-13 A13,13 0 1,0 0,13 A13,13 0 1,0 0,-13 Z',
rew:'M-2,-12 L-19,0 L-2,12 Z M16,-12 L-1,0 L16,12 Z',
play:'M-9,-13 L14,0 L-9,13 Z',
ff:'M-16,-12 L1,0 L-16,12 Z M2,-12 L19,0 L2,12 Z',
stop:'M-11,-11 H11 V11 H-11 Z',
pausa:'M-11,-12 H-3 V12 H-11 Z M3,-12 H11 V12 H3 Z'
};
const TECLAS = [
{ id:'rec', x:80, texto:'GRAB', aria:'Grabar' },
{ id:'rew', x:222, texto:'REBOB', aria:'Rebobinar' },
{ id:'play', x:364, texto:'REPROD', aria:'Reproducir' },
{ id:'ff', x:506, texto:'AVANCE', aria:'Avance rápido' },
{ id:'stop', x:648, texto:'STOP', aria:'Parar' },
{ id:'pausa', x:790, texto:'PAUSA', aria:'Pausa' }
];
const VU = { cx:216, cy:574, r:80 };
const marcasVU = [-26,-13,0,13,26].map((g) => {
const a = g * Math.PI / 180;
const p = (rr) => [(VU.cx + rr * Math.sin(a)).toFixed(1), (VU.cy - rr * Math.cos(a)).toFixed(1)];
const [x1,y1] = p(VU.r - 9), [x2,y2] = p(VU.r);
return `<path d="M${x1} ${y1} L${x2} ${y2}" stroke="${g === 0 ? '#6e1f2a' : '#3a2e24'}" stroke-width="${g === 0 ? 2.5 : 1.5}"/>`;
}).join('');
const svgPletina = () => `
<svg class="pletina" id="svg-pletina" viewBox="0 0 1000 780" role="group" aria-label="Pletina de casete Levante CR-77">
<defs>
<linearGradient id="pl-cuerpo" x1="0" y1="0" x2="0.4" y2="1"><stop offset="0" stop-color="#2c2318"/><stop offset="1" stop-color="#140e08"/></linearGradient>
<linearGradient id="pl-alu" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#d7d0c0"/><stop offset="0.18" stop-color="#b3ab9b"/><stop offset="0.5" stop-color="#c9c1b0"/><stop offset="0.82" stop-color="#a49c8c"/><stop offset="1" stop-color="#c2baa9"/></linearGradient>
<linearGradient id="pl-tecla" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fbf7ec"/><stop offset="0.62" stop-color="#eee5cf"/><stop offset="1" stop-color="#cfc5ac"/></linearGradient>
<linearGradient id="pl-frente" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#bdb39a"/><stop offset="1" stop-color="#8f8672"/></linearGradient>
<radialGradient id="pl-tor" cx="0.36" cy="0.32" r="0.75"><stop offset="0" stop-color="#8a8175"/><stop offset="1" stop-color="#3a3227"/></radialGradient>
<radialGradient id="pl-piloto" cx="0.4" cy="0.35" r="0.7"><stop offset="0" stop-color="#ff8a72"/><stop offset="0.5" stop-color="#c8352a"/><stop offset="1" stop-color="#5e120d"/></radialGradient>
<pattern id="pl-rejilla" width="21" height="21" patternUnits="userSpaceOnUse"><circle cx="10.5" cy="10.5" r="5.2" fill="#1b140d" fill-opacity="0.9"/></pattern>
<clipPath id="pl-vu"><rect x="76" y="470" width="280" height="88" rx="6"/></clipPath>
<clipPath id="pl-cristal"><rect x="456" y="128" width="452" height="304" rx="6"/></clipPath>
<filter id="pl-sombra" x="-10%" y="-10%" width="120%" height="130%"><feDropShadow dx="0" dy="16" stdDeviation="14" flood-color="#3a2e24" flood-opacity="0.45"/></filter>
</defs>
<path d="M336 96 Q336 30 500 30 Q664 30 664 96" fill="none" stroke="#1b140d" stroke-width="22" stroke-linecap="round"/>
<g filter="url(#pl-sombra)">
<rect x="20" y="64" width="960" height="692" rx="28" fill="url(#pl-cuerpo)"/>
<rect x="44" y="88" width="912" height="644" rx="16" fill="url(#pl-alu)"/>
<rect x="44" y="88" width="912" height="644" rx="16" fill="none" stroke="#6f6757" stroke-opacity="0.6" stroke-width="2"/>
</g>
${[[32,140],[968,140],[32,680],[968,680]].map(([cx,cy]) =>
`<circle cx="${cx}" cy="${cy}" r="8" fill="url(#pl-tor)"/><path d="M${cx-5} ${cy}h10" stroke="#12100a" stroke-width="2.5" stroke-linecap="round"/>`).join('')}
<rect x="76" y="112" width="330" height="336" rx="10" fill="#8f8672"/>
<rect x="82" y="118" width="318" height="324" rx="7" fill="url(#pl-rejilla)"/>
<rect x="76" y="112" width="330" height="336" rx="10" fill="none" stroke="#4f4838" stroke-width="2"/>
<rect x="440" y="112" width="484" height="336" rx="12" fill="#241c15"/>
<rect x="456" y="128" width="452" height="304" rx="6" fill="#0f0a06"/>
<svg class="hueco" x="464" y="143" width="436" height="274" viewBox="0 0 1000 629" preserveAspectRatio="xMidYMid meet">${svgCasete('p')}</svg>
<g clip-path="url(#pl-cristal)">
<rect x="456" y="128" width="452" height="304" fill="#0f0a06" opacity="0.26"/>
<path d="M456 432 L640 128 L760 128 L576 432 Z" fill="#ffffff" opacity="0.06"/>
</g>
<rect x="456" y="128" width="452" height="304" rx="6" fill="none" stroke="#000000" stroke-opacity="0.5" stroke-width="3"/>
<rect x="76" y="470" width="280" height="88" rx="6" fill="#f2e8d0"/>
<g clip-path="url(#pl-vu)">
<path d="M180.9 502.1 A80 80 0 0 1 251.1 502.1" fill="none" stroke="#3a2e24" stroke-width="1.5"/>
<path d="M227.1 494.8 A80 80 0 0 1 251.1 502.1" fill="none" stroke="#6e1f2a" stroke-width="4"/>
${marcasVU}
<g class="aguja"><path d="M216 574 L216 492" stroke="#231b13" stroke-width="2.5" stroke-linecap="round"/></g>
<circle cx="216" cy="558" r="9" fill="#8f8672"/>
</g>
<text x="216" y="548" text-anchor="middle" font-family="'Cormorant Garamond',serif" font-size="15" letter-spacing="5" fill="#7a6a52">VU</text>
<rect x="76" y="470" width="280" height="88" rx="6" fill="none" stroke="#4f4838" stroke-width="2"/>
<rect x="386" y="482" width="180" height="66" rx="6" fill="#241c15"/>
${[0,1,2,3].map((i) => `<rect x="${399+i*40}" y="494" width="34" height="42" rx="3" fill="#f2e8d0"/>
<text id="dig-${i}" x="${416+i*40}" y="528" text-anchor="middle" font-family="'Bodoni Moda',serif" font-size="30" fill="#231b13">0</text>`).join('')}
<g class="reset" id="btn-reset" role="button" tabindex="0" aria-label="Poner el contador a cero">
<circle cx="590" cy="515" r="14" fill="url(#pl-tor)"/><circle cx="590" cy="515" r="7" fill="#1b140d"/>
</g>
<circle cx="640" cy="515" r="11" fill="#3a3227"/>
<circle class="piloto" cx="640" cy="515" r="8" fill="url(#pl-piloto)"/>
<text x="660" y="521" font-family="'Cormorant Garamond',serif" font-size="15" letter-spacing="4" fill="#5c5445">REPROD</text>
<text x="930" y="512" text-anchor="end" font-family="'Bodoni Moda',serif" font-weight="700" font-size="34" fill="#3a2e24">Levante</text>
<text x="930" y="540" text-anchor="end" font-family="'Cormorant Garamond',serif" font-size="17" letter-spacing="6" fill="#6f6757">MODELO CR-77</text>
${TECLAS.map((t) => `<text x="${t.x+65}" y="590" text-anchor="middle" font-family="'Cormorant Garamond',serif" font-size="15" letter-spacing="3" fill="#5c5445">${t.texto}</text>`).join('')}
<rect x="60" y="596" width="880" height="150" rx="10" fill="#1b140d"/>
${TECLAS.map((t) => `
<g class="tecla" id="tecla-${t.id}" role="button" tabindex="0" aria-label="${t.aria}" aria-pressed="false">
<rect x="${t.x}" y="604" width="130" height="128" rx="7" fill="#0d0906"/>
<g class="cuerpo">
<rect x="${t.x}" y="604" width="130" height="118" rx="7" fill="url(#pl-tecla)"/>
<rect x="${t.x}" y="700" width="130" height="22" rx="6" fill="url(#pl-frente)"/>
<path d="M${t.x+7} 611 H${t.x+123}" stroke="#ffffff" stroke-opacity="0.75" stroke-width="2"/>
<path d="${SIMBOLOS[t.id]}" transform="translate(${t.x+65} 652)" fill="${t.id === 'rec' ? '#6e1f2a' : '#3a2e24'}"/>
</g>
</g>`).join('')}
</svg>`;
// ═════════ Montaje ═════════
$('caja-vinilo').innerHTML = svgTocadiscos('v');
$('caja-giradiscos').innerHTML = svgGiradiscos();
$('caja-casete').innerHTML = svgCasete('c');
$('caja-pletina').innerHTML = svgPletina();
// ── Seguimiento del surco ──
// La aguja entra por r=185 y sale por r=88; con paso de surco constante el
// radio baja linealmente con el tiempo. r² = K + A·cos θ + B·sin θ, así que
// invertir radio -> ángulo tiene forma cerrada.
const PIV = { x:450, y:100 }, AGU = { x:452.63, y:354.16 };
const _a = AGU.x-PIV.x, _b = AGU.y-PIV.y, _dx = PIV.x-CX, _dy = PIV.y-CY;
const _K = _dx*_dx + _dy*_dy + _a*_a + _b*_b;
const _A = 2*(_dx*_a + _dy*_b), _B = 2*(_dy*_a - _dx*_b);
const _R = Math.hypot(_A,_B), _delta = Math.atan2(_B,_A);
const anguloPara = (r) => (_delta + Math.acos(Math.max(-1, Math.min(1, (r*r-_K)/_R)))) * 180 / Math.PI;
// ── Cinta: el área se conserva, así que R_izq² + R_der² es constante ──
const R_NUCLEO = 27, R_LLENO = 54;
const _SALTO = R_LLENO*R_LLENO - R_NUCLEO*R_NUCLEO;
const VUELTAS = 42, _porVuelta = 360*VUELTAS/(R_LLENO-R_NUCLEO);
const S = {
estado:'parado', soporte:'giradiscos', bobinando:null, cuenta:0, velocidad:45,
tiempo:0, duracion:165,
titulo:'Quédate', artista:'Juan Luis Alarcón', anio:'1973',
sello:'Discos Levante', rpm:'45 R.P.M.', imagen:''
};
const FOTO = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%23e0c7ac'/%3E%3Cstop offset='1' stop-color='%23a8853f'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='300' height='300' fill='url(%23g)'/%3E%3Ccircle cx='150' cy='115' r='54' fill='%23241c15' opacity='.6'/%3E%3Cpath d='M36 300c0-66 51-114 114-114s114 48 114 114z' fill='%23241c15' opacity='.6'/%3E%3C/svg%3E";
/** <text> no salta de línea: se mide y se baja el cuerpo si no cabe. */
function encajar(id, max, base) {
const el = $(id);
if (!el || !el.getComputedTextLength) return;
el.style.fontSize = base + 'px';
const w = el.getComputedTextLength();
if (w > max) el.style.fontSize = Math.max(base * 0.45, base * max / w) + 'px';
}
function pintar() {
const hay = S.imagen.trim() !== '';
for (const p of ['v', 'g']) {
$(p + '-sello').textContent = S.sello.toUpperCase();
$(p + '-titulo').textContent = S.titulo;
$(p + '-artista').textContent = S.artista.toUpperCase();
$(p + '-creditos').textContent = S.anio + ' · ' + S.velocidad + ' R.P.M.';
encajar(p + '-sello', 128, 8); encajar(p + '-titulo', 132, 22);
encajar(p + '-artista', 130, 8); encajar(p + '-creditos', 130, 8);
$(p + '-foto').style.display = hay ? '' : 'none';
$(p + '-velo').style.display = hay ? '' : 'none';
if (hay) $(p + '-foto').setAttribute('href', S.imagen);
}
const anchoTexto = hay ? 436 : 626;
for (const p of ['c', 'p']) {
$(p + '-titulo').textContent = S.titulo;
$(p + '-artista').textContent = S.artista.toUpperCase() + ' · ' + S.anio;
$(p + '-sello').textContent = S.sello.toUpperCase() + ' · C-60';
encajar(p + '-titulo', anchoTexto, 56);
encajar(p + '-artista', anchoTexto, 24);
encajar(p + '-sello', anchoTexto, 20);
$(p + '-foto').style.display = hay ? '' : 'none';
$(p + '-marco').style.display = hay ? '' : 'none';
if (hay) $(p + '-foto').setAttribute('href', S.imagen);
}
}
function sincronizar() {
const activo = S.estado !== 'parado';
const suena = S.estado === 'sonando' && !S.bobinando;
// 33⅓ r.p.m. son 1,8 s por vuelta; 45 r.p.m., 1,333 s.
const vuelta = (S.velocidad === 33 ? 1.8 : 1.333) + 's';
for (const id of ['svg-g', 'svg-giradiscos']) $(id).style.setProperty('--vuelta', vuelta);
for (const id of ['svg-v', 'svg-g']) {
const el = $(id);
el.classList.toggle('activo', activo);
el.classList.toggle('sonando', suena);
}
for (const id of ['svg-c', 'svg-p']) {
const el = $(id);
el.classList.toggle('activo', activo || !!S.bobinando);
el.classList.toggle('sonando', suena);
el.classList.toggle('rapido', !!S.bobinando);
}
const gd = $('svg-giradiscos');
gd.classList.toggle('activo', activo);
gd.classList.toggle('sonando', suena);
for (const v of [33, 45]) {
const b = $('vel-' + v);
b.classList.toggle('hundido', S.velocidad === v);
b.setAttribute('aria-pressed', String(S.velocidad === v));
}
$('gd-marcha').classList.toggle('hundido', activo);
$('gd-elevador').classList.toggle('arriba', S.estado === 'pausado');
$('gd-elevador').setAttribute('aria-pressed', String(S.estado === 'pausado'));
const pl = $('svg-pletina');
pl.classList.toggle('activo', activo);
pl.classList.toggle('sonando', suena);
pl.classList.toggle('bobinando', !!S.bobinando);
const hundidas = {
rec:false, rew:S.bobinando === 'atras', play:activo && !S.bobinando,
ff:S.bobinando === 'adelante', stop:false, pausa:S.estado === 'pausado'
};
for (const t of TECLAS) {
const el = $('tecla-' + t.id);
el.classList.toggle('hundida', hundidas[t.id]);
el.setAttribute('aria-pressed', String(hundidas[t.id]));
}
for (const id of ['vinilo', 'giradiscos', 'casete', 'pletina']) {
$('caja-' + id).classList.toggle('visible', S.soporte === id);
$('btn-' + id).setAttribute('aria-pressed', String(S.soporte === id));
}
$('btn-play').innerHTML = suena ? '❚❚&nbsp; Pausar' : '▶&nbsp; Reproducir';
$('eco-estado').textContent = "'" + S.estado + "'";
dibujarCuenta();
mecanica();
}
const mmss = (t) => Math.floor(t/60) + ':' + String(Math.floor(t%60)).padStart(2,'0');
function mecanica() {
const p = Math.min(1, Math.max(0, S.tiempo / S.duracion));
const activo = S.estado !== 'parado';
// Brazo: de reposo (-9°) al surco que toque
const radio = 185 + (88 - 185) * p;
const brazo = activo ? anguloPara(radio) : -9;
for (const id of ['svg-v','svg-g']) $(id).style.setProperty('--brazo', brazo.toFixed(3) + 'deg');
// Cinta: izquierda suministradora (llena) y derecha receptora (vacía)
const ri = Math.sqrt(R_LLENO*R_LLENO - p*_SALTO);
const rd = Math.sqrt(R_NUCLEO*R_NUCLEO + p*_SALTO);
for (const q of ['c','p']) {
$(q+'-pi').setAttribute('r', ri.toFixed(2));
$(q+'-pi2').setAttribute('r', ((ri+R_NUCLEO)/2).toFixed(2));
$(q+'-pd').setAttribute('r', rd.toFixed(2));
$(q+'-pd2').setAttribute('r', ((rd+R_NUCLEO)/2).toFixed(2));
const tira = $(q+'-tira');
tira.setAttribute('x', (366+ri).toFixed(2));
tira.setAttribute('width', (268-ri-rd).toFixed(2));
// Cada vuelta añade un grosor: las vueltas dadas son proporcionales a lo
// que ha crecido el radio, y de ahí sale sola la velocidad correcta.
const svg = $('svg-'+q);
svg.style.setProperty('--giro-izq', ((R_LLENO-ri)*_porVuelta).toFixed(2)+'deg');
svg.style.setProperty('--giro-der', ((rd-R_NUCLEO)*_porVuelta).toFixed(2)+'deg');
}
$('in-tiempo').value = S.tiempo;
$('eco-tiempo').textContent = mmss(S.tiempo) + ' / ' + mmss(S.duracion);
}
function dibujarCuenta() {
const d = String(S.cuenta).padStart(4, '0');
for (let i = 0; i < 4; i++) $('dig-' + i).textContent = d[i];
}
let reloj = null;
function relojear() {
if (reloj) { clearInterval(reloj); reloj = null; }
if (S.estado !== 'sonando' && !S.bobinando) return;
const paso = S.bobinando ? 37 : 1;
const ms = S.bobinando ? 55 : 340;
reloj = setInterval(() => {
S.cuenta = (S.cuenta + (S.bobinando === 'atras' ? -paso : paso) + 10000) % 10000;
dibujarCuenta();
// El tiempo lo pondría el <audio> real; aquí lo simulamos.
const dt = (S.bobinando ? (S.bobinando === 'atras' ? -12 : 12) : 1) * ms / 1000;
S.tiempo = Math.min(S.duracion, Math.max(0, S.tiempo + dt));
mecanica();
}, ms);
}
let tempo = null;
function posar() {
for (const id of ['svg-v','svg-g']) $(id).classList.add('posando');
clearTimeout(tempo);
tempo = setTimeout(() => {
for (const id of ['svg-v','svg-g']) $(id).classList.remove('posando');
}, 1700);
}
function aplicar(cambio) {
if ('estado' in cambio && (cambio.estado === 'parado') !== (S.estado === 'parado')) posar();
Object.assign(S, cambio);
if (S.estado === 'parado') { S.cuenta = 0; S.tiempo = 0; }
sincronizar();
relojear();
}
// ── Mandos de la pletina ──
function pulsar(id) {
if (id === 'stop') aplicar({ estado:'parado', bobinando:null });
else if (id === 'play') aplicar({ estado:'sonando', bobinando:null });
else if (id === 'pausa') aplicar({ estado: S.estado === 'pausado' ? 'sonando' : 'pausado' });
else if (id === 'rew' || id === 'ff') {
const destino = id === 'rew' ? 'atras' : 'adelante';
const nuevo = S.bobinando === destino ? null : destino;
aplicar({ bobinando:nuevo, estado: nuevo ? 'pausado' : S.estado });
}
}
const pulsable = (id, fn) => {
const el = $(id);
el.addEventListener('click', fn);
el.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); fn(); }
});
};
for (const t of TECLAS) pulsable('tecla-' + t.id, () => pulsar(t.id));
pulsable('btn-reset', () => { S.cuenta = 0; dibujarCuenta(); });
// ── Mandos del giradiscos ──
pulsable('gd-marcha', () => pulsar('play'));
pulsable('gd-paro', () => pulsar('stop'));
pulsable('gd-elevador', () => pulsar(S.estado === 'parado' ? 'play' : 'pausa'));
for (const v of [33, 45]) {
pulsable('vel-' + v, () => { S.velocidad = v; sincronizar(); pintar(); });
}
// ── Controles del panel ──
for (const id of ['vinilo', 'giradiscos', 'casete', 'pletina']) {
$('btn-' + id).addEventListener('click', () => aplicar({ soporte:id }));
}
$('btn-play').addEventListener('click', () => pulsar(S.estado === 'sonando' ? 'pausa' : 'play'));
$('btn-stop').addEventListener('click', () => pulsar('stop'));
for (const [input, clave] of [['in-titulo','titulo'], ['in-artista','artista'], ['in-anio','anio'], ['in-sello','sello']]) {
$(input).addEventListener('input', (e) => { S[clave] = e.target.value; pintar(); });
}
$('in-tiempo').addEventListener('input', (e) => { S.tiempo = +e.target.value; mecanica(); });
$('in-imagen').addEventListener('input', (e) => { S.imagen = e.target.value; pintar(); });
$('btn-foto').addEventListener('click', () => { S.imagen = FOTO; $('in-imagen').value = FOTO; pintar(); });
$('btn-sinfoto').addEventListener('click', () => { S.imagen = ''; $('in-imagen').value = ''; pintar(); });
pintar();
sincronizar();
// Las tipografías cambian el ancho medido: hay que reajustar al cargarlas.
if (document.fonts && document.fonts.ready) document.fonts.ready.then(pintar);
})();
</script>
</body>
</html>
Loading…
Cancel
Save

Powered by TurnKey Linux.