feat(media-player): VolumeFloat — el volumen en panel, sin mover la botonera

Opcional: componer MuteButton + VolumeSlider al lado sigue siendo la forma en
linea; esta es la otra y la elige el desarrollador.

POR QUE UN FLOAT Y NO UN DESPLIEGUE EN LA FILA. Se probo lo segundo —el patron
de YouTube, rail a inline-size 0 creciendo al hover— y se descarto con el
usuario delante: un slider que se expande dentro de una fila flex EMPUJA todo lo
que va detras (tiempo, scrubber, subtitulos, PiP, pantalla completa) y en una
fila apretada desborda. YouTube se lo permite porque su barra ocupa la ventana
entera; la nuestra no. El panel va fuera de flujo.

VERIFICADO que no mueve nada: se compararon las posiciones de los DIEZ controles
con el panel cerrado y abierto — identicas, cero diferencias. Es la promesa que
incumpli con el hover y que esta vez se midio.

Todo compuesto del catalogo, sin reinventar el flotado: Popover con openOnHover
(ya existia; solo se acorto la apertura a 120ms —esto es un control, no un
tooltip— y se alargo el cierre a 400ms para poder viajar hasta el), MuteButton
como trigger via child (sigue siendo el boton de verdad: conserva aria-pressed,
el atajo m y el glifo por nivel) y VolumeSlider vertical, el eje que el morfo
declaraba desde el principio.

El gesto: hover abre, CLIC SILENCIA y cierra, que es el remate natural de esa
accion. En tactil no hay hover, asi que el toque hace las dos cosas; es ademas
la unica via por la que un usuario tactil llega al slider.

TRAMPA ENCARADA: los tokens --_mp-* NO cruzan el portal (se declaran en
[data-media-player] y el contenido sale fuera), asi que el tinte del slider
lleva fallback al rol que envuelve. Sin el, el control sale sin color dentro del
panel.

Demo con chip volume inline|float para compararlos en la misma barra.

check 0 errores propios · player 14/14 · eidos-lint invalid 0 · docs:check 0 ·
prettier limpio. Panel 48x131, slider 32x96 vertical, visto en Chrome.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent 1061e94a59
commit 26ddbaa1c9

@ -162,6 +162,38 @@ esas propiedades. **Consecuencia**: el VTT posicionado, el de karaoke y el
multi-hablante no se verán como los autoró quien los escribió. Es una decisión,
no un olvido.
## Volumen flotante — `<MediaPlayer.VolumeFloat>` (2026-08-04)
**Opcional.** Componer `<MuteButton>` + `<VolumeSlider>` uno al lado del otro
sigue siendo la forma en línea; ésta es la otra, y la elige el desarrollador.
**Por qué un float y no un despliegue en la propia fila.** Se probó lo segundo —
el patrón de YouTube, con el rail a `inline-size: 0` creciendo al hover— y se
descartó **con el usuario delante**: un slider que se expande dentro de una fila
flex **empuja todo lo que va detrás** (tiempo, scrubber, subtítulos, PiP,
pantalla completa) y en una fila apretada desborda. YouTube se lo permite porque
su barra ocupa la ventana entera y tiene holgura; la nuestra no. El panel va
fuera de flujo, así que **abrirlo no mueve nada** — verificado comparando la
posición de los diez controles con el panel abierto y cerrado.
**Composición**, toda de piezas del catálogo, sin nada reinventado:
| pieza | papel |
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Popover` con `openOnHover` | el flotado y el gesto. Ya existía; sólo se acortó el retardo de apertura (120ms — esto es un control, no un tooltip) y se alargó el de cierre (400ms, para poder viajar hasta él) |
| `MuteButton` como trigger vía `child` | **sigue siendo el botón de verdad**: conserva `aria-pressed`, el atajo `m` y el glifo por nivel |
| `VolumeSlider orientation="vertical"` | el eje que el morfo declaraba desde el principio y nadie había cableado |
**El gesto**: hover abre, **clic silencia** — y cierra, que es el remate natural
de esa acción. En táctil no hay hover, así que el toque hace las dos cosas; es
además la única vía por la que un usuario táctil llega al slider.
⚠️ **Los tokens `--_mp-*` NO cruzan el portal.** El contenido se porta fuera del
player, y esos tokens se declaran en `[data-media-player]`. Por eso el tinte del
slider de volumen lleva fallback al rol que envuelve
(`var(--_mp-fg, var(--color-content-primary))`): sin él, el control sale sin
color dentro del panel.
## Audit exceptions
- **R-1.2 exception:** el estado disabled lo dibujan los primitivos compuestos —

@ -24,6 +24,7 @@ import Title from './media-player-title.svelte';
import Time from './media-player-time.svelte';
import TimeSlider from './media-player-time-slider.svelte';
import VolumeSlider from './media-player-volume-slider.svelte';
import VolumeFloat from './media-player-volume-float.svelte';
import DefaultControls from './media-player-default-controls.svelte';
import Artwork from './media-player-artwork.svelte';
import Artist from './media-player-artist.svelte';
@ -49,6 +50,7 @@ type MediaPlayerNamespace = typeof MediaPlayerComponent & {
Time: typeof Time;
TimeSlider: typeof TimeSlider;
VolumeSlider: typeof VolumeSlider;
VolumeFloat: typeof VolumeFloat;
DefaultControls: typeof DefaultControls;
Artwork: typeof Artwork;
Artist: typeof Artist;
@ -75,6 +77,7 @@ MediaPlayer.Title = Title;
MediaPlayer.Time = Time;
MediaPlayer.TimeSlider = TimeSlider;
MediaPlayer.VolumeSlider = VolumeSlider;
MediaPlayer.VolumeFloat = VolumeFloat;
MediaPlayer.DefaultControls = DefaultControls;
MediaPlayer.Artwork = Artwork;
MediaPlayer.Artist = Artist;
@ -104,6 +107,7 @@ export type {
MediaPlayerTimeProps as TimeProps,
MediaPlayerTimeSliderProps as TimeSliderProps,
MediaPlayerVolumeSliderProps as VolumeSliderProps,
MediaPlayerVolumeFloatProps as VolumeFloatProps,
MediaPlayerDefaultControlsProps as DefaultControlsProps,
MediaPlayerArtworkProps as ArtworkProps,
MediaPlayerArtistProps as ArtistProps,

@ -0,0 +1,48 @@
<script lang="ts">
/**
* `<MediaPlayer.VolumeFloat>` — the volume as a floating panel instead of an
* always-visible rail. Optional: composing `<MuteButton>` + `<VolumeSlider>`
* side by side is still the inline shape, and this is the other one.
*
* Why a float and not an in-row reveal: a slider that expands inside the
* control row PUSHES everything after it — time, scrubber, captions, PiP,
* fullscreen all shift on hover, and a tight row overflows. The panel is
* positioned out of flow, so opening it moves nothing.
*
* The speaker stays the real `<MuteButton>`: it keeps `aria-pressed`, the `m`
* hotkey and the loudness glyph. Hover opens the panel, click mutes — and
* closes it, which is the right end to that gesture. On touch there is no
* hover, so the tap does both: it is also the only way a touch user reaches
* the slider at all.
*
* Composed from the catalogue's own `Popover` (its `openOnHover` already
* exists); nothing about floating is reinvented here.
*/
import { Popover } from '$uix/eidos/components/popover';
import MuteButton from './media-player-mute-button.svelte';
import VolumeSlider from './media-player-volume-slider.svelte';
import type { MediaPlayerVolumeFloatProps } from './types';
let {
openDelay = 120,
closeDelay = 400,
side = 'top',
...rest
}: MediaPlayerVolumeFloatProps = $props();
</script>
<Popover {...rest}>
<Popover.Trigger openOnHover {openDelay} {closeDelay}>
{#snippet child({ props })}
<MuteButton {...props} />
{/snippet}
</Popover.Trigger>
<Popover.Portal>
<!-- The panel hugs the control. Popover's own sizes are made for prose and
forms; a vertical rail needs its hit area and nothing more, so the
width is overridden on the space scale rather than inherited. -->
<Popover.Content {side} size="sm" width="calc(var(--space-8) * 1.5)">
<VolumeSlider orientation="vertical" />
</Popover.Content>
</Popover.Portal>
</Popover>

@ -397,7 +397,25 @@
align-items: center;
margin-inline-end: var(--space-2);
}
/* The fallback is load-bearing, not defensive noise: in the floating volume the
slider is PORTALLED out of the player, and `--_mp-*` are declared on
`[data-media-player]`, so they do not inherit across the portal. Falling back
to the same role the token wraps keeps the control tinted in both places. */
[data-media-player-volume-slider] [data-slider] {
--slider-range-bg: var(--_mp-fg);
--slider-thumb-bg: var(--_mp-fg);
--slider-range-bg: var(--_mp-fg, var(--color-content-primary));
--slider-thumb-bg: var(--_mp-fg, var(--color-content-primary));
}
/* ── Volume, vertical (the floating panel) ────────────────────────────────
The Slider draws the whole vertical geometry itself — rail, range and thumb
all flip axis in its own recipe — so the player owes it only a LENGTH and the
removal of the inline width it wears inside a control row. */
[data-media-player-volume-slider][data-orientation='vertical'] {
inline-size: auto;
margin-inline-end: 0;
justify-content: center;
}
[data-media-player-volume-slider][data-orientation='vertical'] [data-slider] {
--slider-min-block-size: calc(var(--space-8) * 3);
flex: none;
}

@ -25,6 +25,10 @@ import type {
LiveIndicatorProps
} from '$soma/components/media-player';
import type { ResponsiveProp, Size } from '$uix/eidos/lib/types';
import type {
PopoverProps,
ContentProps as PopoverContentProps
} from '$uix/eidos/components/popover';
/** The player's control-chrome size. Scales the buttons, time and spacing. */
export type MediaPlayerSize = Extract<Size, 'sm' | 'md' | 'lg' | 'xl'>;
@ -48,6 +52,20 @@ export type MediaPlayerTitleProps = TitleProps;
export type MediaPlayerTimeProps = TimeProps;
export type MediaPlayerTimeSliderProps = TimeSliderProps;
export type MediaPlayerVolumeSliderProps = VolumeSliderProps;
/**
* The floating volume. An eidos-only composition (no morfo part of its own —
* same footing as `DefaultControls` and `AudioLayout`): it arranges the canon
* `Popover`, `MuteButton` and `VolumeSlider` and adds no contract.
*/
export type MediaPlayerVolumeFloatProps = PopoverProps & {
/** ms of hover before the panel opens. Short: this is a control, not a tip. @default 120 */
openDelay?: number;
/** ms before it closes on leave — long enough to travel into it. @default 400 */
closeDelay?: number;
/** Which side of the speaker the panel sits on. @default 'top' */
side?: PopoverContentProps['side'];
};
export type MediaPlayerDefaultControlsProps = { id?: string };
// ── Audio-only parts (F5, PLAN-audio-player-v2.md) ───────────────────────────

@ -50,6 +50,9 @@
let ariaLabel = $state('');
let captionsTrack = $state(true);
let size = $state<'sm' | 'md' | 'lg' | 'xl'>('md');
// Volume shape: the always-visible rail, or the floating panel. Opt-in either
// way — the point of the control is to A/B them on the same bar.
let volume = $state<'inline' | 'float'>('inline');
// ── Audio mode (F5): the <AudioPlayer> competing root + its 4 shapes ────
let variant = $state<'card' | 'row' | 'bar' | 'inline'>('card');
@ -212,8 +215,12 @@
<MediaPlayer.PlayButton />
<MediaPlayer.SeekButton direction="backward" />
<MediaPlayer.SeekButton direction="forward" />
<MediaPlayer.MuteButton />
<MediaPlayer.VolumeSlider />
{#if volume === 'float'}
<MediaPlayer.VolumeFloat />
{:else}
<MediaPlayer.MuteButton />
<MediaPlayer.VolumeSlider />
{/if}
<MediaPlayer.Time type="current" />
<MediaPlayer.TimeSlider />
<MediaPlayer.Time type="duration" />
@ -347,6 +354,20 @@
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>volume <span data-uix-control-hint>rail or floating panel</span></span
>
<span data-uix-chips role="radiogroup">
{#each ['inline', 'float'] as v}
<button
data-uix-chip
data-active={volume === v}
onclick={() => (volume = v as typeof volume)}>{v}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>layout</span>
<span data-uix-chips role="radiogroup">

Loading…
Cancel
Save

Powered by TurnKey Linux.