251 lines
7.2 KiB
TypeScript
251 lines
7.2 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 { 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;
|
||
}
|
||
|
||
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="/dolphin-mascot.png"
|
||
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 ? "dolphinSwim 62s linear infinite" : "none",
|
||
// 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 210 / .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 210 / .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 210)",
|
||
}}
|
||
/>
|
||
)}
|
||
</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 210 / .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 210 / .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)"
|
||
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 210 / .95)",
|
||
color: assignPending ? "#fff" : "var(--ink)",
|
||
fontSize: 22,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
boxShadow: "0 8px 24px oklch(15% 0.05 210 / .4)",
|
||
}}
|
||
>
|
||
🎛️
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|