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
parent
2447a466b4
commit
bf12b8a571
@ -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,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,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;
|
||||||
|
};
|
||||||
@ -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,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">▶ Reproducir</button>
|
||||||
|
<button class="accion suave" id="btn-stop">■ 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 ? '❚❚ Pausar' : '▶ 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…
Reference in new issue