diff --git a/src/uix/eidos/components/skin-media-player/README.md b/src/uix/eidos/components/skin-media-player/README.md new file mode 100644 index 000000000..be170ae24 --- /dev/null +++ b/src/uix/eidos/components/skin-media-player/README.md @@ -0,0 +1,155 @@ +# SkinMediaPlayer + +Fecha: 2026-08-15. + +Una **piel** para el `MediaPlayer`: la cara del reproductor es un objeto físico +dibujado — un disco, un tocadiscos, un casete o una pletina. El comportamiento +es del player entero; aquí sólo vive la representación y las afordancias que ese +objeto tiene de verdad. + +```svelte + + + +``` + +Para componer a mano, la pieza suelta dentro de cualquier player: + +```svelte + + + + + + + +``` + +## Baseline + +**No hay baseline.** `air` no tenía nada así, y las referencias headless del +proyecto (Radix / Ark / Bits / React Aria) no definen ni un media player, menos +aún una piel figurativa. El punto de partida son cuatro bocetos SVG del autor +(`web/routes/otros/players/`, banco de pruebas con estado simulado y sin audio) +y las dos pieles que ya existen en la casa: `ProofOfHuman.Clock` sobre el motor +`RotateAlign`, y las cuatro variantes de `AudioPlayer` sobre este mismo morfo. + +## Las cuatro pieles + +| `skin` | Objeto | Mandos | viewBox | +| ----------- | ---------- | --------------------------------------------- | ---------- | +| `record` | Disco | ninguno — pintura pura | 525 × 570 | +| `turntable` | Giradiscos | 33/45 · MARCHA · PARO · ELEVADOR | 1000 × 880 | +| `cassette` | Casete | ninguno — pintura pura | 1000 × 629 | +| `deck` | Pletina | GRAB · REBOB · REPROD · AVANCE · STOP · PAUSA | 1000 × 780 | + +`turntable` anida `record` en el hueco del plato y `deck` anida `cassette` tras +el cristal, los dos como `` interior: la piel del soporte se reutiliza +entera, no se redibuja. + +## Comparativa + +| Referencia | Qué hace | Aquí | +| ---------------------------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| **Winamp / foobar2000** (skins clásicos) | El skin ES el reproductor: bitmap + mapa de zonas activas, cada uno reimplementa su transporte | El skin **no es** el reproductor: reflejo + afordancias sobre las partes reales. Cero comportamiento propio | +| **Vidstack / Media Chrome** | Layouts intercambiables sobre un controlador; chrome funcional, nunca figurativo | Mismo reparto (estado en el elemento, chrome fuera), llevado a una representación de objeto | +| **Plyr / Video.js** | «Skins» = temas CSS sobre la misma barra | Aquí la piel cambia el OBJETO, no el tema de una barra. La barra canónica sigue debajo | +| **`AudioPlayer` (esta casa)** | Raíz competidora + 4 variantes en `[data-variant]` | Mismo patrón, eje distinto: `[data-skin]`. `data-variant` ya está ocupado por las formas de audio | +| **`ProofOfHuman.Clock` (esta casa)** | Piel hermana sobre un motor soma, con recipe propia | Misma idea; aquí las cuatro conviven en una raíz porque el eje es «qué objeto se ve» | + +## Decisiones + +- **La piel es un COMPLEMENTO, no un reproductor.** Ninguno de los cuatro + objetos tiene mando de volumen ni permite buscar libremente: la barra canónica + va debajo y posee esos dos ejes. `controls={false}` es para quien los aporte + por otro lado. Sin esto, `` sería un player incompleto + disfrazado de completo. +- **La afordancia es un `
+ {#if resolved === 'record'} + + {:else if resolved === 'cassette'} + + {:else if resolved === 'deck'} + + {:else} + + {/if} +
diff --git a/src/uix/eidos/components/skin-media-player/skin-media-player-turntable.svelte b/src/uix/eidos/components/skin-media-player/skin-media-player-turntable.svelte new file mode 100644 index 000000000..2c36ccb91 --- /dev/null +++ b/src/uix/eidos/components/skin-media-player/skin-media-player-turntable.svelte @@ -0,0 +1,362 @@ + + + + + +{#each [{ v: 33 as const, x: 736 }, { v: 45 as const, x: 836 }] as b (b.v)} + (speed = b.v)} + {...hold(`rpm-${b.v}`)} + /> +{/each} + + { + // MARCHA only starts. A bare toggle here would PAUSE a machine whose key + // is already latched down — the opposite of what the key means. + if (!provider.paused) e.preventDefault(); + }} +> + {#snippet child({ props }: { props: Record })} + + {/snippet} + + + + + + {#snippet child({ props }: { props: Record })} + + + {/snippet} + diff --git a/src/uix/eidos/components/skin-media-player/skin-media-player.css b/src/uix/eidos/components/skin-media-player/skin-media-player.css new file mode 100644 index 000000000..449ba17ea --- /dev/null +++ b/src/uix/eidos/components/skin-media-player/skin-media-player.css @@ -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); + } +} diff --git a/src/uix/eidos/components/skin-media-player/skin-media-player.svelte b/src/uix/eidos/components/skin-media-player/skin-media-player.svelte new file mode 100644 index 000000000..dabd7cb34 --- /dev/null +++ b/src/uix/eidos/components/skin-media-player/skin-media-player.svelte @@ -0,0 +1,77 @@ + + + + + + {#if controls} + + + + + + + + + {/if} + {@render children?.()} + diff --git a/src/uix/eidos/components/skin-media-player/types.ts b/src/uix/eidos/components/skin-media-player/types.ts new file mode 100644 index 000000000..8d3469309 --- /dev/null +++ b/src/uix/eidos/components/skin-media-player/types.ts @@ -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 ``. */ +export type SkinProps = SkinIdentity & { + /** Which object to depict. @default 'turntable' */ + skin?: ResponsiveProp; + /** + * Platter speed for the two vinyl skins. Decorative — it sets the spin + * duration and the strobe, and never touches `playbackRate`. @default 45 + */ + rpm?: SkinMediaPlayerRpm; +}; + +/** + * `` — the one-tag door: mounts `` + * + 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 & + SkinProps & { + /** Audio source URL (forwarded to the native `