Typing lessons like duolingo & musicmouse cleanup
This commit is contained in:
24
web/eslint.config.js
Normal file
24
web/eslint.config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import js from "@eslint/js";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config([
|
||||
{
|
||||
ignores: ["dist"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
reactHooks.configs["recommended-latest"],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
]);
|
||||
1501
web/package-lock.json
generated
1501
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "tsc -b --noEmit false --emitDeclarationOnly false && eslint . || tsc -b",
|
||||
"check": "tsc -b && eslint .",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -15,10 +15,16 @@
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.5",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^15.15.0",
|
||||
"typescript": "^5.9.0",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "^7.1.0",
|
||||
"vitest": "^3.2.0"
|
||||
},
|
||||
|
||||
@@ -154,6 +154,33 @@ export function App() {
|
||||
void (state.playing ? api.pause() : api.resume());
|
||||
}, [connection, library.albums, play, results.albums, state]);
|
||||
|
||||
const onMute = useCallback(
|
||||
() => setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT),
|
||||
[setVolume, state],
|
||||
);
|
||||
|
||||
/** The one seek primitive: an optimistic position patch plus the backend call.
|
||||
* Shared by dragging the progress bar (an absolute target) and Shift+Arrow /
|
||||
* podcast skip (a target computed from the current position). */
|
||||
const seekTo = useCallback(
|
||||
(target: number) => {
|
||||
connection.optimistic({ position: target });
|
||||
void api.seek(target);
|
||||
},
|
||||
[connection],
|
||||
);
|
||||
|
||||
const skipPodcast = useCallback(
|
||||
(direction: 1 | -1) => {
|
||||
const target =
|
||||
direction > 0
|
||||
? Math.min(state?.duration ?? 0, (state?.position ?? 0) + PODCAST_SKIP_SECONDS)
|
||||
: Math.max(0, (state?.position ?? 0) - PODCAST_SKIP_SECONDS);
|
||||
seekTo(target);
|
||||
},
|
||||
[seekTo, state],
|
||||
);
|
||||
|
||||
/** The one place a keyboard action, a click or a tap all end up. */
|
||||
// Read-modify-write the whole mapping, like ParentPanel does for settings: the
|
||||
// backend takes a full replacement, not a per-slot patch.
|
||||
@@ -218,12 +245,7 @@ export function App() {
|
||||
case "next":
|
||||
playPop(260);
|
||||
if (currentAlbum && groupOf(currentAlbum) === "podcasts") {
|
||||
const target = Math.min(
|
||||
state?.duration ?? 0,
|
||||
(state?.position ?? 0) + PODCAST_SKIP_SECONDS,
|
||||
);
|
||||
connection.optimistic({ position: target });
|
||||
void api.seek(target);
|
||||
skipPodcast(1);
|
||||
} else {
|
||||
connection.optimistic({ position: 0 });
|
||||
void api.next();
|
||||
@@ -232,9 +254,7 @@ export function App() {
|
||||
case "previous":
|
||||
playPop(260);
|
||||
if (currentAlbum && groupOf(currentAlbum) === "podcasts") {
|
||||
const target = Math.max(0, (state?.position ?? 0) - PODCAST_SKIP_SECONDS);
|
||||
connection.optimistic({ position: target });
|
||||
void api.seek(target);
|
||||
skipPodcast(-1);
|
||||
} else {
|
||||
connection.optimistic({ position: 0 });
|
||||
void api.previous();
|
||||
@@ -249,8 +269,7 @@ export function App() {
|
||||
0,
|
||||
Math.min(state.duration, (state?.position ?? 0) + action.delta),
|
||||
);
|
||||
connection.optimistic({ position: target });
|
||||
void api.seek(target);
|
||||
seekTo(target);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -258,12 +277,23 @@ export function App() {
|
||||
playPop(action.freq);
|
||||
break;
|
||||
case "mute":
|
||||
setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT);
|
||||
onMute();
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[assignCurrentTo, connection, currentAlbum, play, setVolume, state, toggle],
|
||||
[
|
||||
assignCurrentTo,
|
||||
connection,
|
||||
currentAlbum,
|
||||
onMute,
|
||||
play,
|
||||
seekTo,
|
||||
setVolume,
|
||||
skipPodcast,
|
||||
state,
|
||||
toggle,
|
||||
],
|
||||
);
|
||||
|
||||
// Held in a ref so the listener is installed once rather than on every state change.
|
||||
@@ -356,11 +386,8 @@ export function App() {
|
||||
};
|
||||
|
||||
const onPlaySong = (hit: SongHit) => play(hit.album.id, hit.index);
|
||||
const onSeek = (target: number) => {
|
||||
connection.optimistic({ position: target });
|
||||
void api.seek(target);
|
||||
};
|
||||
const onMute = () => setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT);
|
||||
const onNext = useCallback(() => run([{ type: "next" }]), [run]);
|
||||
const onPrevious = useCallback(() => run([{ type: "previous" }]), [run]);
|
||||
|
||||
// The single top-left "back" button, shared by every level of the music page -
|
||||
// search, categories, play view - so it always means "one step out" no matter what
|
||||
@@ -425,9 +452,9 @@ export function App() {
|
||||
state={state}
|
||||
album={currentAlbum}
|
||||
onToggle={toggle}
|
||||
onNext={() => run([{ type: "next" }])}
|
||||
onPrevious={() => run([{ type: "previous" }])}
|
||||
onSeek={onSeek}
|
||||
onNext={onNext}
|
||||
onPrevious={onPrevious}
|
||||
onSeek={seekTo}
|
||||
onVolume={setVolume}
|
||||
onMute={onMute}
|
||||
onOpenAlbum={onOpenCurrentAlbum}
|
||||
@@ -514,9 +541,9 @@ export function App() {
|
||||
state={state}
|
||||
album={currentAlbum}
|
||||
onToggle={toggle}
|
||||
onNext={() => run([{ type: "next" }])}
|
||||
onPrevious={() => run([{ type: "previous" }])}
|
||||
onSeek={onSeek}
|
||||
onNext={onNext}
|
||||
onPrevious={onPrevious}
|
||||
onSeek={seekTo}
|
||||
onVolume={setVolume}
|
||||
onMute={onMute}
|
||||
onOpenPlayView={() => setUi((previous) => ({ ...previous, view: "play" }))}
|
||||
|
||||
@@ -30,32 +30,34 @@ function post(path: string, body?: unknown): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** `null` means the room-control page isn't configured, not an error - unlike
|
||||
* `request()`, a 404 here is expected and shouldn't throw. */
|
||||
async function fetchHaConfig(): Promise<HaConfig | null> {
|
||||
const response = await fetch("/api/ha");
|
||||
/** GET, treating a 404 as an expected "not configured" rather than an error - unlike
|
||||
* `request()`, which throws on it. Anything else that isn't ok still throws. */
|
||||
async function fetchOrNullOn404<T>(path: string): Promise<T | null> {
|
||||
const response = await fetch(`/api${path}`);
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) throw new Error(`GET /ha failed: ${response.status}`);
|
||||
return (await response.json()) as HaConfig;
|
||||
if (!response.ok) throw new Error(`GET ${path} failed: ${response.status}`);
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
/** `null` means the IR remote isn't configured, not an error - same convention as
|
||||
* `fetchHaConfig`. */
|
||||
async function fetchLircConfig(): Promise<LircConfig | null> {
|
||||
const response = await fetch("/api/lirc");
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) throw new Error(`GET /lirc failed: ${response.status}`);
|
||||
return (await response.json()) as LircConfig;
|
||||
/** GET, treating *any* non-ok response as "nothing to show" rather than an error - for
|
||||
* callers that poll and would rather fall back quietly than crash the poll loop. */
|
||||
async function fetchOrNullOnError<T>(path: string): Promise<T | null> {
|
||||
const response = await fetch(`/api${path}`);
|
||||
if (!response.ok) return null;
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
/** `null` means the room-control page isn't configured, not an error. */
|
||||
const fetchHaConfig = (): Promise<HaConfig | null> => fetchOrNullOn404<HaConfig>("/ha");
|
||||
|
||||
/** `null` means the IR remote isn't configured, not an error. */
|
||||
const fetchLircConfig = (): Promise<LircConfig | null> => fetchOrNullOn404<LircConfig>("/lirc");
|
||||
|
||||
/** `null` covers both "unknown to Home Assistant" and "Home Assistant unreachable
|
||||
* right now" (the backend answers the latter with a 502) - the room page treats a
|
||||
* device with no state the same way either way, rather than crashing on a poll. */
|
||||
async function fetchHaState(entityId: string): Promise<HaEntityState | null> {
|
||||
const response = await fetch(`/api/ha/states/${entityId}`);
|
||||
if (!response.ok) return null;
|
||||
return (await response.json()) as HaEntityState;
|
||||
}
|
||||
const fetchHaState = (entityId: string): Promise<HaEntityState | null> =>
|
||||
fetchOrNullOnError<HaEntityState>(`/ha/states/${entityId}`);
|
||||
|
||||
async function fetchHaStates(entityIds: string[]): Promise<Record<string, HaEntityState>> {
|
||||
const results = await Promise.all(entityIds.map(fetchHaState));
|
||||
@@ -68,11 +70,8 @@ async function fetchHaStates(entityIds: string[]): Promise<Record<string, HaEnti
|
||||
|
||||
/** `null` means "not analyzed" (the backend's expected 404 for this), not an error -
|
||||
* the ambient background just falls back to its un-analyzed baseline for that track. */
|
||||
async function fetchTrackDetail(albumId: string, trackIndex: number): Promise<TrackDetail | null> {
|
||||
const response = await fetch(`/api/tracks/${albumId}/${trackIndex}/analysis`);
|
||||
if (!response.ok) return null;
|
||||
return (await response.json()) as TrackDetail;
|
||||
}
|
||||
const fetchTrackDetail = (albumId: string, trackIndex: number): Promise<TrackDetail | null> =>
|
||||
fetchOrNullOnError<TrackDetail>(`/tracks/${albumId}/${trackIndex}/analysis`);
|
||||
|
||||
export const api = {
|
||||
library: () => request<{ albums: Album[] }>("/library").then((body) => body.albums),
|
||||
|
||||
@@ -234,7 +234,6 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
|
||||
const baseRef = useRef(ambienceBaseFor(album, trackAnalysis));
|
||||
useEffect(() => {
|
||||
baseRef.current = ambienceBaseFor(album, trackAnalysis);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [album, trackAnalysis]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* the whole page. */
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { MouseEvent as ReactMouseEvent, ReactNode } from "react";
|
||||
|
||||
import type { Album } from "../api/types";
|
||||
import {
|
||||
@@ -546,7 +547,7 @@ function ShelfRow({
|
||||
children,
|
||||
focusedIndex,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
/** Which tile in this row the keyboard is on, or `null` while some other row has
|
||||
* focus - keeps a Ctrl+h/l selection that's scrolled off the edge on screen, the
|
||||
* horizontal equivalent of the outer scroller's own keep-in-view effect above. */
|
||||
@@ -580,7 +581,7 @@ function ShelfRow({
|
||||
return () => element.removeEventListener("wheel", onWheel);
|
||||
}, []);
|
||||
|
||||
const onMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const onMouseDown = (event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
const element = ref.current;
|
||||
if (event.button !== 0 || !element) return;
|
||||
event.preventDefault(); // no text-selection/ghost-drag while panning
|
||||
@@ -632,7 +633,7 @@ function CategoryTile({
|
||||
entry: Category;
|
||||
navIndex?: number;
|
||||
selected?: boolean;
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onClick: (event: ReactMouseEvent<HTMLButtonElement>) => void;
|
||||
}) {
|
||||
const books = entry.albums.filter(isBook).length;
|
||||
const allBooks = books === entry.albums.length;
|
||||
@@ -728,7 +729,7 @@ function SectionTitle({
|
||||
children,
|
||||
centered,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
centered?: boolean;
|
||||
}) {
|
||||
return (
|
||||
@@ -750,7 +751,7 @@ function SectionTitle({
|
||||
);
|
||||
}
|
||||
|
||||
function Muted({ children }: { children: React.ReactNode }) {
|
||||
function Muted({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: "oklch(88% 0.02 210 / .7)" }}>
|
||||
{children}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* taller than wide for an audiobook. Callers pick which axis is fixed, never the ratio.
|
||||
*/
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { coverUrl } from "../api/client";
|
||||
import type { Album } from "../api/types";
|
||||
import { aspectOf, coverBackground, isBook } from "../lib/covers";
|
||||
@@ -21,7 +23,7 @@ interface Props {
|
||||
fit?: "width" | "height";
|
||||
radius?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
style?: CSSProperties;
|
||||
/** Print the title over generated art. Off for thumbnails, where it would not fit. */
|
||||
label?: boolean;
|
||||
}
|
||||
@@ -29,7 +31,7 @@ interface Props {
|
||||
export function Cover({ album, size, fit = "width", radius, className, style, label }: Props) {
|
||||
const book = isBook(album);
|
||||
const defaultRadius = book ? "6px 18px 18px 6px" : "16px";
|
||||
const box: React.CSSProperties =
|
||||
const box: CSSProperties =
|
||||
fit === "height"
|
||||
? { height: "100%", width: "auto", aspectRatio: aspectOf(album) }
|
||||
: { width: "100%", aspectRatio: aspectOf(album) };
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { Album, PlayerState } from "../api/types";
|
||||
import { usePlaybackClock } from "../hooks/usePlaybackClock";
|
||||
import { albumLine, isBook } from "../lib/covers";
|
||||
import { isBook, nowPlayingText } from "../lib/covers";
|
||||
import { clock, remainingInAlbum } from "../lib/format";
|
||||
import { groupOf } from "../lib/search";
|
||||
import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme";
|
||||
@@ -41,6 +41,7 @@ export function PlayView({
|
||||
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
|
||||
@@ -138,7 +139,7 @@ export function PlayView({
|
||||
textWrap: "pretty",
|
||||
}}
|
||||
>
|
||||
{state.track_title ?? "Wähl ein Album!"}
|
||||
{now.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -148,7 +149,7 @@ export function PlayView({
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
{album ? albumLine(album) : "Tippen oder klicken"}
|
||||
{now.subtitle}
|
||||
</div>
|
||||
{album && (
|
||||
<div
|
||||
@@ -159,7 +160,7 @@ export function PlayView({
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
{book ? "Kapitel" : "Song"} {state.track_index + 1} von {state.track_count}
|
||||
{now.progress}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -169,7 +170,7 @@ export function PlayView({
|
||||
position={position}
|
||||
duration={state.duration}
|
||||
onSeek={onSeek}
|
||||
resetKey={`${state.album_id ?? ""}:${state.track_index}`}
|
||||
resetKey={now.resetKey}
|
||||
height={14}
|
||||
interactive
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { Album, PlayerState } from "../api/types";
|
||||
import { usePlaybackClock } from "../hooks/usePlaybackClock";
|
||||
import { albumLine, isBook } from "../lib/covers";
|
||||
import { nowPlayingText } from "../lib/covers";
|
||||
import { SHOW_DECORATIVE_ANIMATIONS, SHOW_GLASS_BLUR } from "../lib/theme";
|
||||
import { Cover } from "./Cover";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
@@ -33,6 +33,7 @@ export function PlayerBar({
|
||||
onOpenPlayView,
|
||||
}: Props) {
|
||||
const position = usePlaybackClock(state.position, state.playing);
|
||||
const now = nowPlayingText(state, album);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -100,7 +101,7 @@ export function PlayerBar({
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{state.track_title ?? "Wähl ein Album!"}
|
||||
{now.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -112,7 +113,7 @@ export function PlayerBar({
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{album ? albumLine(album) : "Tippen oder klicken"}
|
||||
{now.subtitle}
|
||||
</div>
|
||||
{album && (
|
||||
<div
|
||||
@@ -123,8 +124,7 @@ export function PlayerBar({
|
||||
marginTop: 1,
|
||||
}}
|
||||
>
|
||||
{isBook(album) ? "Kapitel" : "Song"} {state.track_index + 1} von{" "}
|
||||
{state.track_count}
|
||||
{now.progress}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -134,7 +134,7 @@ export function PlayerBar({
|
||||
position={position}
|
||||
duration={state.duration}
|
||||
onSeek={onSeek}
|
||||
resetKey={`${state.album_id ?? ""}:${state.track_index}`}
|
||||
resetKey={now.resetKey}
|
||||
height={10}
|
||||
interactive
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** The progress bar, and the one thing the mockup could not do: scrubbing. */
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { PointerEvent } from "react";
|
||||
|
||||
interface Props {
|
||||
position: number;
|
||||
@@ -45,7 +46,7 @@ export function ProgressBar({
|
||||
const shown = dragging ?? position;
|
||||
const percent = duration > 0 ? Math.min(100, (shown / duration) * 100) : 0;
|
||||
|
||||
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!interactive || !duration) return;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setDragging(positionAt(event.clientX));
|
||||
|
||||
@@ -29,6 +29,11 @@ export function useHomeAssistant(config: HaConfig | null): HomeAssistant {
|
||||
// when the patch landed resolves with pre-click data - without this, that stale
|
||||
// response clobbers the optimistic "on" back to "off" until the *next* poll catches
|
||||
// up, which is what made toggling feel laggy despite the optimistic update existing.
|
||||
//
|
||||
// `usePlayerState`'s `optimistic()` solves the same "local guess vs. eventual truth"
|
||||
// problem far more simply, by just patching the whole object - it can get away with
|
||||
// that because the backend pushes over a websocket rather than being polled, so
|
||||
// there's no in-flight request that can resolve late and stomp on a newer patch.
|
||||
const optimisticAt = useRef<Record<string, number>>({});
|
||||
|
||||
const entityIds = useMemo(
|
||||
|
||||
@@ -15,7 +15,12 @@ const RECONNECT_DELAY_MS = 1500;
|
||||
export interface Connection {
|
||||
state: PlayerState | null;
|
||||
online: boolean;
|
||||
/** Apply a change locally so a keypress feels instant; the next frame reconciles. */
|
||||
/** Apply a change locally so a keypress feels instant; the next frame reconciles.
|
||||
*
|
||||
* A plain whole-object patch is enough here because state only ever arrives pushed
|
||||
* over the websocket - there's no in-flight poll that could resolve late and stomp
|
||||
* on it. `useHomeAssistant`'s `optimistic()` solves the same problem for a *polled*
|
||||
* source, where that race is real, with a per-entity timestamp guard instead. */
|
||||
optimistic: (patch: Partial<PlayerState>) => void;
|
||||
}
|
||||
|
||||
@@ -34,13 +39,21 @@ export function usePlayerState(onLibraryChanged: () => void): Connection {
|
||||
|
||||
const connect = () => {
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
socket = new WebSocket(`${protocol}//${location.host}/api/ws`);
|
||||
const ws = new WebSocket(`${protocol}//${location.host}/api/ws`);
|
||||
socket = ws;
|
||||
|
||||
socket.onopen = () => {
|
||||
setOnline(true);
|
||||
// The server sends a snapshot on connect, but a reconnect may have missed
|
||||
// changes in between, so ask for the truth as well.
|
||||
void api.state().then(setState).catch(() => undefined);
|
||||
// changes in between, so ask for the truth as well. Guarded against a slow
|
||||
// response landing after a *later* reconnect already replaced this socket -
|
||||
// every other fetch-on-mount hook in this codebase guards the same way.
|
||||
void api
|
||||
.state()
|
||||
.then((fetched) => {
|
||||
if (!closed && socket === ws) setState(fetched);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* artwork agree - and so do the LED strips, which run the same primary colour.
|
||||
*/
|
||||
|
||||
import type { Album, AlbumKind } from "../api/types";
|
||||
import type { Album, AlbumKind, PlayerState } from "../api/types";
|
||||
import { GROUP_HUE } from "./theme";
|
||||
|
||||
export const isBook = (album: Album): boolean => album.kind === "book";
|
||||
@@ -14,8 +14,8 @@ export const isBook = (album: Album): boolean => album.kind === "book";
|
||||
/** Shape is how you tell the two apart without reading anything: albums are square,
|
||||
* audiobooks are taller than wide, everywhere they appear - grid, list, group preview,
|
||||
* player bar, now-playing. Nothing else may set an aspect ratio on a cover. */
|
||||
export const ALBUM_ASPECT = 1;
|
||||
export const BOOK_ASPECT = 0.82;
|
||||
const ALBUM_ASPECT = 1;
|
||||
const BOOK_ASPECT = 0.82;
|
||||
|
||||
export const aspectOfKind = (kind: AlbumKind): number =>
|
||||
kind === "book" ? BOOK_ASPECT : ALBUM_ASPECT;
|
||||
@@ -29,7 +29,7 @@ const colours = (album: Album): [string, string, string] => [
|
||||
];
|
||||
|
||||
/** Diagonal two-tone stripes, the mockup's stand-in for a music cover. */
|
||||
export function stripes(album: Album, width: number): string {
|
||||
function stripes(album: Album, width: number): string {
|
||||
const [primary, secondary] = colours(album);
|
||||
return (
|
||||
`repeating-linear-gradient(135deg, ${primary} 0px, ${primary} ${width}px, ` +
|
||||
@@ -82,3 +82,24 @@ export const albumLine = (album: Album): string => {
|
||||
? `${prefix}${album.title} · ${album.artist}`
|
||||
: `${prefix}${album.title}`;
|
||||
};
|
||||
|
||||
export interface NowPlayingText {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
/** "Kapitel 3 von 8" or "Song 3 von 8" - only meaningful while an album is loaded. */
|
||||
progress: string;
|
||||
/** Changes whenever the track itself changes, so a progress bar can reset its drag
|
||||
* state instead of animating across two unrelated tracks. */
|
||||
resetKey: string;
|
||||
}
|
||||
|
||||
/** The title/subtitle/progress text shared by the player bar and the full-screen
|
||||
* now-playing view, so the two cannot drift apart on a copy change. */
|
||||
export function nowPlayingText(state: PlayerState, album: Album | null): NowPlayingText {
|
||||
return {
|
||||
title: state.track_title ?? "Wähl ein Album!",
|
||||
subtitle: album ? albumLine(album) : "Tippen oder klicken",
|
||||
progress: `${album && isBook(album) ? "Kapitel" : "Song"} ${state.track_index + 1} von ${state.track_count}`,
|
||||
resetKey: `${state.album_id ?? ""}:${state.track_index}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
/** The browse screen's shelf/row look, in one place: per-group colors and identity,
|
||||
* and the feature toggles that have gone back and forth while this design was under
|
||||
* review. Flip a toggle here rather than hunting through BrowseView.tsx/covers.ts.
|
||||
/** Per-group colors and identity, and the feature toggles that have gone back and
|
||||
* forth while this design was under review - flip one here rather than hunting
|
||||
* through the components that read it. Most toggles are about the browse screen's
|
||||
* shelf/row look, but some (`SHOW_GLASS_BLUR`, `SHOW_AMBIENCE`,
|
||||
* `SHOW_DECORATIVE_ANIMATIONS`) reach further: the play view, the player bar, the
|
||||
* room page and `App.tsx` all read this module too, wherever they share the same
|
||||
* "cut this on weak hardware" knob.
|
||||
*
|
||||
* `SHOW_ROW_TITLES` and `SHOW_ROW_ICONS` both `true` reproduces the original shelf
|
||||
* header - an icon badge plus label button above each row's tiles. */
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type { Group } from "./search";
|
||||
|
||||
// ---------------------------------------------------------------- toggles --
|
||||
@@ -70,7 +76,7 @@ export const GROUP_LABEL: Record<Group, string> = {
|
||||
|
||||
/** A shelf/row's frosted background and border, tinted with its group's hue - or,
|
||||
* with `SHOW_ROW_TINT` off, `{}` so `.glass-panel`'s own neutral CSS shows through. */
|
||||
export function glassTint(hue: number): React.CSSProperties {
|
||||
export function glassTint(hue: number): CSSProperties {
|
||||
if (!SHOW_ROW_TINT) return {};
|
||||
return {
|
||||
background: `linear-gradient(160deg, oklch(55% 0.1 ${hue} / .32), oklch(30% 0.06 ${hue} / .14))`,
|
||||
@@ -81,7 +87,7 @@ export function glassTint(hue: number): React.CSSProperties {
|
||||
/** One list row's background, tinted with its group's hue and brighter/more opaque
|
||||
* while it's the currently-playing row - or, with `SHOW_ROW_TINT` off, the same
|
||||
* neutral highlight the row used before tinting existed. */
|
||||
export function rowTint(hue: number, highlighted: boolean): React.CSSProperties {
|
||||
export function rowTint(hue: number, highlighted: boolean): CSSProperties {
|
||||
if (!SHOW_ROW_TINT) {
|
||||
return { background: `oklch(97% 0.01 210 / ${highlighted ? ".22" : ".10"})` };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user