Web frontend: hide app title and row titles, regroup help sheet, drop Ctrl+d/u
- New SHOW_APP_TITLE switch (off): the mascot + title header goes, the status pill stays on a slim row of its own. - SHOW_ROW_TITLES off; with only icons, the shelf icon moves into a column left of the tiles so an icon-only shelf is no taller than one without. - Help sheet regrouped into sections in a compact two-column grid, and reachable from a small keyboard button (not on phones or the typing page). - Ctrl+d/Ctrl+u half-page jumps removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -663,6 +663,31 @@ export function App() {
|
||||
)}
|
||||
{PERF_ENABLED && perfOpen && <PerfPanel onClose={() => setPerfOpen(false)} />}
|
||||
|
||||
{/* The F1 sheet, reachable without knowing about F1. Not on a phone (no keyboard)
|
||||
or the typing page (TippenApp has its own help and owns every key there). */}
|
||||
{!phone && ui.page !== "typing" && !ui.showHelp && (
|
||||
<button
|
||||
onClick={() => setUi((previous) => ({ ...previous, showHelp: true }))}
|
||||
aria-label="Tastenkürzel"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: PERF_ENABLED && !perfOpen ? 48 : 8,
|
||||
bottom: 8,
|
||||
zIndex: 5,
|
||||
width: 32,
|
||||
height: 32,
|
||||
border: "none",
|
||||
borderRadius: 999,
|
||||
cursor: "pointer",
|
||||
background: "oklch(15% 0.03 210 / .35)",
|
||||
color: "#fff",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
⌨
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(!state || library.loading) && <Splash error={library.error} />}
|
||||
|
||||
{debugDynamicUI && debugSnapshot && (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { SHOW_APP_TITLE } from "../lib/theme";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
mascot?: string;
|
||||
@@ -15,6 +17,25 @@ export function AppHeader({
|
||||
status,
|
||||
compact = false,
|
||||
}: Props) {
|
||||
if (!SHOW_APP_TITLE) {
|
||||
// Still clear the translucent standalone-mode status bar, and keep the status
|
||||
// pill - it is the only place an offline connection shows on these screens.
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
padding: status
|
||||
? `calc(${compact ? 8 : 10}px + env(safe-area-inset-top)) 16px 0`
|
||||
: "env(safe-area-inset-top) 0 0",
|
||||
flex: "none",
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mascotSize = compact ? 44 : 64;
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -225,15 +225,20 @@ function BrowseViewImpl({
|
||||
? "4px solid var(--paper)"
|
||||
: undefined,
|
||||
outlineOffset: 2,
|
||||
// Without a title the icon has no header to sit in, so it takes a column
|
||||
// of its own beside the tiles rather than a row above them.
|
||||
...(SHOW_ROW_ICONS && !SHOW_ROW_TITLES
|
||||
? { display: "flex", alignItems: "center", gap: 12 }
|
||||
: {}),
|
||||
...glassTint(GROUP_HUE[shelfGroup]),
|
||||
}}
|
||||
>
|
||||
{(SHOW_ROW_TITLES || SHOW_ROW_ICONS) && (
|
||||
{SHOW_ROW_TITLES && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: SHOW_ROW_TITLES && SHOW_ROW_ICONS ? 10 : 6,
|
||||
gap: 10,
|
||||
marginBottom: 12,
|
||||
fontSize: 20,
|
||||
fontWeight: 900,
|
||||
@@ -241,39 +246,44 @@ function BrowseViewImpl({
|
||||
}}
|
||||
>
|
||||
{SHOW_ROW_ICONS && <span className="group-icon">{GROUP_ICON[shelfGroup]}</span>}
|
||||
{SHOW_ROW_TITLES && GROUP_LABEL[shelfGroup]}
|
||||
{SHOW_ROW_TITLES && <span style={{ opacity: 0.6, fontSize: 17 }}>›</span>}
|
||||
{GROUP_LABEL[shelfGroup]}
|
||||
<span style={{ opacity: 0.6, fontSize: 17 }}>›</span>
|
||||
</div>
|
||||
)}
|
||||
{shelfCategories.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: "oklch(88% 0.02 210 / .6)",
|
||||
}}
|
||||
>
|
||||
Noch nichts hier
|
||||
</div>
|
||||
) : (
|
||||
<ShelfRow focusedIndex={focusedRow === index ? focusedCol : null}>
|
||||
{shelfCategories.map((entry, tileIndex) => (
|
||||
<CategoryTile
|
||||
key={entry.key}
|
||||
entry={entry}
|
||||
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>
|
||||
{SHOW_ROW_ICONS && !SHOW_ROW_TITLES && (
|
||||
<span className="group-icon">{GROUP_ICON[shelfGroup]}</span>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{shelfCategories.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: "oklch(88% 0.02 210 / .6)",
|
||||
}}
|
||||
>
|
||||
Noch nichts hier
|
||||
</div>
|
||||
) : (
|
||||
<ShelfRow focusedIndex={focusedRow === index ? focusedCol : null}>
|
||||
{shelfCategories.map((entry, tileIndex) => (
|
||||
<CategoryTile
|
||||
key={entry.key}
|
||||
entry={entry}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,66 +1,104 @@
|
||||
/** The Zaubertasten sheet (F1). Mirrors lib/keyboard.ts - if a binding changes there,
|
||||
* it changes here. */
|
||||
|
||||
const KEYS: Array<[caps: string[], label: string]> = [
|
||||
[["LEER"], "Play / Pause"],
|
||||
[["SHIFT+H", "SHIFT+L"], "Song zurück / vor"],
|
||||
[["SHIFT+J", "SHIFT+K"], "Leiser / lauter"],
|
||||
[["SHIFT+M"], "Stumm"],
|
||||
[["⇧←", "⇧→"], "15 Sekunden zurück / vor"],
|
||||
[["← ↑ ↓ →"], "Auswahl bewegen"],
|
||||
[["CTRL+H/J/K/L"], "Auswahl bewegen (Raster oder Titelliste)"],
|
||||
[["CTRL+D", "CTRL+U"], "Eine halbe Seite weiter / zurück"],
|
||||
[["A-Z"], "Album oder Hörbuch suchen"],
|
||||
[["?"], "Einzelne Titel suchen"],
|
||||
[["TAB"], "Musik / Hörbücher / Podcasts"],
|
||||
[["ENTER"], "Auswahl abspielen"],
|
||||
[["⇧ENTER"], "Titelliste öffnen"],
|
||||
[["A", "0-9"], "Aktuellen Titel einer Fernbedienungs-Taste zuweisen"],
|
||||
[["F1"], "Diese Hilfe"],
|
||||
[["ESC"], "Schließen / Suche löschen"],
|
||||
import { Fragment } from "react";
|
||||
|
||||
const SECTIONS: Array<[title: string, rows: Array<[caps: string[], label: string]>]> = [
|
||||
[
|
||||
"Abspielen",
|
||||
[
|
||||
[["LEER"], "Play / Pause"],
|
||||
[["⇧H", "⇧L"], "Song zurück / vor"],
|
||||
[["⇧J", "⇧K"], "Leiser / lauter"],
|
||||
[["⇧M"], "Stumm"],
|
||||
[["⇧←", "⇧→"], "15 Sekunden zurück / vor"],
|
||||
],
|
||||
],
|
||||
[
|
||||
"Auswählen",
|
||||
[
|
||||
[["← ↑ ↓ →"], "Auswahl bewegen"],
|
||||
[["STRG+HJKL"], "Auswahl bewegen"],
|
||||
[["ENTER"], "Auswahl abspielen"],
|
||||
[["⇧ENTER"], "Titelliste öffnen"],
|
||||
[["TAB"], "Musik / Hörbücher / Podcasts"],
|
||||
],
|
||||
],
|
||||
[
|
||||
"Suchen",
|
||||
[
|
||||
[["A-Z"], "Album oder Hörbuch suchen"],
|
||||
[["?"], "Einzelne Titel suchen"],
|
||||
[["/"], "Suche öffnen / Alben ↔ Titel"],
|
||||
],
|
||||
],
|
||||
[
|
||||
"Sonstiges",
|
||||
[
|
||||
[["A", "0-9"], "Titel auf Fernbedienungs-Taste legen"],
|
||||
[["F1"], "Diese Hilfe"],
|
||||
[["ESC"], "Schließen / Suche löschen"],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
const CAP_STYLE = { minWidth: 0, padding: "3px 7px", fontSize: 11 } as const;
|
||||
|
||||
export function HelpOverlay({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div className="overlay" style={{ zIndex: 5 }} onClick={onClose}>
|
||||
<div className="sheet" style={{ padding: "26px 30px", width: 330 }}>
|
||||
<div className="sheet" style={{ padding: "18px 22px" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontSize: 15,
|
||||
fontWeight: 800,
|
||||
color: "var(--ink)",
|
||||
marginBottom: 14,
|
||||
marginBottom: 4,
|
||||
letterSpacing: 0.3,
|
||||
}}
|
||||
>
|
||||
⌨️ Zaubertasten
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{KEYS.map(([caps, label]) => (
|
||||
<div key={label} style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
{caps.map((cap) => (
|
||||
<div key={cap} className="key-cap" style={{ minWidth: caps.length > 1 ? 0 : 56 }}>
|
||||
{cap}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: "oklch(35% 0.03 210)" }}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "oklch(50% 0.03 210 / .8)",
|
||||
textAlign: "center",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "max-content max-content",
|
||||
columnGap: 14,
|
||||
rowGap: 5,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
Tippe irgendwohin, um zu schließen
|
||||
{SECTIONS.map(([title, rows]) => (
|
||||
<Fragment key={title}>
|
||||
<div
|
||||
style={{
|
||||
gridColumn: "1 / -1",
|
||||
marginTop: 10,
|
||||
fontSize: 10,
|
||||
fontWeight: 800,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
color: "oklch(50% 0.03 210 / .8)",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{rows.map(([caps, label]) => (
|
||||
<Fragment key={label + caps.join()}>
|
||||
<div style={{ display: "flex", gap: 3, justifyContent: "flex-end" }}>
|
||||
{caps.map((cap) => (
|
||||
<span key={cap} className="key-cap" style={CAP_STYLE}>
|
||||
{cap}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: "oklch(35% 0.03 210)" }}>
|
||||
{label}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -122,23 +122,6 @@ describe("keyboard", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("jumps a half-page with Ctrl+d/Ctrl+u", () => {
|
||||
const ui = { ...initialUiState, group: "music" as const, cols: 2 };
|
||||
const grid: Results = {
|
||||
songs: [],
|
||||
categories: Array.from({ length: 10 }, (_, i) => ({ key: String(i), albums: [] })),
|
||||
albums: [],
|
||||
total: 10,
|
||||
};
|
||||
// 3 rows of 2 columns each = 6.
|
||||
expect(handleKey(key("d", { ctrlKey: true }), { ...ui, selIndex: 0 }, grid)).toEqual([
|
||||
{ type: "ui", patch: { selIndex: 6 } },
|
||||
]);
|
||||
expect(handleKey(key("u", { ctrlKey: true }), { ...ui, selIndex: 8 }, grid)).toEqual([
|
||||
{ type: "ui", patch: { selIndex: 2 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does nothing with Ctrl+hjkl outside the browse grid, so it never fights the play view's own bindings", () => {
|
||||
expect(press("l", { ...initialUiState, view: "play" }, { ctrlKey: true })).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -85,9 +85,6 @@ export const GROUP_ORDER: Group[] = ["music", "audiobooks", "podcasts"];
|
||||
|
||||
export const VOLUME_STEP = 10;
|
||||
export const SEEK_STEP = 15;
|
||||
/** How far Ctrl+d/Ctrl+u jump through the grid - vim's own half-page scroll, applied to
|
||||
* rows of cards instead of lines of text. */
|
||||
export const PAGE_ROWS = 3;
|
||||
|
||||
/** Shift+h/j/k/l/m: transport, reachable from anywhere, matching the muscle memory of
|
||||
* every other vim-ish media app. Keyed on the exact shifted letter, so this only ever
|
||||
@@ -292,8 +289,7 @@ export function handleKey(
|
||||
|
||||
// Ctrl+hjkl moves the highlight in whichever list is on screen - the open album's
|
||||
// track list if one is open, otherwise the browse grid - the same job the arrow keys
|
||||
// already do, just reachable without leaving the home row. Ctrl+d/u are vim's own
|
||||
// half-page jump, applied to rows of cards.
|
||||
// already do, just reachable without leaving the home row.
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (state.openAlbumId !== null) {
|
||||
switch (key.toLowerCase()) {
|
||||
@@ -317,7 +313,7 @@ export function handleKey(
|
||||
}
|
||||
if (state.view !== "browse" || state.showHelp) return [];
|
||||
const lower = key.toLowerCase();
|
||||
if (!"hjkldu".includes(lower)) return [];
|
||||
if (!"hjkl".includes(lower)) return [];
|
||||
if (isRootShelf(state)) {
|
||||
switch (lower) {
|
||||
case "h":
|
||||
@@ -325,10 +321,8 @@ export function handleKey(
|
||||
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 [];
|
||||
@@ -343,10 +337,6 @@ export function handleKey(
|
||||
return moveSelection(state, results, 0, 1);
|
||||
case "k":
|
||||
return moveSelection(state, results, 0, -1);
|
||||
case "d":
|
||||
return moveSelection(state, results, 0, PAGE_ROWS);
|
||||
case "u":
|
||||
return moveSelection(state, results, 0, -PAGE_ROWS);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -21,12 +21,19 @@ import type { UiState } from "./keyboard";
|
||||
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;
|
||||
export const SHOW_ROW_TITLES = false;
|
||||
|
||||
/** 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. */
|
||||
/** Show the small icon badge (🎵/📖/🎙️) beside the title. With `SHOW_ROW_TITLES`
|
||||
* off it moves out of the header into a column left of the tiles, so an icon-only
|
||||
* shelf is no taller than one with neither; the original look needs both. */
|
||||
export const SHOW_ROW_ICONS = true;
|
||||
|
||||
/** Show the mascot + title header ("Musik Delfin", "Mein Zimmer") at the top of the
|
||||
* browse and room screens. Off reclaims its vertical space; the connection/figure
|
||||
* status pill it carries still shows, on a slim row of its own, whenever it has
|
||||
* something to say. */
|
||||
export const SHOW_APP_TITLE = false;
|
||||
|
||||
/** Vertical gap between shelves on the root browse screen, in px. */
|
||||
export const ROW_SPACING = 25;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user