Files
musicmouse/web/src/components/PlayView.tsx
Martin Bauer 459599d2ba Number keys: the keyboard's digits work like the remote's; a key can hold one song
The IR remote's digits and the kiosk keyboard's number keys now both emit
PlayRemoteSlotRequested, and one reaction resolves the slot for either (a podcast
show to its newest episode, an album to the album). POST /api/remote/press/{digit}
is the keyboard's way in. Mid-search a digit is still part of the query.

A remote slot can start one song instead of a whole album: `track_index` on an
album slot plays from that track, then the rest of the album. Shift+A on the play
screen arms "this song" where A arms "this album", and the assign popup offers
both. A series slot cannot name a song, and an index past the album's end is
rejected when saving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:41:34 +02:00

262 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** The full-screen now-playing view. */
import type { Album, PlayerState } from "../api/types";
import { usePlaybackClock } from "../hooks/usePlaybackClock";
import { isBook, nowPlayingText } from "../lib/covers";
import { clock, remainingInAlbum } from "../lib/format";
import { groupOf } from "../lib/search";
import { SKIN } from "../lib/skins";
import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme";
import { Cover } from "./Cover";
import { ProgressBar } from "./ProgressBar";
import { Transport } from "./Transport";
import { VolumeBars } from "./VolumeBars";
interface Props {
state: PlayerState;
album: Album | null;
onToggle: () => void;
onNext: () => void;
onPrevious: () => void;
onSeek: (position: number) => void;
onVolume: (percent: number) => void;
onMute: () => void;
onOpenAlbum: () => void;
/** Assigning the current album/show to a remote key. `assignPending` is true right
* after "A" is pressed, waiting for the digit that completes the shortcut. */
onOpenAssign: () => void;
assignPending: boolean;
}
/** How the mascot crosses the play view - keyframes in app.css. */
const MASCOT_CROSSING = {
swim: "mascotSwim 62s linear infinite",
walk: "mascotWalk 62s linear infinite, mascotWaddle 0.8s ease-in-out infinite",
} as const;
export function PlayView({
state,
album,
onToggle,
onNext,
onPrevious,
onSeek,
onVolume,
onMute,
onOpenAlbum,
onOpenAssign,
assignPending,
}: Props) {
const position = usePlaybackClock(state.position, state.playing);
const now = nowPlayingText(state, album);
const book = album ? isBook(album) : false;
const podcast = album ? groupOf(album) === "podcasts" : false;
const remaining = album
? remainingInAlbum(
album.tracks.map((track) => track.duration),
state.track_index,
position,
)
: 0;
return (
<div style={{ position: "relative", zIndex: 1, height: "100%", minHeight: 0, overflow: "auto" }}>
<div
style={{
minHeight: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "clamp(8px, 1.8vh, 26px)",
padding: "clamp(14px, 3.5vh, 40px) 40px",
}}
>
<img
src={SKIN.mascot}
alt=""
style={{
position: "absolute",
top: "14%",
left: 0,
width: 140,
height: 140,
objectFit: "contain",
zIndex: 0,
pointerEvents: "none",
opacity: 0.92,
animation: SHOW_DECORATIVE_ANIMATIONS ? MASCOT_CROSSING[SKIN.mascotMotion] : "none",
// `mascotSwim` assumes a drawing that faces right. `scale` composes with the
// keyframes' `transform` rather than replacing it, so a left-facing mascot
// mirrored here still turns round at the far edge with everyone else.
scale: SKIN.mascotFacesLeft ? "-1 1" : undefined,
// Freezes in its current pose rather than snapping back to frame 0 -
// `animation: "none"` would restart it, `animationPlayState` just pauses.
animationPlayState: state.playing ? "running" : "paused",
filter: "drop-shadow(0 10px 26px oklch(10% 0.05 var(--hue) / .45))",
}}
/>
<div
style={{
height: "min(340px, 32vh)",
flex: "none",
position: "relative",
zIndex: 1,
display: "flex",
filter: "drop-shadow(0 20px 40px oklch(10% 0.05 var(--hue) / .55))",
}}
>
{album ? (
<button
onClick={onOpenAlbum}
aria-label="Titelliste anzeigen"
style={{
border: "none",
background: "none",
padding: 0,
cursor: "pointer",
display: "flex",
height: "100%",
}}
>
<Cover
album={album}
size={340}
fit="height"
radius={book ? "18px 34px 34px 18px" : "28px"}
label
/>
</button>
) : (
<div
style={{
height: "100%",
aspectRatio: 1,
borderRadius: 28,
background: "oklch(35% 0.03 var(--hue))",
}}
/>
)}
</div>
<div style={{ textAlign: "center", maxWidth: 760, position: "relative", zIndex: 1 }}>
<div
style={{
fontSize: "clamp(28px, 4.6vh, 46px)",
fontWeight: 900,
color: "var(--paper)",
lineHeight: 1.1,
textWrap: "pretty",
}}
>
{now.title}
</div>
<div
style={{
fontSize: 20,
fontWeight: 700,
color: "oklch(88% 0.02 var(--hue) / .8)",
marginTop: 8,
}}
>
{now.subtitle}
</div>
{album && (
<div
style={{
fontSize: 15,
fontWeight: 800,
color: "var(--accent-dim)",
marginTop: 6,
}}
>
{now.progress}
</div>
)}
</div>
<div style={{ width: "min(680px, 90%)", position: "relative", zIndex: 1 }}>
<ProgressBar
position={position}
duration={state.duration}
onSeek={onSeek}
resetKey={now.resetKey}
height={14}
interactive
/>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
marginTop: 8,
fontSize: 14,
fontWeight: 800,
color: "oklch(88% 0.02 var(--hue) / .8)",
}}
>
<span style={{ fontFamily: "ui-monospace, Menlo, monospace" }}>
{state.duration ? `−${clock(state.duration - position)}` : ""}
</span>
<span>
{album && !podcast ? `Noch ${clock(remaining)} ${book ? "im Hörbuch" : "im Album"}` : ""}
</span>
</div>
</div>
<div style={{ position: "relative", zIndex: 1 }}>
<Transport
playing={state.playing}
onToggle={onToggle}
onNext={onNext}
onPrevious={onPrevious}
size={72}
gap={22}
/>
</div>
<div style={{ position: "relative", zIndex: 1 }}>
<VolumeBars
volume={state.volume}
onChange={onVolume}
onMute={onMute}
barWidth={18}
barHeight="clamp(26px, 4.6vh, 44px)"
gap={5}
iconSize={24}
/>
</div>
{album && (
<button
onClick={onOpenAssign}
aria-label={assignPending ? "Zahl drücken …" : "Taste zuweisen"}
title="Einer Fernbedienungs-Taste zuweisen (oder: A und dann eine Zahl, Shift+A für nur dieses Lied)"
style={{
position: "absolute",
bottom: 28,
right: 32,
border: "none",
cursor: "pointer",
width: 52,
height: 52,
borderRadius: 999,
background: assignPending ? "var(--accent)" : "oklch(97% 0.01 var(--hue) / .95)",
color: assignPending ? "#fff" : "var(--ink)",
fontSize: 22,
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 8px 24px oklch(15% 0.05 var(--hue) / .4)",
}}
>
🎛️
</button>
)}
</div>
</div>
);
}