UI cleanup, visual and keyboard navigation
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "Musik Delphin",
|
"name": "Musik Delfin",
|
||||||
"short_name": "Musik",
|
"short_name": "Musik",
|
||||||
"description": "Musik und Hörbücher für die MusicMouse",
|
"description": "Musik und Hörbücher für die MusicMouse",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ import { usePlayerState } from "./hooks/usePlayerState";
|
|||||||
import { DEFAULT_MANUAL_CONTROL, DEFAULT_TUNABLES } from "./lib/ambienceTunables";
|
import { DEFAULT_MANUAL_CONTROL, DEFAULT_TUNABLES } from "./lib/ambienceTunables";
|
||||||
import type { AmbienceTunables, ManualControl } from "./lib/ambienceTunables";
|
import type { AmbienceTunables, ManualControl } from "./lib/ambienceTunables";
|
||||||
import type { Action, UiState } from "./lib/keyboard";
|
import type { Action, UiState } from "./lib/keyboard";
|
||||||
import { browseBackActions, handleKey, initialUiState, isRootShelf } from "./lib/keyboard";
|
import { browseBackActions, GROUP_ORDER, handleKey, initialUiState, isRootShelf } from "./lib/keyboard";
|
||||||
import { playPop } from "./lib/pop";
|
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 { groupOf, results as computeResults } from "./lib/search";
|
import { categoryMatches, groupOf, results as computeResults } from "./lib/search";
|
||||||
|
|
||||||
/** 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;
|
||||||
@@ -101,6 +101,23 @@ export function App() {
|
|||||||
[library.albums, ui.search, ui.mode, ui.group, ui.category],
|
[library.albums, ui.search, ui.mode, ui.group, ui.category],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The bare root's three shelves each compute their own categories independently
|
||||||
|
// (see BrowseView) - the keyboard handler needs the same lists, by key only, to know
|
||||||
|
// how far Ctrl+hjkl can move within a row and which category ENTER opens.
|
||||||
|
const rootShelves = useMemo(
|
||||||
|
() =>
|
||||||
|
GROUP_ORDER.map((group) =>
|
||||||
|
categoryMatches({
|
||||||
|
albums: library.albums,
|
||||||
|
search: "",
|
||||||
|
mode: "albums",
|
||||||
|
category: null,
|
||||||
|
group,
|
||||||
|
}).map((entry) => entry.key),
|
||||||
|
),
|
||||||
|
[library.albums],
|
||||||
|
);
|
||||||
|
|
||||||
const byId = useMemo(
|
const byId = useMemo(
|
||||||
() => new Map(library.albums.map((album) => [album.id, album])),
|
() => new Map(library.albums.map((album) => [album.id, album])),
|
||||||
[library.albums],
|
[library.albums],
|
||||||
@@ -249,8 +266,14 @@ export function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Held in a ref so the listener is installed once rather than on every state change.
|
// Held in a ref so the listener is installed once rather than on every state change.
|
||||||
const latest = useRef({ ui, results, run, openAlbumTrackCount: 0 });
|
const latest = useRef({ ui, results, run, openAlbumTrackCount: 0, rootShelves });
|
||||||
latest.current = { ui, results, run, openAlbumTrackCount: openAlbum?.tracks.length ?? 0 };
|
latest.current = {
|
||||||
|
ui,
|
||||||
|
results,
|
||||||
|
run,
|
||||||
|
openAlbumTrackCount: openAlbum?.tracks.length ?? 0,
|
||||||
|
rootShelves,
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -259,7 +282,13 @@ export function App() {
|
|||||||
if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
|
if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
|
||||||
|
|
||||||
const current = latest.current;
|
const current = latest.current;
|
||||||
const actions = handleKey(event, current.ui, current.results, current.openAlbumTrackCount);
|
const actions = handleKey(
|
||||||
|
event,
|
||||||
|
current.ui,
|
||||||
|
current.results,
|
||||||
|
current.openAlbumTrackCount,
|
||||||
|
current.rootShelves,
|
||||||
|
);
|
||||||
if (!actions.length) return;
|
if (!actions.length) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
current.run(actions);
|
current.run(actions);
|
||||||
@@ -378,6 +407,7 @@ export function App() {
|
|||||||
search={ui.search}
|
search={ui.search}
|
||||||
category={ui.category}
|
category={ui.category}
|
||||||
selIndex={ui.selIndex}
|
selIndex={ui.selIndex}
|
||||||
|
shelfRow={ui.shelfRow}
|
||||||
currentAlbumId={state.album_id}
|
currentAlbumId={state.album_id}
|
||||||
gridRef={gridRef}
|
gridRef={gridRef}
|
||||||
onEnterGroup={onEnterGroup}
|
onEnterGroup={onEnterGroup}
|
||||||
@@ -478,7 +508,7 @@ export function App() {
|
|||||||
<HelpOverlay onClose={() => setUi((previous) => ({ ...previous, showHelp: false }))} />
|
<HelpOverlay onClose={() => setUi((previous) => ({ ...previous, showHelp: false }))} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ui.page === "music" && ui.view === "browse" && state && (
|
{ui.page === "music" && ui.view === "browse" && state && currentAlbum && (
|
||||||
<PlayerBar
|
<PlayerBar
|
||||||
state={state}
|
state={state}
|
||||||
album={currentAlbum}
|
album={currentAlbum}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface Props {
|
|||||||
status?: ReactNode;
|
status?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppHeader({ title = "Musik Delphin", mascot = "/dolphin-mascot.png", status }: Props) {
|
export function AppHeader({ title = "Musik Delfin", mascot = "/dolphin-mascot.png", status }: Props) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ import {
|
|||||||
import { clock } from "../lib/format";
|
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 {
|
||||||
|
glassTint,
|
||||||
|
GROUP_HUE,
|
||||||
|
GROUP_ICON,
|
||||||
|
GROUP_LABEL,
|
||||||
|
ROW_SPACING,
|
||||||
|
rowTint,
|
||||||
|
SHOW_ROW_ICONS,
|
||||||
|
SHOW_ROW_TITLES,
|
||||||
|
} from "../lib/theme";
|
||||||
import { Cover } from "./Cover";
|
import { Cover } from "./Cover";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -29,6 +39,9 @@ interface Props {
|
|||||||
search: string;
|
search: string;
|
||||||
category: string | null;
|
category: string | null;
|
||||||
selIndex: number;
|
selIndex: number;
|
||||||
|
/** Which of the three root shelves is focused - only meaningful at the bare root,
|
||||||
|
* where `selIndex` means "which tile in that row" instead of a flat position. */
|
||||||
|
shelfRow: number;
|
||||||
currentAlbumId: string | null;
|
currentAlbumId: string | null;
|
||||||
gridRef: (element: HTMLElement | null) => void;
|
gridRef: (element: HTMLElement | null) => void;
|
||||||
onEnterGroup: (group: Group, category: string | null) => void;
|
onEnterGroup: (group: Group, category: string | null) => void;
|
||||||
@@ -43,12 +56,6 @@ interface Props {
|
|||||||
|
|
||||||
const GROUPS: Group[] = ["music", "audiobooks", "podcasts"];
|
const GROUPS: Group[] = ["music", "audiobooks", "podcasts"];
|
||||||
|
|
||||||
const GROUP_TITLE: Record<Group, string> = {
|
|
||||||
music: "🎵 Musik",
|
|
||||||
audiobooks: "📖 Hörbücher",
|
|
||||||
podcasts: "🎙️ Podcasts",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Heading over the category tiles inside a group. */
|
/** Heading over the category tiles inside a group. */
|
||||||
const GROUP_CATEGORY_LABEL: Record<Group, string> = {
|
const GROUP_CATEGORY_LABEL: Record<Group, string> = {
|
||||||
music: "Künstler",
|
music: "Künstler",
|
||||||
@@ -71,6 +78,7 @@ export function BrowseView({
|
|||||||
search,
|
search,
|
||||||
category,
|
category,
|
||||||
selIndex,
|
selIndex,
|
||||||
|
shelfRow,
|
||||||
currentAlbumId,
|
currentAlbumId,
|
||||||
gridRef,
|
gridRef,
|
||||||
onEnterGroup,
|
onEnterGroup,
|
||||||
@@ -81,10 +89,23 @@ export function BrowseView({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const scroller = useRef<HTMLDivElement | null>(null);
|
const scroller = useRef<HTMLDivElement | null>(null);
|
||||||
const { songs, categories, albums: albumResults, total } = results;
|
const { songs, categories, albums: albumResults, total } = results;
|
||||||
const selected = total ? Math.min(selIndex, total - 1) : -1;
|
|
||||||
|
|
||||||
const isRootShelf = group === null && mode !== "tracks" && !search;
|
const isRootShelf = group === null && mode !== "tracks" && !search;
|
||||||
|
|
||||||
|
// The root shelf has no flat `results` of its own (each row computes its own
|
||||||
|
// categories independently) - it's a genuine two-axis layout instead: `shelfRow`
|
||||||
|
// is which of the three rows is focused, `selIndex` which tile within that row,
|
||||||
|
// mirroring keyboard.ts's `moveRootShelf`. `selected` here only drives the outer
|
||||||
|
// scroller's keep-the-focused-row-on-screen effect below, so at the root it only
|
||||||
|
// needs the row half of that.
|
||||||
|
const focusedRow = Math.max(0, Math.min(GROUPS.length - 1, shelfRow));
|
||||||
|
const focusedCol = Math.max(0, selIndex);
|
||||||
|
const selected = isRootShelf
|
||||||
|
? focusedRow
|
||||||
|
: total
|
||||||
|
? Math.min(selIndex, total - 1)
|
||||||
|
: -1;
|
||||||
|
|
||||||
const shelves = useMemo(
|
const shelves = useMemo(
|
||||||
() =>
|
() =>
|
||||||
GROUPS.map((shelfGroup) => ({
|
GROUPS.map((shelfGroup) => ({
|
||||||
@@ -117,28 +138,52 @@ 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} style={{ flex: 1, overflow: "auto", minHeight: 0, padding: "14px 32px 250px" }}>
|
||||||
{shelves.map(({ group: shelfGroup, categories: shelfCategories }) => (
|
{shelves.map(({ group: shelfGroup, categories: shelfCategories }, index) => (
|
||||||
<div key={shelfGroup} style={{ marginBottom: 34 }}>
|
<div
|
||||||
<button
|
key={shelfGroup}
|
||||||
|
className="glass-panel"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
data-nav-index={index}
|
||||||
onClick={() => onEnterGroup(shelfGroup, null)}
|
onClick={() => onEnterGroup(shelfGroup, null)}
|
||||||
aria-label={`Alle ${GROUP_TITLE[shelfGroup]} durchsuchen`}
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
event.preventDefault();
|
||||||
|
onEnterGroup(shelfGroup, null);
|
||||||
|
}}
|
||||||
|
aria-label={`Alle ${GROUP_ICON[shelfGroup]} ${GROUP_LABEL[shelfGroup]} durchsuchen`}
|
||||||
|
style={{
|
||||||
|
marginBottom: ROW_SPACING,
|
||||||
|
padding: "12px 14px",
|
||||||
|
cursor: "pointer",
|
||||||
|
// Once a row has tiles, the focus ring belongs on the tile the keyboard
|
||||||
|
// is actually on (below) rather than the row around it - this only
|
||||||
|
// stands in for that when there's nothing to focus.
|
||||||
|
outline:
|
||||||
|
focusedRow === index && shelfCategories.length === 0
|
||||||
|
? "4px solid var(--paper)"
|
||||||
|
: undefined,
|
||||||
|
outlineOffset: 2,
|
||||||
|
...glassTint(GROUP_HUE[shelfGroup]),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(SHOW_ROW_TITLES || SHOW_ROW_ICONS) && (
|
||||||
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 6,
|
gap: SHOW_ROW_TITLES && SHOW_ROW_ICONS ? 10 : 6,
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
border: "none",
|
|
||||||
background: "none",
|
|
||||||
padding: 0,
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: 900,
|
fontWeight: 900,
|
||||||
color: "var(--paper)",
|
color: "var(--paper)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{GROUP_TITLE[shelfGroup]}
|
{SHOW_ROW_ICONS && <span className="group-icon">{GROUP_ICON[shelfGroup]}</span>}
|
||||||
<span style={{ opacity: 0.6, fontSize: 17 }}>›</span>
|
{SHOW_ROW_TITLES && GROUP_LABEL[shelfGroup]}
|
||||||
</button>
|
{SHOW_ROW_TITLES && <span style={{ opacity: 0.6, fontSize: 17 }}>›</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{shelfCategories.length === 0 ? (
|
{shelfCategories.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -151,12 +196,19 @@ export function BrowseView({
|
|||||||
Noch nichts hier
|
Noch nichts hier
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ShelfRow>
|
<ShelfRow focusedIndex={focusedRow === index ? focusedCol : null}>
|
||||||
{shelfCategories.map((entry) => (
|
{shelfCategories.map((entry, tileIndex) => (
|
||||||
<CategoryTile
|
<CategoryTile
|
||||||
key={entry.key}
|
key={entry.key}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
onClick={() => onEnterGroup(shelfGroup, entry.key)}
|
navIndex={tileIndex}
|
||||||
|
selected={focusedRow === index && focusedCol === tileIndex}
|
||||||
|
onClick={(event) => {
|
||||||
|
// Otherwise this bubbles to the panel's own onClick, which would
|
||||||
|
// open the group root instead of the category just clicked.
|
||||||
|
event.stopPropagation();
|
||||||
|
onEnterGroup(shelfGroup, entry.key);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ShelfRow>
|
</ShelfRow>
|
||||||
@@ -183,12 +235,12 @@ export function BrowseView({
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
padding: "4px 32px 6px",
|
padding: "4px 32px 6px",
|
||||||
flex: "none",
|
flex: "none",
|
||||||
fontSize: 18,
|
fontSize: 15,
|
||||||
fontWeight: 900,
|
fontWeight: 800,
|
||||||
color: "var(--paper)",
|
color: "var(--paper)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{GROUP_TITLE[group]}
|
{GROUP_LABEL[group]}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -250,7 +302,7 @@ export function BrowseView({
|
|||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: 8,
|
gap: 6,
|
||||||
maxWidth: 760,
|
maxWidth: 760,
|
||||||
margin: "0 auto",
|
margin: "0 auto",
|
||||||
}}
|
}}
|
||||||
@@ -260,19 +312,17 @@ export function BrowseView({
|
|||||||
key={`${hit.album.id}-${hit.index}`}
|
key={`${hit.album.id}-${hit.index}`}
|
||||||
data-nav-index={index}
|
data-nav-index={index}
|
||||||
onClick={() => onPlaySong(hit)}
|
onClick={() => onPlaySong(hit)}
|
||||||
|
className="list-row"
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 14,
|
gap: 14,
|
||||||
padding: "10px 16px",
|
padding: "8px 16px",
|
||||||
borderRadius: 14,
|
borderRadius: 12,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
border: "none",
|
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
font: "inherit",
|
font: "inherit",
|
||||||
background: `oklch(97% 0.01 210 / ${
|
...rowTint(GROUP_HUE[groupOf(hit.album)], currentAlbumId === hit.album.id),
|
||||||
currentAlbumId === hit.album.id ? ".22" : ".10"
|
|
||||||
})`,
|
|
||||||
outline:
|
outline:
|
||||||
selected === index ? "4px solid var(--paper)" : undefined,
|
selected === index ? "4px solid var(--paper)" : undefined,
|
||||||
outlineOffset: 2,
|
outlineOffset: 2,
|
||||||
@@ -485,9 +535,32 @@ export function BrowseView({
|
|||||||
* still scroll the page. Drag-to-scroll is plain mouse events on `window` rather than
|
* still scroll the page. Drag-to-scroll is plain mouse events on `window` rather than
|
||||||
* pointer capture, so a plain click still reaches the tile underneath - capturing the
|
* pointer capture, so a plain click still reaches the tile underneath - capturing the
|
||||||
* pointer on the row would retarget even a non-dragging click's events to the row. */
|
* pointer on the row would retarget even a non-dragging click's events to the row. */
|
||||||
function ShelfRow({ children }: { children: React.ReactNode }) {
|
function ShelfRow({
|
||||||
|
children,
|
||||||
|
focusedIndex,
|
||||||
|
}: {
|
||||||
|
children: React.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. */
|
||||||
|
focusedIndex: number | null;
|
||||||
|
}) {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = ref.current;
|
||||||
|
if (!element || focusedIndex === null) return;
|
||||||
|
const tile = element.querySelector<HTMLElement>(`[data-nav-index="${focusedIndex}"]`);
|
||||||
|
if (!tile) return;
|
||||||
|
const left = tile.offsetLeft;
|
||||||
|
const right = left + tile.offsetWidth;
|
||||||
|
const pad = 24;
|
||||||
|
if (left - pad < element.scrollLeft) element.scrollLeft = Math.max(0, left - pad);
|
||||||
|
else if (right + pad > element.scrollLeft + element.clientWidth) {
|
||||||
|
element.scrollLeft = right + pad - element.clientWidth;
|
||||||
|
}
|
||||||
|
}, [focusedIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const element = ref.current;
|
const element = ref.current;
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
@@ -552,7 +625,7 @@ function CategoryTile({
|
|||||||
entry: Category;
|
entry: Category;
|
||||||
navIndex?: number;
|
navIndex?: number;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
onClick: () => void;
|
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
}) {
|
}) {
|
||||||
const books = entry.albums.filter(isBook).length;
|
const books = entry.albums.filter(isBook).length;
|
||||||
const allBooks = books === entry.albums.length;
|
const allBooks = books === entry.albums.length;
|
||||||
@@ -565,7 +638,9 @@ function CategoryTile({
|
|||||||
data-selected={selected}
|
data-selected={selected}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
style={{
|
style={{
|
||||||
background: allBooks ? "oklch(93% 0.055 88 / .7)" : "oklch(95% 0.015 210 / .66)",
|
background: allBooks
|
||||||
|
? `oklch(92% 0.09 ${GROUP_HUE.audiobooks} / .7)`
|
||||||
|
: `oklch(95% 0.015 ${GROUP_HUE.music} / .66)`,
|
||||||
borderRadius: allBooks ? "6px 18px 18px 6px" : "16px",
|
borderRadius: allBooks ? "6px 18px 18px 6px" : "16px",
|
||||||
boxShadow: "0 6px 18px var(--shadow)",
|
boxShadow: "0 6px 18px var(--shadow)",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { remoteSlotView } from "../lib/remote";
|
|||||||
import { Cover } from "./Cover";
|
import { Cover } from "./Cover";
|
||||||
|
|
||||||
const DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];
|
const DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];
|
||||||
|
const ZERO_COLUMN = 2;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
album: Album;
|
album: Album;
|
||||||
@@ -44,14 +45,20 @@ export function RemoteAssignPopup({ album, mapping, albums, onAssign, onClear, o
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(5, 1fr)",
|
gridTemplateColumns: "repeat(3, 1fr)",
|
||||||
gap: 10,
|
gap: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{DIGITS.map((digit) => {
|
{DIGITS.map((digit) => {
|
||||||
const view = remoteSlotView(byDigit.get(digit), albums);
|
const view = remoteSlotView(byDigit.get(digit), albums);
|
||||||
return (
|
return (
|
||||||
<div key={digit} style={{ position: "relative" }}>
|
<div
|
||||||
|
key={digit}
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
...(digit === "0" ? { gridColumn: ZERO_COLUMN } : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => onAssign(digit)}
|
onClick={() => onAssign(digit)}
|
||||||
title={
|
title={
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ describe("ambienceBaseFor", () => {
|
|||||||
const hues = [musicHue, audiobook.fixed!.gradient[0].h, podcast.fixed!.gradient[0].h];
|
const hues = [musicHue, audiobook.fixed!.gradient[0].h, podcast.fixed!.gradient[0].h];
|
||||||
expect(new Set(hues).size).toBe(3); // all three distinct
|
expect(new Set(hues).size).toBe(3); // all three distinct
|
||||||
expect(musicHue).toBe(210);
|
expect(musicHue).toBe(210);
|
||||||
expect(audiobook.fixed!.gradient[0].h).toBe(80);
|
expect(audiobook.fixed!.gradient[0].h).toBe(55);
|
||||||
expect(podcast.fixed!.gradient[0].h).toBe(300);
|
expect(podcast.fixed!.gradient[0].h).toBe(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -48,8 +48,18 @@ const resultsFor = (ui: UiState) =>
|
|||||||
category: ui.category,
|
category: ui.category,
|
||||||
});
|
});
|
||||||
|
|
||||||
const press = (k: string, ui: UiState = initialUiState, over = {}, openAlbumTrackCount = 0) =>
|
// The three root shelves' own category keys for ALBUMS above, in the same
|
||||||
handleKey(key(k, over), ui, resultsFor(ui), openAlbumTrackCount);
|
// alphabetical order categoryMatches produces: music has two (Kinderparty, Rolf),
|
||||||
|
// audiobooks one (Conni), podcasts none.
|
||||||
|
const ROOT_SHELVES: string[][] = [["Kinderparty", "Rolf"], ["Conni"], []];
|
||||||
|
|
||||||
|
const press = (
|
||||||
|
k: string,
|
||||||
|
ui: UiState = initialUiState,
|
||||||
|
over = {},
|
||||||
|
openAlbumTrackCount = 0,
|
||||||
|
rootShelves: string[][] = ROOT_SHELVES,
|
||||||
|
) => handleKey(key(k, over), ui, resultsFor(ui), openAlbumTrackCount, rootShelves);
|
||||||
|
|
||||||
describe("normalize", () => {
|
describe("normalize", () => {
|
||||||
it("strips case, punctuation and diacritics so a child's letters match", () => {
|
it("strips case, punctuation and diacritics so a child's letters match", () => {
|
||||||
@@ -130,6 +140,57 @@ describe("keyboard", () => {
|
|||||||
expect(press("l", { ...initialUiState, view: "play" }, { ctrlKey: true })).toEqual([]);
|
expect(press("l", { ...initialUiState, view: "play" }, { ctrlKey: true })).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("steps tiles within a row with Ctrl+h/l and rows with Ctrl+j/k, without opening any of them", () => {
|
||||||
|
// Row 0 (music) has two tiles: Kinderparty at column 0, Rolf at column 1.
|
||||||
|
expect(press("l", initialUiState, { ctrlKey: true })).toEqual([
|
||||||
|
{ type: "ui", patch: { shelfRow: 0, selIndex: 1 } },
|
||||||
|
]);
|
||||||
|
const onCol1 = { ...initialUiState, selIndex: 1 };
|
||||||
|
expect(press("h", onCol1, { ctrlKey: true })).toEqual([
|
||||||
|
{ type: "ui", patch: { shelfRow: 0, selIndex: 0 } },
|
||||||
|
]);
|
||||||
|
// Clamped at the row's own end rather than wrapping or spilling into the next row.
|
||||||
|
expect(press("l", onCol1, { ctrlKey: true })).toEqual([
|
||||||
|
{ type: "ui", patch: { shelfRow: 0, selIndex: 1 } },
|
||||||
|
]);
|
||||||
|
// Dropping a row (Ctrl+j) into audiobooks - one tile - clamps the column down with it.
|
||||||
|
expect(press("j", onCol1, { ctrlKey: true })).toEqual([
|
||||||
|
{ type: "ui", patch: { shelfRow: 1, selIndex: 0 } },
|
||||||
|
]);
|
||||||
|
// Off the top edge stays put rather than wrapping.
|
||||||
|
expect(press("k", initialUiState, { ctrlKey: true })).toEqual([
|
||||||
|
{ type: "ui", patch: { shelfRow: 0, selIndex: 0 } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches Ctrl+hjkl's root-shelf stepping with the arrow keys", () => {
|
||||||
|
expect(press("l", initialUiState, { ctrlKey: true })).toEqual(
|
||||||
|
press("ArrowRight", initialUiState),
|
||||||
|
);
|
||||||
|
expect(press("j", initialUiState, { ctrlKey: true })).toEqual(
|
||||||
|
press("ArrowDown", initialUiState),
|
||||||
|
);
|
||||||
|
const onCol1 = { ...initialUiState, selIndex: 1 };
|
||||||
|
expect(press("h", onCol1, { ctrlKey: true })).toEqual(press("ArrowLeft", onCol1));
|
||||||
|
expect(press("k", onCol1, { ctrlKey: true })).toEqual(press("ArrowUp", onCol1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the focused tile's category with ENTER at the bare root", () => {
|
||||||
|
const onAudiobooksRow = { ...initialUiState, shelfRow: 1, selIndex: 0 };
|
||||||
|
expect(press("Enter", onAudiobooksRow)).toEqual([
|
||||||
|
{ type: "pop", freq: 440 },
|
||||||
|
{ type: "ui", patch: { group: "audiobooks", category: "Conni", selIndex: 0 } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the bare group with ENTER on a row with nothing in it", () => {
|
||||||
|
const onPodcastsRow = { ...initialUiState, shelfRow: 2, selIndex: 0 };
|
||||||
|
expect(press("Enter", onPodcastsRow)).toEqual([
|
||||||
|
{ type: "pop", freq: 380 },
|
||||||
|
{ type: "ui", patch: { group: "podcasts", category: null, selIndex: 0 } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("controls transport and volume from anywhere with Shift+h/j/k/l/m", () => {
|
it("controls transport and volume from anywhere with Shift+h/j/k/l/m", () => {
|
||||||
expect(press("H", initialUiState, { shiftKey: true })).toEqual([{ type: "previous" }]);
|
expect(press("H", initialUiState, { shiftKey: true })).toEqual([{ type: "previous" }]);
|
||||||
expect(press("L", initialUiState, { shiftKey: true })).toEqual([{ type: "next" }]);
|
expect(press("L", initialUiState, { shiftKey: true })).toEqual([{ type: "next" }]);
|
||||||
@@ -159,11 +220,9 @@ describe("keyboard", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("jumps into the first group with arrows at the bare root", () => {
|
it("moves within a row with left/right and between rows with up/down at the bare root, rather than opening one", () => {
|
||||||
expect(press("ArrowRight")).toEqual([
|
expect(press("ArrowRight")).toEqual([{ type: "ui", patch: { shelfRow: 0, selIndex: 1 } }]);
|
||||||
{ type: "pop", freq: 380 },
|
expect(press("ArrowDown")).toEqual([{ type: "ui", patch: { shelfRow: 1, selIndex: 0 } }]);
|
||||||
{ type: "ui", patch: { group: "music", selIndex: 0 } },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives arrows to navigation once inside a group, and to transport while playing", () => {
|
it("gives arrows to navigation once inside a group, and to transport while playing", () => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import type { Album, TrackAnalysis, TrackCurves, TrackDetail } from "../api/types";
|
import type { Album, TrackAnalysis, TrackCurves, TrackDetail } from "../api/types";
|
||||||
import type { AmbienceTunables } from "./ambienceTunables";
|
import type { AmbienceTunables } from "./ambienceTunables";
|
||||||
import { groupOf } from "./search";
|
import { groupOf } from "./search";
|
||||||
|
import { GROUP_HUE } from "./theme";
|
||||||
|
|
||||||
export interface Oklch {
|
export interface Oklch {
|
||||||
/** 0..100, percent lightness. */
|
/** 0..100, percent lightness. */
|
||||||
@@ -42,14 +43,6 @@ export function oklchString(color: Oklch, alpha = 1): string {
|
|||||||
const clamp01 = (value: number): number => Math.max(0, Math.min(1, value));
|
const clamp01 = (value: number): number => Math.max(0, Math.min(1, value));
|
||||||
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
|
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
|
||||||
|
|
||||||
//: Today's sea, unchanged - the Dolphin Beats identity, and music's baseline hue.
|
|
||||||
const MUSIC_HUE = 210;
|
|
||||||
//: Matches the book-paper tint `cardBackground` already uses for audiobooks
|
|
||||||
//: (`oklch(93% 0.055 88)` in `lib/covers.ts`).
|
|
||||||
const AUDIOBOOK_HUE = 80;
|
|
||||||
//: The one unused family, so all three shelves are instantly distinguishable at a glance.
|
|
||||||
const PODCAST_HUE = 300;
|
|
||||||
|
|
||||||
const BASE_CHROMA = 0.07;
|
const BASE_CHROMA = 0.07;
|
||||||
const BASE_TOP_LIGHTNESS = 55;
|
const BASE_TOP_LIGHTNESS = 55;
|
||||||
const DEEP_LIGHTNESS = 20;
|
const DEEP_LIGHTNESS = 20;
|
||||||
@@ -73,7 +66,7 @@ const MUSIC_SPAWN_ENERGY_SWING = 2.6;
|
|||||||
//: Audiobooks and podcasts: a slow ambient drift, not a field.
|
//: Audiobooks and podcasts: a slow ambient drift, not a field.
|
||||||
const AMBIENT_SPAWN_RATE = 0.5;
|
const AMBIENT_SPAWN_RATE = 0.5;
|
||||||
|
|
||||||
const BUBBLE_TINT: Oklch = { l: 90, c: 0.02, h: MUSIC_HUE };
|
const BUBBLE_TINT: Oklch = { l: 90, c: 0.02, h: GROUP_HUE.music };
|
||||||
//: Tint chroma tracks gradient chroma proportionally, so a result with every scalar
|
//: Tint chroma tracks gradient chroma proportionally, so a result with every scalar
|
||||||
//: `null` (the un-analyzed baseline, or a track the analyzer failed on) computes back
|
//: `null` (the un-analyzed baseline, or a track the analyzer failed on) computes back
|
||||||
//: to exactly `BUBBLE_TINT.c` rather than a near-miss from an independent constant.
|
//: to exactly `BUBBLE_TINT.c` rather than a near-miss from an independent constant.
|
||||||
@@ -109,7 +102,7 @@ export function tempoToRise(tempo: number | null): number {
|
|||||||
|
|
||||||
/** Nothing loaded: today's sea, no bubbles. */
|
/** Nothing loaded: today's sea, no bubbles. */
|
||||||
export const IDLE_AMBIENCE: Ambience = {
|
export const IDLE_AMBIENCE: Ambience = {
|
||||||
gradient: gradientForHue(MUSIC_HUE, BASE_CHROMA, BASE_TOP_LIGHTNESS),
|
gradient: gradientForHue(GROUP_HUE.music, BASE_CHROMA, BASE_TOP_LIGHTNESS),
|
||||||
tint: BUBBLE_TINT,
|
tint: BUBBLE_TINT,
|
||||||
spawnRate: 0,
|
spawnRate: 0,
|
||||||
rise: 1,
|
rise: 1,
|
||||||
@@ -128,8 +121,8 @@ function fixedAmbience(hue: number): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUDIOBOOK_FIXED = fixedAmbience(AUDIOBOOK_HUE);
|
const AUDIOBOOK_FIXED = fixedAmbience(GROUP_HUE.audiobooks);
|
||||||
const PODCAST_FIXED = fixedAmbience(PODCAST_HUE);
|
const PODCAST_FIXED = fixedAmbience(GROUP_HUE.podcasts);
|
||||||
|
|
||||||
/** The per-track constant part of the picture: group palette (non-music), and
|
/** The per-track constant part of the picture: group palette (non-music), and
|
||||||
* tempo-derived rise/current-magnitude bases (music). Computed once on track change -
|
* tempo-derived rise/current-magnitude bases (music). Computed once on track change -
|
||||||
@@ -181,9 +174,9 @@ export function ambienceBaseFor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
//: valence 0 -> dark blue-violet, valence 1 -> bright green-teal, centred on
|
//: valence 0 -> dark blue-violet, valence 1 -> bright green-teal, centred on
|
||||||
//: MUSIC_HUE - a narrower swing than the pre-refactor 85 degrees, since valence is
|
//: GROUP_HUE.music - a narrower swing than the pre-refactor 85 degrees, since valence
|
||||||
//: now a secondary nudge rather than the primary colour driver. Clamped implicitly by
|
//: is now a secondary nudge rather than the primary colour driver. Clamped implicitly
|
||||||
//: its own small amplitude: at any valence 0..1 the result stays well inside a
|
//: by its own small amplitude: at any valence 0..1 the result stays well inside a
|
||||||
//: water-plausible range, never drifting toward orange.
|
//: water-plausible range, never drifting toward orange.
|
||||||
const VALENCE_HUE_NUDGE = 24;
|
const VALENCE_HUE_NUDGE = 24;
|
||||||
//: Energy's swing on chroma/lightness - wide, so the effect reads as "noticeable" per
|
//: Energy's swing on chroma/lightness - wide, so the effect reads as "noticeable" per
|
||||||
@@ -200,7 +193,7 @@ export function ambienceColorAt(
|
|||||||
valence: number,
|
valence: number,
|
||||||
tunables: AmbienceTunables,
|
tunables: AmbienceTunables,
|
||||||
): { gradient: [Oklch, Oklch, Oklch]; tint: Oklch } {
|
): { gradient: [Oklch, Oklch, Oklch]; tint: Oklch } {
|
||||||
const hue = MUSIC_HUE - VALENCE_HUE_NUDGE * (clamp01(valence) - 0.5);
|
const hue = GROUP_HUE.music - VALENCE_HUE_NUDGE * (clamp01(valence) - 0.5);
|
||||||
const gain = tunables.energyColorGain;
|
const gain = tunables.energyColorGain;
|
||||||
const energyDelta = clamp01(energy) - 0.5;
|
const energyDelta = clamp01(energy) - 0.5;
|
||||||
const chroma = Math.max(0, BASE_CHROMA + CHROMA_ENERGY_SWING * gain * energyDelta);
|
const chroma = Math.max(0, BASE_CHROMA + CHROMA_ENERGY_SWING * gain * energyDelta);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Album, AlbumKind } from "../api/types";
|
import type { Album, AlbumKind } from "../api/types";
|
||||||
|
import { GROUP_HUE } from "./theme";
|
||||||
|
|
||||||
export const isBook = (album: Album): boolean => album.kind === "book";
|
export const isBook = (album: Album): boolean => album.kind === "book";
|
||||||
|
|
||||||
@@ -39,9 +40,10 @@ export function stripes(album: Album, width: number): string {
|
|||||||
/** A book spine: page edges on the right, a darker board on the left. */
|
/** A book spine: page edges on the right, a darker board on the left. */
|
||||||
export function spine(album: Album): string {
|
export function spine(album: Album): string {
|
||||||
const [primary, secondary] = colours(album);
|
const [primary, secondary] = colours(album);
|
||||||
|
const hue = GROUP_HUE.audiobooks;
|
||||||
return [
|
return [
|
||||||
"linear-gradient(90deg, transparent 0 95%, oklch(97% 0.02 88) 95% 97.5%," +
|
`linear-gradient(90deg, transparent 0 95%, oklch(97% 0.02 ${hue}) 95% 97.5%,` +
|
||||||
" oklch(90% 0.03 88) 97.5% 100%)",
|
` oklch(90% 0.03 ${hue}) 97.5% 100%)`,
|
||||||
"linear-gradient(90deg, transparent 0 3.5%, oklch(98% 0 0 / .35) 3.5% 4.3%," +
|
"linear-gradient(90deg, transparent 0 3.5%, oklch(98% 0 0 / .35) 3.5% 4.3%," +
|
||||||
" transparent 4.3% 7%, oklch(98% 0 0 / .35) 7% 7.8%, transparent 7.8%)",
|
" transparent 4.3% 7%, oklch(98% 0 0 / .35) 7% 7.8%, transparent 7.8%)",
|
||||||
`linear-gradient(90deg, ${secondary} 0 11%, ${primary} 11% 13%, transparent 13%)`,
|
`linear-gradient(90deg, ${secondary} 0 11%, ${primary} 11% 13%, transparent 13%)`,
|
||||||
@@ -53,15 +55,20 @@ export function coverBackground(album: Album, stripeWidth: number): string {
|
|||||||
return isBook(album) ? spine(album) : stripes(album, stripeWidth);
|
return isBook(album) ? spine(album) : stripes(album, stripeWidth);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The card's own tint: warm paper for books, cool glass for music. */
|
/** The card's own tint: warm paper for books, cool glass for music. Amber
|
||||||
|
* (`GROUP_HUE.audiobooks`) rather than a duller yellow-brown, so it reads as rich
|
||||||
|
* rather than washed-out. */
|
||||||
export const cardBackground = (album: Album): string =>
|
export const cardBackground = (album: Album): string =>
|
||||||
isBook(album) ? "oklch(93% 0.055 88 / .7)" : "oklch(95% 0.015 210 / .66)";
|
isBook(album)
|
||||||
|
? `oklch(92% 0.09 ${GROUP_HUE.audiobooks} / .7)`
|
||||||
|
: `oklch(95% 0.015 ${GROUP_HUE.music} / .66)`;
|
||||||
|
|
||||||
export const cardShadow = (album: Album): string =>
|
export const cardShadow = (album: Album): string =>
|
||||||
isBook(album)
|
isBook(album)
|
||||||
? "inset -7px 0 0 oklch(88% 0.06 88 / .7), inset -11px 0 0 oklch(82% 0.06 88 / .7)," +
|
? `inset -7px 0 0 oklch(86% 0.09 ${GROUP_HUE.audiobooks} / .7), ` +
|
||||||
" 0 6px 18px oklch(15% 0.05 210 / .35)"
|
`inset -11px 0 0 oklch(78% 0.09 ${GROUP_HUE.audiobooks} / .7), ` +
|
||||||
: "0 6px 18px oklch(15% 0.05 210 / .35)";
|
`0 6px 18px oklch(15% 0.05 ${GROUP_HUE.music} / .35)`
|
||||||
|
: `0 6px 18px oklch(15% 0.05 ${GROUP_HUE.music} / .35)`;
|
||||||
|
|
||||||
export const unitLabel = (album: Album, count: number): string =>
|
export const unitLabel = (album: Album, count: number): string =>
|
||||||
isBook(album)
|
isBook(album)
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export interface UiState {
|
|||||||
group: Group | null;
|
group: Group | null;
|
||||||
category: string | null;
|
category: string | null;
|
||||||
selIndex: number;
|
selIndex: number;
|
||||||
|
/** Which of the three root shelves (music/audiobooks/podcasts) is focused. Only
|
||||||
|
* meaningful at the bare root screen - `selIndex` doubles up there as "which tile in
|
||||||
|
* that row", the way it means "which flat position" everywhere else. */
|
||||||
|
shelfRow: number;
|
||||||
/** Which top-level page is showing. Orthogonal to `view`/`search`/`group`/… below,
|
/** Which top-level page is showing. Orthogonal to `view`/`search`/`group`/… below,
|
||||||
* so toggling to the room and back leaves the music side exactly as it was. */
|
* so toggling to the room and back leaves the music side exactly as it was. */
|
||||||
page: "music" | "room";
|
page: "music" | "room";
|
||||||
@@ -41,6 +45,7 @@ export const initialUiState: UiState = {
|
|||||||
group: null,
|
group: null,
|
||||||
category: null,
|
category: null,
|
||||||
selIndex: 0,
|
selIndex: 0,
|
||||||
|
shelfRow: 0,
|
||||||
page: "music",
|
page: "music",
|
||||||
view: "browse",
|
view: "browse",
|
||||||
openAlbumId: null,
|
openAlbumId: null,
|
||||||
@@ -73,7 +78,7 @@ function typeIntoSearch(state: UiState, key: string): Action[] {
|
|||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const GROUP_ORDER: Group[] = ["music", "audiobooks", "podcasts"];
|
export const GROUP_ORDER: Group[] = ["music", "audiobooks", "podcasts"];
|
||||||
|
|
||||||
export const VOLUME_STEP = 10;
|
export const VOLUME_STEP = 10;
|
||||||
export const SEEK_STEP = 15;
|
export const SEEK_STEP = 15;
|
||||||
@@ -133,6 +138,27 @@ export function selectionAlbumAt(results: Results, selIndex: number): Album | nu
|
|||||||
return album && groupOf(album) !== "podcasts" ? album : null;
|
return album && groupOf(album) !== "podcasts" ? album : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The bare root (three shelves) has no flat selection for `moveSelection` to move -
|
||||||
|
* `results` is empty there, since each shelf computes its own categories independently.
|
||||||
|
* It's a genuine two-axis layout instead: three rows (tracked by `shelfRow`), each a
|
||||||
|
* differently-long row of category tiles (tracked by `selIndex`, doubling up as "which
|
||||||
|
* tile in the focused row" the way it means "which flat position" everywhere else).
|
||||||
|
* `rootShelves[row]` is that row's tile keys in display order - the same data
|
||||||
|
* BrowseView renders - so a vertical move can clamp the column to how long the row it
|
||||||
|
* lands on actually is, rather than guessing. */
|
||||||
|
function moveRootShelf(state: UiState, dx: number, dy: number, rootShelves: string[][]): Action[] {
|
||||||
|
let row = state.shelfRow;
|
||||||
|
let col = state.selIndex;
|
||||||
|
if (dy) {
|
||||||
|
row = Math.max(0, Math.min(GROUP_ORDER.length - 1, row + dy));
|
||||||
|
col = Math.min(col, Math.max(0, (rootShelves[row]?.length ?? 0) - 1));
|
||||||
|
}
|
||||||
|
if (dx) {
|
||||||
|
col = Math.max(0, Math.min((rootShelves[row]?.length ?? 0) - 1, col + dx));
|
||||||
|
}
|
||||||
|
return [{ type: "ui", patch: { shelfRow: row, selIndex: col } }];
|
||||||
|
}
|
||||||
|
|
||||||
function moveSelection(state: UiState, results: Results, dx: number, dy: number): Action[] {
|
function moveSelection(state: UiState, results: Results, dx: number, dy: number): Action[] {
|
||||||
if (!results.total) return [];
|
if (!results.total) return [];
|
||||||
const cols = Math.max(1, state.cols);
|
const cols = Math.max(1, state.cols);
|
||||||
@@ -193,13 +219,16 @@ export interface KeyEvent {
|
|||||||
*
|
*
|
||||||
* `openAlbumTrackCount` is the track count of the open album (when `openAlbumId` is
|
* `openAlbumTrackCount` is the track count of the open album (when `openAlbumId` is
|
||||||
* set) - the one piece of data Ctrl+j/k needs to clamp the track-list highlight that
|
* set) - the one piece of data Ctrl+j/k needs to clamp the track-list highlight that
|
||||||
* `results` doesn't otherwise carry.
|
* `results` doesn't otherwise carry. `rootShelves` is the equivalent for the bare root
|
||||||
|
* screen: each of the three rows' category keys, in display order, for `moveRootShelf`
|
||||||
|
* to clamp against and ENTER to open.
|
||||||
*/
|
*/
|
||||||
export function handleKey(
|
export function handleKey(
|
||||||
event: KeyEvent,
|
event: KeyEvent,
|
||||||
state: UiState,
|
state: UiState,
|
||||||
results: Results,
|
results: Results,
|
||||||
openAlbumTrackCount = 0,
|
openAlbumTrackCount = 0,
|
||||||
|
rootShelves: string[][] = [],
|
||||||
): Action[] {
|
): Action[] {
|
||||||
const { key } = event;
|
const { key } = event;
|
||||||
|
|
||||||
@@ -257,7 +286,25 @@ export function handleKey(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (state.view !== "browse" || state.showHelp) return [];
|
if (state.view !== "browse" || state.showHelp) return [];
|
||||||
switch (key.toLowerCase()) {
|
const lower = key.toLowerCase();
|
||||||
|
if (!"hjkldu".includes(lower)) return [];
|
||||||
|
if (isRootShelf(state)) {
|
||||||
|
switch (lower) {
|
||||||
|
case "h":
|
||||||
|
return moveRootShelf(state, -1, 0, rootShelves);
|
||||||
|
case "l":
|
||||||
|
return moveRootShelf(state, 1, 0, rootShelves);
|
||||||
|
case "j":
|
||||||
|
case "d":
|
||||||
|
return moveRootShelf(state, 0, 1, rootShelves);
|
||||||
|
case "k":
|
||||||
|
case "u":
|
||||||
|
return moveRootShelf(state, 0, -1, rootShelves);
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (lower) {
|
||||||
case "h":
|
case "h":
|
||||||
return moveSelection(state, results, -1, 0);
|
return moveSelection(state, results, -1, 0);
|
||||||
case "l":
|
case "l":
|
||||||
@@ -291,27 +338,19 @@ export function handleKey(
|
|||||||
|
|
||||||
case "ArrowRight":
|
case "ArrowRight":
|
||||||
if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }];
|
if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }];
|
||||||
if (browsing && isRootShelf(state)) {
|
if (browsing && isRootShelf(state)) return moveRootShelf(state, 1, 0, rootShelves);
|
||||||
return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
|
|
||||||
}
|
|
||||||
return browsing ? moveSelection(state, results, 1, 0) : [{ type: "next" }];
|
return browsing ? moveSelection(state, results, 1, 0) : [{ type: "next" }];
|
||||||
case "ArrowLeft":
|
case "ArrowLeft":
|
||||||
if (event.shiftKey) return [{ type: "seek", delta: -SEEK_STEP }];
|
if (event.shiftKey) return [{ type: "seek", delta: -SEEK_STEP }];
|
||||||
if (browsing && isRootShelf(state)) {
|
if (browsing && isRootShelf(state)) return moveRootShelf(state, -1, 0, rootShelves);
|
||||||
return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
|
|
||||||
}
|
|
||||||
return browsing ? moveSelection(state, results, -1, 0) : [{ type: "previous" }];
|
return browsing ? moveSelection(state, results, -1, 0) : [{ type: "previous" }];
|
||||||
case "ArrowDown":
|
case "ArrowDown":
|
||||||
if (browsing && isRootShelf(state)) {
|
if (browsing && isRootShelf(state)) return moveRootShelf(state, 0, 1, rootShelves);
|
||||||
return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
|
|
||||||
}
|
|
||||||
return browsing
|
return browsing
|
||||||
? moveSelection(state, results, 0, 1)
|
? moveSelection(state, results, 0, 1)
|
||||||
: [{ type: "volume", delta: -VOLUME_STEP }];
|
: [{ type: "volume", delta: -VOLUME_STEP }];
|
||||||
case "ArrowUp":
|
case "ArrowUp":
|
||||||
if (browsing && isRootShelf(state)) {
|
if (browsing && isRootShelf(state)) return moveRootShelf(state, 0, -1, rootShelves);
|
||||||
return [{ type: "pop", freq: 380 }, { type: "ui", patch: { group: GROUP_ORDER[0], selIndex: 0 } }];
|
|
||||||
}
|
|
||||||
return browsing
|
return browsing
|
||||||
? moveSelection(state, results, 0, -1)
|
? moveSelection(state, results, 0, -1)
|
||||||
: [{ type: "volume", delta: VOLUME_STEP }];
|
: [{ type: "volume", delta: VOLUME_STEP }];
|
||||||
@@ -336,6 +375,15 @@ export function handleKey(
|
|||||||
if (state.openAlbumId !== null) {
|
if (state.openAlbumId !== null) {
|
||||||
return [{ type: "play", albumId: state.openAlbumId, trackIndex: state.modalTrackIndex }];
|
return [{ type: "play", albumId: state.openAlbumId, trackIndex: state.modalTrackIndex }];
|
||||||
}
|
}
|
||||||
|
if (state.view === "browse" && isRootShelf(state)) {
|
||||||
|
const row = Math.max(0, Math.min(GROUP_ORDER.length - 1, state.shelfRow));
|
||||||
|
const group = GROUP_ORDER[row]!;
|
||||||
|
const category = rootShelves[row]?.[state.selIndex] ?? null;
|
||||||
|
return [
|
||||||
|
{ type: "pop", freq: category ? 440 : 380 },
|
||||||
|
{ type: "ui", patch: { group, category, selIndex: 0 } },
|
||||||
|
];
|
||||||
|
}
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
const album = selectionAlbumAt(results, state.selIndex);
|
const album = selectionAlbumAt(results, state.selIndex);
|
||||||
if (album) return [{ type: "ui", patch: { openAlbumId: album.id, modalTrackIndex: 0 } }];
|
if (album) return [{ type: "ui", patch: { openAlbumId: album.id, modalTrackIndex: 0 } }];
|
||||||
|
|||||||
70
web/src/lib/theme.ts
Normal file
70
web/src/lib/theme.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/** 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.
|
||||||
|
*
|
||||||
|
* `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 { Group } from "./search";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- toggles --
|
||||||
|
|
||||||
|
/** Tint each shelf panel and its rows with the group's hue below. */
|
||||||
|
export const SHOW_ROW_TINT = false;
|
||||||
|
|
||||||
|
/** Show each shelf's title text ("Musik", "Hörbücher", "Podcasts") above its tiles. */
|
||||||
|
export const SHOW_ROW_TITLES = true;
|
||||||
|
|
||||||
|
/** Show the small icon badge (🎵/📖/🎙️) beside the title. Stands alone fine with
|
||||||
|
* `SHOW_ROW_TITLES` off (an icon-only header), but the original look needs both. */
|
||||||
|
export const SHOW_ROW_ICONS = true;
|
||||||
|
|
||||||
|
/** Vertical gap between shelves on the root browse screen, in px. */
|
||||||
|
export const ROW_SPACING = 25;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ colors --
|
||||||
|
|
||||||
|
/** Per-group hue (oklch degrees). The play view's ambient water uses the same
|
||||||
|
* family (`ambienceBaseFor` in `lib/ambience.ts`), so a tinted shelf already hints
|
||||||
|
* at the mood its albums play into - and so does the book/music card tint in
|
||||||
|
* `lib/covers.ts`, which reads `GROUP_HUE.audiobooks`/`GROUP_HUE.music` too. */
|
||||||
|
export const GROUP_HUE: Record<Group, number> = {
|
||||||
|
music: 210, // today's sea - Dolphin Beats' baseline hue
|
||||||
|
audiobooks: 55, // warm amber - richer than the old shared hue (80)'s dull brown
|
||||||
|
podcasts: 300, // the one otherwise-unused family
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GROUP_ICON: Record<Group, string> = {
|
||||||
|
music: "🎵",
|
||||||
|
audiobooks: "📖",
|
||||||
|
podcasts: "🎙️",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GROUP_LABEL: Record<Group, string> = {
|
||||||
|
music: "Musik",
|
||||||
|
audiobooks: "Hörbücher",
|
||||||
|
podcasts: "Podcasts",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
if (!SHOW_ROW_TINT) return {};
|
||||||
|
return {
|
||||||
|
background: `linear-gradient(160deg, oklch(55% 0.1 ${hue} / .32), oklch(30% 0.06 ${hue} / .14))`,
|
||||||
|
borderColor: `oklch(75% 0.09 ${hue} / .4)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
if (!SHOW_ROW_TINT) {
|
||||||
|
return { background: `oklch(97% 0.01 210 / ${highlighted ? ".22" : ".10"})` };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
background: `oklch(${highlighted ? "62% 0.12" : "42% 0.08"} ${hue} / ${highlighted ? ".34" : ".2"})`,
|
||||||
|
borderColor: `oklch(78% 0.09 ${hue} / ${highlighted ? ".5" : ".28"})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -165,9 +165,13 @@ button {
|
|||||||
|
|
||||||
.shelf-row {
|
.shelf-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 12px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding: 4px 4px 16px;
|
/* Room for a focused card's outline (5px + 3px offset) plus its -3px lift, on every
|
||||||
|
side - otherwise the row's own scroll clipping (overflow-x: auto computes
|
||||||
|
overflow-y to auto too, not visible) cuts the ring off on whichever card is
|
||||||
|
focused. */
|
||||||
|
padding: 14px 10px 14px;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none; /* Firefox */
|
||||||
-ms-overflow-style: none; /* old Edge */
|
-ms-overflow-style: none; /* old Edge */
|
||||||
@@ -182,7 +186,54 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.shelf-row .card {
|
.shelf-row .card {
|
||||||
flex: 0 0 180px;
|
flex: 0 0 148px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The emoji glyph used as a group icon (🎵/📖/🎙️) renders thin and low-contrast
|
||||||
|
sitting directly on the stage gradient. A round frosted badge behind it gives it
|
||||||
|
the same visual weight as the bold label text next to it. Only shown when
|
||||||
|
SHOW_ROW_ICONS (`lib/theme.ts`) is on. */
|
||||||
|
.group-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 17px;
|
||||||
|
background: linear-gradient(160deg, oklch(97% 0.01 210 / 0.38), oklch(97% 0.01 210 / 0.12));
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid oklch(97% 0.01 210 / 0.4);
|
||||||
|
box-shadow:
|
||||||
|
0 3px 10px var(--shadow),
|
||||||
|
inset 0 1px 0 oklch(100% 0 0 / 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A frosted panel wrapped around a shelf/list of rows so they read as a distinct
|
||||||
|
surface floating over the stage gradient, rather than flat text on a gradient. */
|
||||||
|
.glass-panel {
|
||||||
|
background: linear-gradient(160deg, oklch(97% 0.01 210 / 0.14), oklch(97% 0.01 210 / 0.05));
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid oklch(97% 0.01 210 / 0.16);
|
||||||
|
border-radius: 22px;
|
||||||
|
box-shadow:
|
||||||
|
0 8px 24px var(--shadow),
|
||||||
|
inset 0 1px 0 oklch(100% 0 0 / 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One row inside a glass list (song/track rows). Layered on top of the row's own
|
||||||
|
selection-state background so it keeps its own tint while still reading as glass. */
|
||||||
|
.list-row {
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid oklch(97% 0.01 210 / 0.16);
|
||||||
|
box-shadow:
|
||||||
|
0 4px 14px var(--shadow),
|
||||||
|
inset 0 1px 0 oklch(100% 0 0 / 0.12);
|
||||||
|
transition: transform 0.1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.round {
|
.round {
|
||||||
|
|||||||
Reference in New Issue
Block a user