Web frontend: touch layout for phones
Phones got the kiosk's keyboard-first layout squeezed down: shelves cut off by the tab rail and a header taking a fifth of the screen. They now get their own layout, chosen by a media query (narrow, or short landscape on a touch screen); tablets and desktop are unchanged. - Pick a group (Musik/Hörbücher/Podcasts), then a category, then scroll through a grid of smaller albums. - A floating glass dock: mini player, Musik/Zimmer tabs (no typing tab - a phone has no keyboard for it) and a search button that turns into a real search field, raised above the on-screen keyboard. - Album sheet and room page get compact phone variants. Browse state stays in the shared UiState, so resizing or rotating keeps your place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,8 @@ import { AmbienceDebugOverlay } from "./components/AmbienceDebugOverlay";
|
||||
import { AppHeader } from "./components/AppHeader";
|
||||
import { BrowseView } from "./components/BrowseView";
|
||||
import { HelpOverlay } from "./components/HelpOverlay";
|
||||
import { MobileBrowse } from "./components/mobile/MobileBrowse";
|
||||
import { MobileDock } from "./components/mobile/MobileDock";
|
||||
import { ParentPanel } from "./components/ParentPanel";
|
||||
import { PlayerBar } from "./components/PlayerBar";
|
||||
import { PlayView } from "./components/PlayView";
|
||||
@@ -28,6 +30,7 @@ import { RemoteAssignPopup } from "./components/RemoteAssignPopup";
|
||||
import { TabRail } from "./components/TabRail";
|
||||
import { useGridColumns } from "./hooks/useGridColumns";
|
||||
import { useLibrary } from "./hooks/useLibrary";
|
||||
import { usePhoneLayout } from "./hooks/usePhoneLayout";
|
||||
import { usePlayerState } from "./hooks/usePlayerState";
|
||||
import { DEFAULT_MANUAL_CONTROL, DEFAULT_TUNABLES } from "./lib/ambienceTunables";
|
||||
import type { AmbienceTunables, ManualControl } from "./lib/ambienceTunables";
|
||||
@@ -35,7 +38,7 @@ import type { Action, UiState } from "./lib/keyboard";
|
||||
import { browseBackActions, GROUP_ORDER, handleKey, initialUiState, isRootShelf, toggleModeActions } from "./lib/keyboard";
|
||||
import { playPop } from "./lib/pop";
|
||||
import { targetForAlbum } from "./lib/remote";
|
||||
import type { Group, Results, SongHit } from "./lib/search";
|
||||
import type { Group, Mode, Results, SongHit } from "./lib/search";
|
||||
import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } from "./lib/search";
|
||||
import { SHOW_AMBIENCE, SHOW_CARD_BLUR, SHOW_GLASS_BLUR } from "./lib/theme";
|
||||
|
||||
@@ -64,6 +67,9 @@ export function App() {
|
||||
const library = useLibrary();
|
||||
const connection = usePlayerState(library.reload);
|
||||
const [gridRef, columns] = useGridColumns();
|
||||
// A phone gets its own touch layout (components/mobile/); everything else - the
|
||||
// kiosk, a desktop browser, a tablet - keeps the keyboard-first one.
|
||||
const phone = usePhoneLayout();
|
||||
const [parentMode, setParentMode] = useState(
|
||||
() => new URLSearchParams(location.search).get("parentMode") === "1",
|
||||
);
|
||||
@@ -413,6 +419,7 @@ export function App() {
|
||||
};
|
||||
|
||||
const onPlaySong = useEvent((hit: SongHit) => play(hit.album.id, hit.index));
|
||||
const onMode = useEvent((mode: Mode) => setUi((previous) => ({ ...previous, mode, selIndex: 0 })));
|
||||
const onNext = useCallback(() => run([{ type: "next" }]), [run]);
|
||||
const onPrevious = useCallback(() => run([{ type: "previous" }]), [run]);
|
||||
|
||||
@@ -420,7 +427,9 @@ export function App() {
|
||||
// search, categories, play view - so it always means "one step out" no matter what
|
||||
// is on screen. Nothing to back out of on the room page (it has no sub-levels), and
|
||||
// the toggle button is what moves between the two pages.
|
||||
const canGoBack = ui.page === "music" && (ui.view === "play" || !isRootShelf(ui));
|
||||
// The phone layout has its own back button in the browse screen's nav bar.
|
||||
const canGoBack =
|
||||
ui.page === "music" && (ui.view === "play" || (!phone && !isRootShelf(ui)));
|
||||
const onBack = () => {
|
||||
if (ui.view === "play") {
|
||||
setUi((previous) => ({ ...previous, view: "browse" }));
|
||||
@@ -430,6 +439,9 @@ export function App() {
|
||||
};
|
||||
const onSelectPage = (page: UiState["page"]) =>
|
||||
setUi((previous) => ({ ...previous, page: previous.page === page ? "music" : page }));
|
||||
// Tabs, unlike the rail's toggles: tapping the open one again leaves it open.
|
||||
const onSelectPhonePage = (page: UiState["page"]) =>
|
||||
setUi((previous) => ({ ...previous, page, search: "", searchOpen: false }));
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -447,7 +459,28 @@ export function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{ui.page === "music" && ui.view === "browse" && state && (
|
||||
{phone && ui.page === "music" && ui.view === "browse" && state && (
|
||||
<div className="m-page">
|
||||
<MobileBrowse
|
||||
index={index}
|
||||
group={ui.group}
|
||||
category={ui.category}
|
||||
searching={ui.searchOpen}
|
||||
search={deferredSearch}
|
||||
typed={ui.search}
|
||||
mode={ui.mode}
|
||||
currentAlbumId={state.album_id}
|
||||
statusLabel={connectionLabel(connection.online, state)}
|
||||
offline={!connection.online}
|
||||
onEnterGroup={onEnterGroup}
|
||||
onMode={onMode}
|
||||
onOpenAlbum={onOpenAlbum}
|
||||
onPlaySong={onPlaySong}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!phone && ui.page === "music" && ui.view === "browse" && state && (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
@@ -531,7 +564,7 @@ export function App() {
|
||||
)}
|
||||
|
||||
<Suspense fallback={null}>
|
||||
{ui.page === "room" && haConfig && <RoomView config={haConfig} />}
|
||||
{ui.page === "room" && haConfig && <RoomView config={haConfig} phone={phone} />}
|
||||
|
||||
{ui.page === "typing" && (
|
||||
<TippenApp onExit={() => setUi((previous) => ({ ...previous, page: "music" }))} />
|
||||
@@ -546,6 +579,7 @@ export function App() {
|
||||
selectedIndex={ui.modalTrackIndex}
|
||||
onClose={() => setUi((previous) => ({ ...previous, openAlbumId: null }))}
|
||||
onPlay={(trackIndex) => play(openAlbum.id, trackIndex)}
|
||||
phone={phone}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -563,13 +597,33 @@ export function App() {
|
||||
</CornerButton>
|
||||
)}
|
||||
|
||||
<TabRail page={ui.page} onSelect={onSelectPage} showRoom={haConfig != null} />
|
||||
{!phone && <TabRail page={ui.page} onSelect={onSelectPage} showRoom={haConfig != null} />}
|
||||
|
||||
{phone && (ui.page === "room" || (ui.page === "music" && ui.view === "browse")) && (
|
||||
<MobileDock
|
||||
page={ui.page}
|
||||
onSelectPage={onSelectPhonePage}
|
||||
showRoom={haConfig != null}
|
||||
searching={ui.page === "music" && ui.searchOpen}
|
||||
typed={ui.search}
|
||||
onOpenSearch={() => setUi((previous) => ({ ...previous, searchOpen: true }))}
|
||||
onCloseSearch={() =>
|
||||
setUi((previous) => ({ ...previous, search: "", searchOpen: false, mode: "albums" }))
|
||||
}
|
||||
onSearchChange={(text) => setUi((previous) => ({ ...previous, search: text, selIndex: 0 }))}
|
||||
state={state}
|
||||
album={currentAlbum}
|
||||
onToggle={toggle}
|
||||
onNext={onNext}
|
||||
onOpenPlayView={() => setUi((previous) => ({ ...previous, view: "play" }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{ui.showHelp && (
|
||||
<HelpOverlay onClose={() => setUi((previous) => ({ ...previous, showHelp: false }))} />
|
||||
)}
|
||||
|
||||
{ui.page === "music" && ui.view === "browse" && state && currentAlbum && (
|
||||
{!phone && ui.page === "music" && ui.view === "browse" && state && currentAlbum && (
|
||||
<PlayerBar
|
||||
state={state}
|
||||
album={currentAlbum}
|
||||
@@ -669,8 +723,12 @@ function CornerButton({
|
||||
);
|
||||
}
|
||||
|
||||
function connectionLabel(online: boolean, state: { active_figure: string | null }): string | null {
|
||||
return !online ? "Keine Verbindung" : state.active_figure ? `🧸 ${state.active_figure}` : null;
|
||||
}
|
||||
|
||||
function ConnectionDot({ online, state }: { online: boolean; state: { active_figure: string | null } }) {
|
||||
const label = !online ? "Keine Verbindung" : state.active_figure ? `🧸 ${state.active_figure}` : null;
|
||||
const label = connectionLabel(online, state);
|
||||
if (!label) return null;
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -16,6 +16,8 @@ interface Props {
|
||||
selectedIndex: number;
|
||||
onClose: () => void;
|
||||
onPlay: (trackIndex: number) => void;
|
||||
/** A bottom sheet with a smaller header, and no keyboard highlight to show. */
|
||||
phone?: boolean;
|
||||
}
|
||||
|
||||
export function AlbumModal({
|
||||
@@ -25,7 +27,9 @@ export function AlbumModal({
|
||||
selectedIndex,
|
||||
onClose,
|
||||
onPlay,
|
||||
phone = false,
|
||||
}: Props) {
|
||||
const coverSize = phone ? 108 : 150;
|
||||
const book = isBook(album);
|
||||
const sheet = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -47,7 +51,11 @@ export function AlbumModal({
|
||||
return (
|
||||
<div
|
||||
className={ANIMATE_VIEW_TRANSITIONS ? "overlay backdrop-enter" : "overlay"}
|
||||
style={{ zIndex: 4, background: "oklch(15% 0.03 210 / .6)" }}
|
||||
style={{
|
||||
zIndex: 4,
|
||||
background: "oklch(15% 0.03 210 / .6)",
|
||||
...(phone && { alignItems: "flex-end", padding: "calc(40px + env(safe-area-inset-top)) 0 0" }),
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
@@ -56,20 +64,20 @@ export function AlbumModal({
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
background: "oklch(96% 0.012 210)",
|
||||
borderRadius: 26,
|
||||
padding: 26,
|
||||
width: 640,
|
||||
borderRadius: phone ? "26px 26px 0 0" : 26,
|
||||
padding: phone ? "20px 16px calc(20px + env(safe-area-inset-bottom))" : 26,
|
||||
width: phone ? "100%" : 640,
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
overflow: "auto",
|
||||
boxShadow: "0 24px 60px oklch(10% 0.04 210 / .55)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 20, alignItems: "flex-start" }}>
|
||||
<div style={{ width: 150, flex: "none" }}>
|
||||
<div style={{ display: "flex", gap: phone ? 14 : 20, alignItems: "flex-start" }}>
|
||||
<div style={{ width: coverSize, flex: "none" }}>
|
||||
<Cover
|
||||
album={album}
|
||||
size={150}
|
||||
size={coverSize}
|
||||
radius={book ? "10px 18px 18px 10px" : "16px"}
|
||||
label
|
||||
locked={album.locked}
|
||||
@@ -78,7 +86,7 @@ export function AlbumModal({
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 26,
|
||||
fontSize: phone ? 20 : 26,
|
||||
fontWeight: 900,
|
||||
color: "oklch(22% 0.03 210)",
|
||||
lineHeight: 1.15,
|
||||
@@ -117,9 +125,9 @@ export function AlbumModal({
|
||||
cursor: album.locked ? "default" : "pointer",
|
||||
background: album.locked ? "oklch(80% 0.02 210)" : "var(--accent)",
|
||||
color: album.locked ? "oklch(40% 0.03 210)" : "#fff",
|
||||
fontSize: 16,
|
||||
fontSize: phone ? 15 : 16,
|
||||
fontWeight: 800,
|
||||
padding: "12px 22px",
|
||||
padding: phone ? "10px 18px" : "12px 22px",
|
||||
borderRadius: 999,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -139,7 +147,7 @@ export function AlbumModal({
|
||||
clipPath: "polygon(6% 0%, 100% 50%, 6% 100%)",
|
||||
}}
|
||||
/>
|
||||
{book ? "Hörbuch abspielen" : "Alle Songs abspielen"}
|
||||
{phone ? "Abspielen" : book ? "Hörbuch abspielen" : "Alle Songs abspielen"}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -187,7 +195,7 @@ export function AlbumModal({
|
||||
background: current
|
||||
? "oklch(70% 0.16 340 / .16)"
|
||||
: "oklch(30% 0.03 210 / .05)",
|
||||
outline: index === selectedIndex ? "3px solid var(--accent)" : "none",
|
||||
outline: !phone && index === selectedIndex ? "3px solid var(--accent)" : "none",
|
||||
outlineOffset: 2,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -5,26 +5,36 @@ interface Props {
|
||||
mascot?: string;
|
||||
/** Shown next to the title when the mouse is reachable but the firmware is not. */
|
||||
status?: ReactNode;
|
||||
/** The phone size: a smaller mascot and title. */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function AppHeader({ title = "Musik Delfin", mascot = "/dolphin-mascot.png", status }: Props) {
|
||||
export function AppHeader({
|
||||
title = "Musik Delfin",
|
||||
mascot = "/dolphin-mascot.png",
|
||||
status,
|
||||
compact = false,
|
||||
}: Props) {
|
||||
const mascotSize = compact ? 44 : 64;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 16,
|
||||
gap: compact ? 10 : 16,
|
||||
// The status bar is translucent in standalone mode, so make room for it.
|
||||
padding: "calc(18px + env(safe-area-inset-top)) 32px 6px",
|
||||
padding: compact
|
||||
? "calc(12px + env(safe-area-inset-top)) 16px 4px"
|
||||
: "calc(18px + env(safe-area-inset-top)) 32px 6px",
|
||||
flex: "none",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<img src={mascot} alt="" style={{ width: 64, height: 64, objectFit: "contain" }} />
|
||||
<img src={mascot} alt="" style={{ width: mascotSize, height: mascotSize, objectFit: "contain" }} />
|
||||
<div
|
||||
style={{
|
||||
fontSize: 34,
|
||||
fontSize: compact ? 28 : 34,
|
||||
fontWeight: 900,
|
||||
color: "var(--paper)",
|
||||
textShadow: "0 3px 14px oklch(15% 0.05 210 / .5)",
|
||||
|
||||
@@ -727,7 +727,7 @@ function ShelfRow({
|
||||
/** One category's 2x2 cover preview + name + count. Used both by the root shelves
|
||||
* (pointer-only) and by the in-group category grid (keyboard-navigable, hence the
|
||||
* optional `navIndex`/`selected`). */
|
||||
function CategoryTile({
|
||||
export function CategoryTile({
|
||||
entry,
|
||||
navIndex,
|
||||
selected,
|
||||
|
||||
@@ -14,7 +14,9 @@ import { LightCard } from "./LightCard";
|
||||
import { SceneRow } from "./SceneRow";
|
||||
import { ShutterCard } from "./ShutterCard";
|
||||
|
||||
export function RoomView({ config }: { config: HaConfig }) {
|
||||
/** `phone`: no tab rail on the right to keep clear of, but the floating dock along the
|
||||
* bottom (see `MobileDock`) instead. */
|
||||
export function RoomView({ config, phone = false }: { config: HaConfig; phone?: boolean }) {
|
||||
const ha = useHomeAssistant(config);
|
||||
// Purely local: Home Assistant scenes have no "currently active" state of their own.
|
||||
// Cleared by any manual device change, set by activating a scene.
|
||||
@@ -39,11 +41,18 @@ export function RoomView({ config }: { config: HaConfig }) {
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<AppHeader title="Mein Zimmer" mascot="/dolphin-remote.png" />
|
||||
<AppHeader title="Mein Zimmer" mascot="/dolphin-remote.png" compact={phone} />
|
||||
<div
|
||||
// Right padding reserves room for the tab rail's own glass bar (see
|
||||
// TAB_RAIL_CLEARANCE) so the device grid never renders under it.
|
||||
style={{ flex: 1, overflow: "auto", minHeight: 0, padding: `14px ${TAB_RAIL_CLEARANCE}px 64px 32px` }}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
minHeight: 0,
|
||||
padding: phone
|
||||
? "8px 16px calc(var(--m-dock-clearance) + 24px)"
|
||||
: `14px ${TAB_RAIL_CLEARANCE}px 64px 32px`,
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 1180, margin: "0 auto" }}>
|
||||
{config.scenes.length > 0 && (
|
||||
|
||||
278
web/src/components/mobile/MobileBrowse.tsx
Normal file
278
web/src/components/mobile/MobileBrowse.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
/** The music page on a phone. Where the kiosk lays out three shelves side by side and
|
||||
* walks them with the keyboard, this is the usual touch app shape: pick a group from
|
||||
* the segmented control, then a category, then scroll down through its albums. Search
|
||||
* results replace all of it while the search bar in `MobileDock` is open.
|
||||
*
|
||||
* Browse state is the same `UiState` the kiosk layout reads (`group`, `category`,
|
||||
* `search`, `mode`), so rotating a tablet or resizing a window keeps your place. */
|
||||
|
||||
import { memo, useMemo } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { Album } from "../../api/types";
|
||||
import { albumLine, isBook, unitLabel } from "../../lib/covers";
|
||||
import { clock } from "../../lib/format";
|
||||
import type { Group, Mode, SearchIndex, SongHit } from "../../lib/search";
|
||||
import { effectiveSearch, groupOf, results as computeResults } from "../../lib/search";
|
||||
import { GROUP_ICON, GROUP_LABEL } from "../../lib/theme";
|
||||
import { CategoryTile } from "../BrowseView";
|
||||
import { Cover } from "../Cover";
|
||||
|
||||
interface Props {
|
||||
index: SearchIndex;
|
||||
/** `null` is the kiosk's three-shelf root, which a phone shows as the music group. */
|
||||
group: Group | null;
|
||||
category: string | null;
|
||||
/** The search bar is open: results replace the browse screen. */
|
||||
searching: boolean;
|
||||
/** The query the results are computed for - may trail `typed` by a render. */
|
||||
search: string;
|
||||
typed: string;
|
||||
mode: Mode;
|
||||
currentAlbumId: string | null;
|
||||
/** Pill next to the title: connection lost, or the figure on the reader. Primitives
|
||||
* rather than an element, so `memo` still holds across position frames. */
|
||||
statusLabel: string | null;
|
||||
offline: boolean;
|
||||
onEnterGroup: (group: Group, category: string | null) => void;
|
||||
onMode: (mode: Mode) => void;
|
||||
onOpenAlbum: (album: Album, navIndex: number) => void;
|
||||
onPlaySong: (hit: SongHit) => void;
|
||||
}
|
||||
|
||||
const GROUPS: Group[] = ["music", "audiobooks", "podcasts"];
|
||||
|
||||
function MobileBrowseImpl({
|
||||
index,
|
||||
group: rawGroup,
|
||||
category,
|
||||
searching,
|
||||
search,
|
||||
typed,
|
||||
mode,
|
||||
currentAlbumId,
|
||||
statusLabel,
|
||||
offline,
|
||||
onEnterGroup,
|
||||
onMode,
|
||||
onOpenAlbum,
|
||||
onPlaySong,
|
||||
}: Props) {
|
||||
const group = rawGroup ?? "music";
|
||||
const query = effectiveSearch(search);
|
||||
|
||||
const results = useMemo(
|
||||
() =>
|
||||
searching
|
||||
? computeResults({ index, search: query, mode, group: null, category: null })
|
||||
: computeResults({ index, search: "", mode: "albums", group, category }),
|
||||
[index, searching, query, mode, group, category],
|
||||
);
|
||||
|
||||
if (searching) {
|
||||
return (
|
||||
<div className="m-scroll" key="search">
|
||||
<div className="m-sticky">
|
||||
<div className="m-segmented">
|
||||
<Segment active={mode === "albums"} onClick={() => onMode("albums")}>
|
||||
Alben
|
||||
</Segment>
|
||||
<Segment active={mode === "tracks"} onClick={() => onMode("tracks")}>
|
||||
Titel
|
||||
</Segment>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!effectiveSearch(typed) ? (
|
||||
<Hint>Was möchtest du hören?</Hint>
|
||||
) : mode === "tracks" ? (
|
||||
results.songs.length ? (
|
||||
<div className="m-list">
|
||||
{results.songs.map((hit) => (
|
||||
<SongRow
|
||||
key={`${hit.album.id}-${hit.index}`}
|
||||
hit={hit}
|
||||
current={hit.album.id === currentAlbumId}
|
||||
onClick={() => onPlaySong(hit)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Hint>Nichts gefunden — probier andere Buchstaben!</Hint>
|
||||
)
|
||||
) : results.albums.length ? (
|
||||
<AlbumGrid albums={results.albums} currentAlbumId={currentAlbumId} onOpen={onOpenAlbum} />
|
||||
) : (
|
||||
<Hint>Nichts gefunden — probier andere Buchstaben!</Hint>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (category !== null) {
|
||||
return (
|
||||
<div className="m-scroll" key={`${group}:${category}`}>
|
||||
<div className="m-sticky m-navbar">
|
||||
<button
|
||||
className="m-glass m-circle m-circle-small"
|
||||
onClick={() => onEnterGroup(group, null)}
|
||||
aria-label="Zurück"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</button>
|
||||
<div className="m-navbar-title">{category}</div>
|
||||
<div className="m-circle-small" aria-hidden />
|
||||
</div>
|
||||
<div className="m-subtitle">
|
||||
{GROUP_ICON[group]} {GROUP_LABEL[group]} · {results.albums.length}{" "}
|
||||
{results.albums.length === 1 ? "Eintrag" : "Einträge"}
|
||||
</div>
|
||||
<AlbumGrid albums={results.albums} currentAlbumId={currentAlbumId} onOpen={onOpenAlbum} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="m-scroll" key={group}>
|
||||
<div className="m-title-row">
|
||||
<img src="/dolphin-mascot.png" alt="" className="m-title-mascot" />
|
||||
<div className="m-title">Musik Delfin</div>
|
||||
{statusLabel && (
|
||||
<div className="m-status" data-offline={offline}>
|
||||
{statusLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="m-sticky">
|
||||
<div className="m-segmented">
|
||||
{GROUPS.map((candidate) => (
|
||||
<Segment
|
||||
key={candidate}
|
||||
active={candidate === group}
|
||||
onClick={() => onEnterGroup(candidate, null)}
|
||||
>
|
||||
<span aria-hidden>{GROUP_ICON[candidate]}</span> {GROUP_LABEL[candidate]}
|
||||
</Segment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{results.categories.length ? (
|
||||
<div className="m-category-grid">
|
||||
{results.categories.map((entry) => (
|
||||
<CategoryTile key={entry.key} entry={entry} onClick={() => onEnterGroup(group, entry.key)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Hint>Noch nichts hier</Hint>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AlbumGrid({
|
||||
albums,
|
||||
currentAlbumId,
|
||||
onOpen,
|
||||
}: {
|
||||
albums: Album[];
|
||||
currentAlbumId: string | null;
|
||||
onOpen: (album: Album, navIndex: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="m-album-grid">
|
||||
{albums.map((album, index) => {
|
||||
const podcast = groupOf(album) === "podcasts";
|
||||
return (
|
||||
<button
|
||||
key={album.id}
|
||||
className="m-album"
|
||||
data-current={album.id === currentAlbumId}
|
||||
disabled={album.locked}
|
||||
onClick={() => onOpen(album, index)}
|
||||
aria-label={
|
||||
album.locked
|
||||
? `${album.title}: noch nicht freigeschaltet`
|
||||
: podcast
|
||||
? `${album.title} abspielen`
|
||||
: `${album.title}: Titel wählen`
|
||||
}
|
||||
>
|
||||
<Cover
|
||||
album={album}
|
||||
size={120}
|
||||
radius={isBook(album) ? "4px 12px 12px 4px" : "12px"}
|
||||
className="m-album-cover"
|
||||
label
|
||||
locked={album.locked}
|
||||
/>
|
||||
<div className="m-album-title">{album.locked ? "❓ Geheimnis" : album.title}</div>
|
||||
{!album.locked && (
|
||||
<div className="m-album-sub">
|
||||
{podcast ? clock(album.duration) : unitLabel(album, album.tracks.length)}
|
||||
{album.figure && " · 🧸"}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SongRow({ hit, current, onClick }: { hit: SongHit; current: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button className="m-song" data-current={current} onClick={onClick}>
|
||||
<div className="m-song-cover">
|
||||
<Cover album={hit.album} size={44} fit="height" radius={isBook(hit.album) ? "3px 8px 8px 3px" : "8px"} />
|
||||
</div>
|
||||
<div className="m-song-text">
|
||||
<div className="m-song-title">{hit.title}</div>
|
||||
<div className="m-song-sub">{albumLine(hit.album)}</div>
|
||||
</div>
|
||||
<div className="m-song-time">{clock(hit.duration)}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Segment({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button className="m-segment" data-active={active} aria-pressed={active} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Hint({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="m-hint">
|
||||
<img src="/dolphin-mascot.png" alt="" />
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronLeft() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M12.5 4.5 6.5 10l6 5.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Memoised for the same reason as `BrowseView`: `App` re-renders on every position
|
||||
* frame during playback, and nothing here changes with the position. */
|
||||
export const MobileBrowse = memo(MobileBrowseImpl);
|
||||
230
web/src/components/mobile/MobileDock.tsx
Normal file
230
web/src/components/mobile/MobileDock.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
/** Everything that floats over the bottom of the phone layout, in glass: the mini
|
||||
* player, and below it the tab bar with a round search button beside it. Tapping
|
||||
* search turns that row into the search field - the tabs shrink to one round button
|
||||
* that closes it again - the way iOS's own apps put search at the bottom, in reach of
|
||||
* a thumb.
|
||||
*
|
||||
* Lifted by `useKeyboardInset` while the keyboard is up, so the field stays above it. */
|
||||
|
||||
import { useRef } from "react";
|
||||
|
||||
import type { Album, PlayerState } from "../../api/types";
|
||||
import { useKeyboardInset } from "../../hooks/useKeyboardInset";
|
||||
import { usePlaybackClock } from "../../hooks/usePlaybackClock";
|
||||
import type { UiState } from "../../lib/keyboard";
|
||||
import { nowPlayingText } from "../../lib/covers";
|
||||
import { PAGE_ICON } from "../../lib/theme";
|
||||
import { Cover } from "../Cover";
|
||||
|
||||
type Page = UiState["page"];
|
||||
|
||||
interface Props {
|
||||
page: Page;
|
||||
onSelectPage: (page: Page) => void;
|
||||
showRoom: boolean;
|
||||
searching: boolean;
|
||||
typed: string;
|
||||
onOpenSearch: () => void;
|
||||
onCloseSearch: () => void;
|
||||
onSearchChange: (text: string) => void;
|
||||
state: PlayerState | null;
|
||||
album: Album | null;
|
||||
onToggle: () => void;
|
||||
onNext: () => void;
|
||||
onOpenPlayView: () => void;
|
||||
}
|
||||
|
||||
/** The phone has no keyboard to type on, so no typing tab. */
|
||||
const TABS: { page: Page; label: string }[] = [
|
||||
{ page: "music", label: "Musik" },
|
||||
{ page: "room", label: "Zimmer" },
|
||||
];
|
||||
|
||||
export function MobileDock({
|
||||
page,
|
||||
onSelectPage,
|
||||
showRoom,
|
||||
searching,
|
||||
typed,
|
||||
onOpenSearch,
|
||||
onCloseSearch,
|
||||
onSearchChange,
|
||||
state,
|
||||
album,
|
||||
onToggle,
|
||||
onNext,
|
||||
onOpenPlayView,
|
||||
}: Props) {
|
||||
const keyboardInset = useKeyboardInset();
|
||||
const input = useRef<HTMLInputElement | null>(null);
|
||||
const tabs = TABS.filter((tab) => tab.page !== "room" || showRoom);
|
||||
const canSearch = page === "music";
|
||||
|
||||
return (
|
||||
<div className="m-dock" style={{ bottom: keyboardInset }} data-keyboard={keyboardInset > 0}>
|
||||
{state && album && !searching && (
|
||||
<MiniPlayer state={state} album={album} onToggle={onToggle} onNext={onNext} onOpen={onOpenPlayView} />
|
||||
)}
|
||||
|
||||
<div className="m-dock-row">
|
||||
{searching ? (
|
||||
<>
|
||||
<button className="m-glass m-circle" onClick={onCloseSearch} aria-label="Suche schließen">
|
||||
<CloseIcon />
|
||||
</button>
|
||||
<form
|
||||
className="m-glass m-search"
|
||||
role="search"
|
||||
onSubmit={(event) => {
|
||||
// Enter only puts the keyboard away - results are already live.
|
||||
event.preventDefault();
|
||||
input.current?.blur();
|
||||
}}
|
||||
>
|
||||
<SearchIcon />
|
||||
<input
|
||||
ref={input}
|
||||
autoFocus
|
||||
type="search"
|
||||
inputMode="search"
|
||||
enterKeyHint="search"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
placeholder="Alben, Hörbücher, Titel …"
|
||||
value={typed}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
/>
|
||||
{typed && (
|
||||
<button
|
||||
type="button"
|
||||
className="m-search-clear"
|
||||
aria-label="Suche leeren"
|
||||
onClick={() => {
|
||||
onSearchChange("");
|
||||
input.current?.focus();
|
||||
}}
|
||||
>
|
||||
<CloseIcon size={12} />
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{tabs.length > 1 && (
|
||||
<nav className="m-glass m-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.page}
|
||||
className="m-tab"
|
||||
data-active={tab.page === page}
|
||||
aria-current={tab.page === page}
|
||||
onClick={() => onSelectPage(tab.page)}
|
||||
>
|
||||
<span className="m-tab-icon" aria-hidden>
|
||||
{PAGE_ICON[tab.page]}
|
||||
</span>
|
||||
<span className="m-tab-label">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
{canSearch &&
|
||||
(tabs.length > 1 ? (
|
||||
<button className="m-glass m-circle" onClick={onOpenSearch} aria-label="Suchen">
|
||||
<SearchIcon />
|
||||
</button>
|
||||
) : (
|
||||
<button className="m-glass m-search m-search-closed" onClick={onOpenSearch}>
|
||||
<SearchIcon />
|
||||
<span>Suchen</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniPlayer({
|
||||
state,
|
||||
album,
|
||||
onToggle,
|
||||
onNext,
|
||||
onOpen,
|
||||
}: {
|
||||
state: PlayerState;
|
||||
album: Album;
|
||||
onToggle: () => void;
|
||||
onNext: () => void;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const position = usePlaybackClock(state.position, state.playing);
|
||||
const now = nowPlayingText(state, album);
|
||||
const progress = state.duration ? Math.min(1, position / state.duration) : 0;
|
||||
return (
|
||||
<div className="m-glass m-mini">
|
||||
<button className="m-mini-main" onClick={onOpen} aria-label="Große Ansicht">
|
||||
<div className="m-mini-cover">
|
||||
<Cover album={album} size={40} fit="height" radius="8px" />
|
||||
</div>
|
||||
<div className="m-mini-text">
|
||||
<div className="m-mini-title">{now.title}</div>
|
||||
<div className="m-mini-sub">{now.subtitle}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button className="m-mini-button" onClick={onToggle} aria-label={state.playing ? "Pause" : "Abspielen"}>
|
||||
{state.playing ? <PauseIcon /> : <PlayIcon />}
|
||||
</button>
|
||||
<button className="m-mini-button" onClick={onNext} aria-label="Nächster Titel">
|
||||
<NextIcon />
|
||||
</button>
|
||||
<div className="m-mini-progress" style={{ transform: `scaleX(${progress})` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden>
|
||||
<circle cx="8.5" cy="8.5" r="5.75" stroke="currentColor" strokeWidth="2.2" />
|
||||
<path d="m13 13 4.5 4.5" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CloseIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<path d="m3 3 10 10M13 3 3 13" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayIcon() {
|
||||
return (
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" aria-hidden>
|
||||
<path d="M6 3.5v15a1 1 0 0 0 1.5.86l12.5-7.5a1 1 0 0 0 0-1.72L7.5 2.64A1 1 0 0 0 6 3.5Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PauseIcon() {
|
||||
return (
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" aria-hidden>
|
||||
<rect x="4.5" y="3" width="4.5" height="16" rx="1.5" fill="currentColor" />
|
||||
<rect x="13" y="3" width="4.5" height="16" rx="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function NextIcon() {
|
||||
return (
|
||||
<svg width="24" height="22" viewBox="0 0 24 22" aria-hidden>
|
||||
<path d="M2 4.2v13.6a.8.8 0 0 0 1.2.7L12 13v4.8a.8.8 0 0 0 1.2.7l9.6-6.8a.8.8 0 0 0 0-1.4L13.2 3.5a.8.8 0 0 0-1.2.7V9L3.2 3.5a.8.8 0 0 0-1.2.7Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
26
web/src/hooks/useKeyboardInset.ts
Normal file
26
web/src/hooks/useKeyboardInset.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/** How much of the bottom of the layout viewport the on-screen keyboard covers, in px.
|
||||
*
|
||||
* iOS Safari slides the keyboard over the page without resizing it - `100dvh` does not
|
||||
* follow it - so a bar pinned to the bottom ends up hidden behind the very keyboard it
|
||||
* is typing with. The visual viewport does shrink, and the difference is the inset. */
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useKeyboardInset(): number {
|
||||
const [inset, setInset] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = window.visualViewport;
|
||||
if (!viewport) return;
|
||||
const measure = () =>
|
||||
setInset(Math.max(0, Math.round(window.innerHeight - viewport.height - viewport.offsetTop)));
|
||||
viewport.addEventListener("resize", measure);
|
||||
viewport.addEventListener("scroll", measure);
|
||||
return () => {
|
||||
viewport.removeEventListener("resize", measure);
|
||||
viewport.removeEventListener("scroll", measure);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return inset;
|
||||
}
|
||||
23
web/src/hooks/usePhoneLayout.ts
Normal file
23
web/src/hooks/usePhoneLayout.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/** Whether the screen is a phone, which gets its own touch-first layout (see
|
||||
* `components/mobile/`) instead of the kiosk's keyboard-first one.
|
||||
*
|
||||
* Narrow portrait, or short landscape on a touch screen - a phone turned sideways is
|
||||
* wide enough for the kiosk layout's columns but nowhere near tall enough for its
|
||||
* header, shelves and player bar. A tablet is neither and keeps the kiosk layout. */
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const PHONE_QUERY = "(max-width: 600px), (max-height: 500px) and (pointer: coarse)";
|
||||
|
||||
export function usePhoneLayout(): boolean {
|
||||
const [phone, setPhone] = useState(() => matchMedia(PHONE_QUERY).matches);
|
||||
|
||||
useEffect(() => {
|
||||
const media = matchMedia(PHONE_QUERY);
|
||||
const onChange = () => setPhone(media.matches);
|
||||
media.addEventListener("change", onChange);
|
||||
return () => media.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return phone;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import { PERF } from "./lib/perfSettings";
|
||||
import "./styles/app.css";
|
||||
import "./styles/mobile.css";
|
||||
import "./styles/room.css";
|
||||
import "./styles/tippen.css";
|
||||
|
||||
|
||||
561
web/src/styles/mobile.css
Normal file
561
web/src/styles/mobile.css
Normal file
@@ -0,0 +1,561 @@
|
||||
/* The phone layout (components/mobile/). Every class is `m-` prefixed and only rendered
|
||||
when `usePhoneLayout` says so, so none of this reaches the kiosk or a tablet. */
|
||||
|
||||
:root {
|
||||
/* Liquid-glass surfaces over the sea gradient: a dark translucent fill, a strong
|
||||
blur with a saturation boost so colour behind it still glows through, and a thin
|
||||
bright rim along the top edge. */
|
||||
--m-glass-fill: oklch(32% 0.04 210 / 0.42);
|
||||
--m-glass-rim: oklch(100% 0 0 / 0.22);
|
||||
--m-glass-shadow: 0 10px 30px oklch(10% 0.04 210 / 0.45);
|
||||
--m-gutter: 16px;
|
||||
/* Height of the floating dock, so scrolling content can clear it. */
|
||||
--m-dock-clearance: calc(96px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.m-glass {
|
||||
background: var(--m-glass-fill);
|
||||
backdrop-filter: blur(14px) saturate(1.7);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(1.7);
|
||||
border: 1px solid var(--m-glass-rim);
|
||||
box-shadow:
|
||||
var(--m-glass-shadow),
|
||||
inset 0 1px 0 oklch(100% 0 0 / 0.28),
|
||||
inset 0 -1px 0 oklch(100% 0 0 / 0.06);
|
||||
color: var(--paper);
|
||||
}
|
||||
|
||||
.m-page {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.m-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: env(safe-area-inset-top) 0 calc(var(--m-dock-clearance) + 84px);
|
||||
animation: viewEnter 180ms ease-out;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ header -- */
|
||||
|
||||
.m-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px var(--m-gutter) 4px;
|
||||
}
|
||||
|
||||
.m-title-mascot {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 3px 8px oklch(15% 0.05 210 / 0.5));
|
||||
}
|
||||
|
||||
.m-title {
|
||||
flex: 1;
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
color: var(--paper);
|
||||
letter-spacing: -0.01em;
|
||||
text-shadow: 0 2px 10px oklch(15% 0.05 210 / 0.45);
|
||||
}
|
||||
|
||||
.m-status {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: var(--paper);
|
||||
background: oklch(97% 0.01 210 / 0.16);
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m-status[data-offline="true"] {
|
||||
color: oklch(80% 0.16 30);
|
||||
}
|
||||
|
||||
/* The bar that stays put while the page scrolls under it. Fades from the top of the
|
||||
sea into transparency rather than ending in a hard edge. */
|
||||
.m-sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
padding: 10px var(--m-gutter) 14px;
|
||||
background: linear-gradient(180deg, #2f6f7aee 0%, #2f6f7a99 65%, #2f6f7a00 100%);
|
||||
}
|
||||
|
||||
.m-navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.m-navbar-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
font-size: 19px;
|
||||
font-weight: 900;
|
||||
color: var(--paper);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.m-subtitle {
|
||||
text-align: center;
|
||||
margin: -6px var(--m-gutter) 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: oklch(90% 0.02 210 / 0.75);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- segmented -- */
|
||||
|
||||
.m-segmented {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: 999px;
|
||||
background: oklch(20% 0.04 210 / 0.38);
|
||||
backdrop-filter: blur(12px) saturate(1.6);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(1.6);
|
||||
border: 1px solid oklch(100% 0 0 / 0.14);
|
||||
box-shadow: inset 0 1px 2px oklch(10% 0.04 210 / 0.35);
|
||||
}
|
||||
|
||||
.m-segment {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: oklch(95% 0.01 210 / 0.85);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
padding: 9px 6px;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.m-segment[data-active="true"] {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
box-shadow: 0 3px 10px oklch(10% 0.04 210 / 0.35);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ grids -- */
|
||||
|
||||
.m-category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 14px;
|
||||
padding: 0 var(--m-gutter);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.m-category-grid > .card {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 230px;
|
||||
}
|
||||
|
||||
.m-album-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 18px 12px;
|
||||
padding: 0 var(--m-gutter);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.m-album {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
color: var(--paper);
|
||||
cursor: pointer;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 170px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.m-album:active .m-album-cover {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.m-album-cover {
|
||||
box-shadow: 0 6px 16px oklch(10% 0.04 210 / 0.45);
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
|
||||
.m-album[data-current="true"] .m-album-cover {
|
||||
outline: 3px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.m-album-title {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.m-album-sub {
|
||||
margin-top: -3px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: var(--accent-dim);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- songs -- */
|
||||
|
||||
.m-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 0 var(--m-gutter);
|
||||
}
|
||||
|
||||
.m-song {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
background: transparent;
|
||||
color: var(--paper);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m-song:active,
|
||||
.m-song[data-current="true"] {
|
||||
background: oklch(97% 0.01 210 / 0.14);
|
||||
}
|
||||
|
||||
.m-song-cover {
|
||||
height: 44px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.m-song-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m-song-title,
|
||||
.m-song-sub {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.m-song-title {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.m-song-sub {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: oklch(88% 0.02 210 / 0.65);
|
||||
}
|
||||
|
||||
.m-song-time {
|
||||
font: 700 12px ui-monospace, Menlo, monospace;
|
||||
color: oklch(88% 0.02 210 / 0.6);
|
||||
}
|
||||
|
||||
.m-hint {
|
||||
text-align: center;
|
||||
margin-top: 48px;
|
||||
padding: 0 var(--m-gutter);
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: oklch(92% 0.02 210 / 0.8);
|
||||
}
|
||||
|
||||
.m-hint img {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
object-fit: contain;
|
||||
opacity: 0.9;
|
||||
display: block;
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- dock -- */
|
||||
|
||||
.m-dock {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 0 12px calc(10px + env(safe-area-inset-bottom));
|
||||
pointer-events: none;
|
||||
transition: bottom 0.2s ease;
|
||||
}
|
||||
|
||||
/* With the keyboard up the home indicator is hidden behind it, so no safe-area gap. */
|
||||
.m-dock[data-keyboard="true"] {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.m-dock > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.m-dock-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
/* Lets the row itself ignore taps between its pills. */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m-dock-row > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.m-circle {
|
||||
flex: none;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.m-circle-small {
|
||||
flex: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.m-tabs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
padding: 4px;
|
||||
border-radius: 999px;
|
||||
height: 58px;
|
||||
}
|
||||
|
||||
.m-tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: oklch(95% 0.01 210 / 0.8);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.m-tab[data-active="true"] {
|
||||
background: oklch(97% 0.01 210 / 0.2);
|
||||
color: var(--paper);
|
||||
box-shadow: inset 0 1px 0 oklch(100% 0 0 / 0.2);
|
||||
}
|
||||
|
||||
.m-tab-icon {
|
||||
font-size: 19px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.m-tab[data-active="true"] .m-tab-icon {
|
||||
color: var(--accent-dim);
|
||||
}
|
||||
|
||||
.m-tab-label {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.m-search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 58px;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 10px 0 18px;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.m-search > svg {
|
||||
flex: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.m-search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--paper);
|
||||
font: inherit;
|
||||
/* 16px or more, or iOS zooms the page into the field on focus. */
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.m-search input::placeholder {
|
||||
color: oklch(95% 0.01 210 / 0.55);
|
||||
}
|
||||
|
||||
.m-search input::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m-search-closed {
|
||||
cursor: pointer;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: oklch(95% 0.01 210 / 0.7);
|
||||
}
|
||||
|
||||
.m-search-clear {
|
||||
flex: none;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: oklch(97% 0.01 210 / 0.3);
|
||||
color: var(--sea-deep);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- mini player -- */
|
||||
|
||||
.m-mini {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 60px;
|
||||
padding: 0 8px 0 8px;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.m-mini-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m-mini-cover {
|
||||
height: 44px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
box-shadow: 0 3px 10px oklch(10% 0.04 210 / 0.4);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.m-mini-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m-mini-title,
|
||||
.m-mini-sub {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.m-mini-title {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.m-mini-sub {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: oklch(90% 0.02 210 / 0.7);
|
||||
}
|
||||
|
||||
.m-mini-button {
|
||||
flex: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--paper);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.m-mini-button:active {
|
||||
background: oklch(97% 0.01 210 / 0.15);
|
||||
}
|
||||
|
||||
.m-mini-progress {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 3px;
|
||||
background: var(--accent);
|
||||
transform-origin: left;
|
||||
transition: transform 0.5s linear;
|
||||
}
|
||||
Reference in New Issue
Block a user