Animations
This commit is contained in:
@@ -33,6 +33,7 @@ import { playPop } from "./lib/pop";
|
|||||||
import { targetForAlbum } from "./lib/remote";
|
import { targetForAlbum } from "./lib/remote";
|
||||||
import type { Group, Results, SongHit } from "./lib/search";
|
import type { Group, Results, SongHit } from "./lib/search";
|
||||||
import { categoryMatches, groupOf, results as computeResults } from "./lib/search";
|
import { categoryMatches, groupOf, results as computeResults } from "./lib/search";
|
||||||
|
import { SHOW_AMBIENCE, SHOW_GLASS_BLUR } from "./lib/theme";
|
||||||
|
|
||||||
/** How long an armed "A" waits for the digit that completes the shortcut. */
|
/** How long an armed "A" waits for the digit that completes the shortcut. */
|
||||||
const ASSIGN_PENDING_TIMEOUT_MS = 4000;
|
const ASSIGN_PENDING_TIMEOUT_MS = 4000;
|
||||||
@@ -377,8 +378,8 @@ export function App() {
|
|||||||
setUi((previous) => ({ ...previous, page: previous.page === "room" ? "music" : "room" }));
|
setUi((previous) => ({ ...previous, page: previous.page === "room" ? "music" : "room" }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stage">
|
<div className="stage" data-blur={SHOW_GLASS_BLUR ? undefined : "off"}>
|
||||||
{state && (
|
{state && SHOW_AMBIENCE && (
|
||||||
<Ambience
|
<Ambience
|
||||||
album={currentAlbum}
|
album={currentAlbum}
|
||||||
state={state}
|
state={state}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import type { Album } from "../api/types";
|
import type { Album } from "../api/types";
|
||||||
import { isBook, unitLabel } from "../lib/covers";
|
import { isBook, unitLabel } from "../lib/covers";
|
||||||
import { clock } from "../lib/format";
|
import { clock } from "../lib/format";
|
||||||
|
import { ANIMATE_VIEW_TRANSITIONS } from "../lib/theme";
|
||||||
import { Cover } from "./Cover";
|
import { Cover } from "./Cover";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -44,9 +45,14 @@ export function AlbumModal({
|
|||||||
}, [selectedIndex]);
|
}, [selectedIndex]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overlay" style={{ zIndex: 4, background: "oklch(15% 0.03 210 / .6)" }} onClick={onClose}>
|
<div
|
||||||
|
className={ANIMATE_VIEW_TRANSITIONS ? "overlay backdrop-enter" : "overlay"}
|
||||||
|
style={{ zIndex: 4, background: "oklch(15% 0.03 210 / .6)" }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
ref={sheet}
|
ref={sheet}
|
||||||
|
className={ANIMATE_VIEW_TRANSITIONS ? "sheet-enter" : undefined}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
background: "oklch(96% 0.012 210)",
|
background: "oklch(96% 0.012 210)",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { clock } from "../lib/format";
|
|||||||
import type { Category, Group, Results, SongHit } from "../lib/search";
|
import type { Category, Group, Results, SongHit } from "../lib/search";
|
||||||
import { categoryMatches, groupOf } from "../lib/search";
|
import { categoryMatches, groupOf } from "../lib/search";
|
||||||
import {
|
import {
|
||||||
|
ANIMATE_VIEW_TRANSITIONS,
|
||||||
glassTint,
|
glassTint,
|
||||||
GROUP_HUE,
|
GROUP_HUE,
|
||||||
GROUP_ICON,
|
GROUP_ICON,
|
||||||
@@ -137,7 +138,11 @@ export function BrowseView({
|
|||||||
|
|
||||||
if (isRootShelf) {
|
if (isRootShelf) {
|
||||||
return (
|
return (
|
||||||
<div ref={scroller} style={{ flex: 1, overflow: "auto", minHeight: 0, padding: "14px 32px 250px" }}>
|
<div
|
||||||
|
ref={scroller}
|
||||||
|
className={ANIMATE_VIEW_TRANSITIONS ? "view-enter" : undefined}
|
||||||
|
style={{ flex: 1, overflow: "auto", minHeight: 0, padding: "14px 32px 250px" }}
|
||||||
|
>
|
||||||
{shelves.map(({ group: shelfGroup, categories: shelfCategories }, index) => (
|
{shelves.map(({ group: shelfGroup, categories: shelfCategories }, index) => (
|
||||||
<div
|
<div
|
||||||
key={shelfGroup}
|
key={shelfGroup}
|
||||||
@@ -291,6 +296,8 @@ export function BrowseView({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
ref={scroller}
|
ref={scroller}
|
||||||
|
key={`${group}:${category}:${mode}`}
|
||||||
|
className={ANIMATE_VIEW_TRANSITIONS ? "view-enter" : undefined}
|
||||||
style={{ flex: 1, overflow: "auto", minHeight: 0, padding: "14px 32px 250px" }}
|
style={{ flex: 1, overflow: "auto", minHeight: 0, padding: "14px 32px 250px" }}
|
||||||
>
|
>
|
||||||
{songs.length > 0 && (
|
{songs.length > 0 && (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { usePlaybackClock } from "../hooks/usePlaybackClock";
|
|||||||
import { albumLine, isBook } from "../lib/covers";
|
import { albumLine, isBook } from "../lib/covers";
|
||||||
import { clock, remainingInAlbum } from "../lib/format";
|
import { clock, remainingInAlbum } from "../lib/format";
|
||||||
import { groupOf } from "../lib/search";
|
import { groupOf } from "../lib/search";
|
||||||
|
import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme";
|
||||||
import { Cover } from "./Cover";
|
import { Cover } from "./Cover";
|
||||||
import { ProgressBar } from "./ProgressBar";
|
import { ProgressBar } from "./ProgressBar";
|
||||||
import { Transport } from "./Transport";
|
import { Transport } from "./Transport";
|
||||||
@@ -76,7 +77,7 @@ export function PlayView({
|
|||||||
zIndex: 0,
|
zIndex: 0,
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
opacity: 0.92,
|
opacity: 0.92,
|
||||||
animation: "dolphinSwim 62s linear infinite",
|
animation: SHOW_DECORATIVE_ANIMATIONS ? "dolphinSwim 62s linear infinite" : "none",
|
||||||
// Freezes in its current pose rather than snapping back to frame 0 -
|
// Freezes in its current pose rather than snapping back to frame 0 -
|
||||||
// `animation: "none"` would restart it, `animationPlayState` just pauses.
|
// `animation: "none"` would restart it, `animationPlayState` just pauses.
|
||||||
animationPlayState: state.playing ? "running" : "paused",
|
animationPlayState: state.playing ? "running" : "paused",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { Album, PlayerState } from "../api/types";
|
import type { Album, PlayerState } from "../api/types";
|
||||||
import { usePlaybackClock } from "../hooks/usePlaybackClock";
|
import { usePlaybackClock } from "../hooks/usePlaybackClock";
|
||||||
import { albumLine, isBook } from "../lib/covers";
|
import { albumLine, isBook } from "../lib/covers";
|
||||||
|
import { SHOW_DECORATIVE_ANIMATIONS, SHOW_GLASS_BLUR } from "../lib/theme";
|
||||||
import { Cover } from "./Cover";
|
import { Cover } from "./Cover";
|
||||||
import { ProgressBar } from "./ProgressBar";
|
import { ProgressBar } from "./ProgressBar";
|
||||||
import { Transport } from "./Transport";
|
import { Transport } from "./Transport";
|
||||||
@@ -41,7 +42,7 @@ export function PlayerBar({
|
|||||||
bottom: 0,
|
bottom: 0,
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
background: "oklch(18% 0.05 210 / .96)",
|
background: "oklch(18% 0.05 210 / .96)",
|
||||||
backdropFilter: "blur(6px)",
|
backdropFilter: SHOW_GLASS_BLUR ? "blur(6px)" : undefined,
|
||||||
padding: "16px clamp(12px, 3vw, 32px)",
|
padding: "16px clamp(12px, 3vw, 32px)",
|
||||||
// Clear of the iPhone home indicator when installed to the home screen.
|
// Clear of the iPhone home indicator when installed to the home screen.
|
||||||
paddingBottom: "calc(16px + env(safe-area-inset-bottom))",
|
paddingBottom: "calc(16px + env(safe-area-inset-bottom))",
|
||||||
@@ -61,7 +62,10 @@ export function PlayerBar({
|
|||||||
objectFit: "contain",
|
objectFit: "contain",
|
||||||
flex: "0 1 auto",
|
flex: "0 1 auto",
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
animation: state.playing ? "dolphinBob 1.6s ease-in-out infinite" : "none",
|
animation:
|
||||||
|
SHOW_DECORATIVE_ANIMATIONS && state.playing
|
||||||
|
? "dolphinBob 1.6s ease-in-out infinite"
|
||||||
|
: "none",
|
||||||
filter: "drop-shadow(0 4px 12px oklch(10% 0.05 210 / .5))",
|
filter: "drop-shadow(0 4px 12px oklch(10% 0.05 210 / .5))",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useState } from "react";
|
|||||||
|
|
||||||
import type { HaConfig } from "../api/types";
|
import type { HaConfig } from "../api/types";
|
||||||
import { useHomeAssistant } from "../hooks/useHomeAssistant";
|
import { useHomeAssistant } from "../hooks/useHomeAssistant";
|
||||||
|
import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme";
|
||||||
import { AppHeader } from "./AppHeader";
|
import { AppHeader } from "./AppHeader";
|
||||||
import { Bubbles } from "./Bubbles";
|
import { Bubbles } from "./Bubbles";
|
||||||
import { LightCard } from "./LightCard";
|
import { LightCard } from "./LightCard";
|
||||||
@@ -28,7 +29,7 @@ export function RoomView({ config }: { config: HaConfig }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stage room-stage" style={{ display: "flex", flexDirection: "column" }}>
|
<div className="stage room-stage" style={{ display: "flex", flexDirection: "column" }}>
|
||||||
<Bubbles />
|
{SHOW_DECORATIVE_ANIMATIONS && <Bubbles />}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
|
|||||||
@@ -22,6 +22,28 @@ export const SHOW_ROW_ICONS = true;
|
|||||||
/** Vertical gap between shelves on the root browse screen, in px. */
|
/** Vertical gap between shelves on the root browse screen, in px. */
|
||||||
export const ROW_SPACING = 25;
|
export const ROW_SPACING = 25;
|
||||||
|
|
||||||
|
/** Fade the album/category grid in (with a slight upward slide) when a group or
|
||||||
|
* category is entered, and the album modal in when it opens. One animation per
|
||||||
|
* container, not per card, so cost stays flat regardless of grid size. */
|
||||||
|
export const ANIMATE_VIEW_TRANSITIONS = true;
|
||||||
|
|
||||||
|
/** Frost the glass panels/cards/rows with a real backdrop blur. `backdrop-filter` is
|
||||||
|
* one of the most GPU-expensive CSS effects in the app - a live backdrop copy+blur
|
||||||
|
* per element, repeated for every album card in the unvirtualized grid - so turn
|
||||||
|
* this off on weak hardware (e.g. an old Raspberry Pi) to fall back to a plain
|
||||||
|
* translucent background with no blur. */
|
||||||
|
export const SHOW_GLASS_BLUR = true;
|
||||||
|
|
||||||
|
/** Render the play view's animated canvas background (gradient + bubbles, both
|
||||||
|
* redrawn every frame at 60fps). Off falls back to `.stage`'s own static CSS
|
||||||
|
* gradient, which is still underneath the canvas either way. */
|
||||||
|
export const SHOW_AMBIENCE = true;
|
||||||
|
|
||||||
|
/** Run the purely decorative CSS animation loops: the room page's bubble field and
|
||||||
|
* the dolphin mascot's bob/swim. Individually cheap (opacity/transform only) but
|
||||||
|
* free to cut on weak hardware. */
|
||||||
|
export const SHOW_DECORATIVE_ANIMATIONS = true;
|
||||||
|
|
||||||
// ------------------------------------------------------------------ colors --
|
// ------------------------------------------------------------------ colors --
|
||||||
|
|
||||||
/** Per-group hue (oklch degrees). The play view's ambient water uses the same
|
/** Per-group hue (oklch degrees). The play view's ambient water uses the same
|
||||||
|
|||||||
@@ -71,6 +71,31 @@ button {
|
|||||||
100% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(-1); }
|
100% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(-1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Entrance animations for the browse grid and the album modal - gated by
|
||||||
|
`ANIMATE_VIEW_TRANSITIONS` in `lib/theme.ts`. */
|
||||||
|
@keyframes viewEnter {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes sheetEnter {
|
||||||
|
from { opacity: 0; transform: translateY(24px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes backdropEnter {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-enter {
|
||||||
|
animation: viewEnter 200ms ease-out;
|
||||||
|
}
|
||||||
|
.sheet-enter {
|
||||||
|
animation: sheetEnter 200ms ease-out;
|
||||||
|
}
|
||||||
|
.backdrop-enter {
|
||||||
|
animation: backdropEnter 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
.stage {
|
.stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -132,6 +157,17 @@ button {
|
|||||||
box-shadow: 0 4px 14px var(--shadow);
|
box-shadow: 0 4px 14px var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* `SHOW_GLASS_BLUR` (`lib/theme.ts`) off sets `data-blur="off"` on `.stage` - drop
|
||||||
|
the (GPU-expensive) backdrop blur everywhere below and fall back to each
|
||||||
|
element's own plain translucent background. */
|
||||||
|
.stage[data-blur="off"] .card,
|
||||||
|
.stage[data-blur="off"] .group-icon,
|
||||||
|
.stage[data-blur="off"] .glass-panel,
|
||||||
|
.stage[data-blur="off"] .list-row {
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -34,6 +34,12 @@
|
|||||||
box-shadow: 0 8px 24px var(--room-shadow);
|
box-shadow: 0 8px 24px var(--room-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* See the matching rule in app.css - `SHOW_GLASS_BLUR` off sets this on `.stage`. */
|
||||||
|
.stage[data-blur="off"] .room-card {
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
.room-pill {
|
.room-pill {
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
Reference in New Issue
Block a user