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>
262 lines
7.9 KiB
TypeScript
262 lines
7.9 KiB
TypeScript
/** 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>
|
||
);
|
||
}
|