From bf12b8a57192561c521e997f4da72b6004fee28f Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 15 Aug 2026 15:41:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(skin-media-player):=20cuatro=20aparatos=20?= =?UTF-8?q?sobre=20el=20contrato=20del=20player=20=E2=80=94=20versi=C3=B3n?= =?UTF-8?q?=20A,=20para=20el=20hist=C3=B3rico?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` interior, así que la pintura del soporte se reutiliza. - 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 `