From 0f08e1d16c386db76b41251b07605fb190380fef Mon Sep 17 00:00:00 2001 From: Martin Bauer Date: Fri, 2 Oct 2026 21:33:26 +0200 Subject: [PATCH] Web frontend: touch layout for phones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/App.tsx | 72 ++- web/src/components/AlbumModal.tsx | 32 +- web/src/components/AppHeader.tsx | 20 +- web/src/components/BrowseView.tsx | 2 +- web/src/components/RoomView.tsx | 15 +- web/src/components/mobile/MobileBrowse.tsx | 278 ++++++++++ web/src/components/mobile/MobileDock.tsx | 230 +++++++++ web/src/hooks/useKeyboardInset.ts | 26 + web/src/hooks/usePhoneLayout.ts | 23 + web/src/main.tsx | 1 + web/src/styles/mobile.css | 561 +++++++++++++++++++++ 11 files changed, 1232 insertions(+), 28 deletions(-) create mode 100644 web/src/components/mobile/MobileBrowse.tsx create mode 100644 web/src/components/mobile/MobileDock.tsx create mode 100644 web/src/hooks/useKeyboardInset.ts create mode 100644 web/src/hooks/usePhoneLayout.ts create mode 100644 web/src/styles/mobile.css diff --git a/web/src/App.tsx b/web/src/App.tsx index cf99c0b..e2686f7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 (
)} - {ui.page === "music" && ui.view === "browse" && state && ( + {phone && ui.page === "music" && ui.view === "browse" && state && ( +
+ +
+ )} + + {!phone && ui.page === "music" && ui.view === "browse" && state && (
- {ui.page === "room" && haConfig && } + {ui.page === "room" && haConfig && } {ui.page === "typing" && ( 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() { )} - + {!phone && } + + {phone && (ui.page === "room" || (ui.page === "music" && ui.view === "browse")) && ( + 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 && ( setUi((previous) => ({ ...previous, showHelp: false }))} /> )} - {ui.page === "music" && ui.view === "browse" && state && currentAlbum && ( + {!phone && ui.page === "music" && ui.view === "browse" && state && currentAlbum && ( 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(null); @@ -47,7 +51,11 @@ export function AlbumModal({ return (
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)", }} > -
-
+
+
- {book ? "Hörbuch abspielen" : "Alle Songs abspielen"} + {phone ? "Abspielen" : book ? "Hörbuch abspielen" : "Alle Songs abspielen"} )} @@ -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, }} > diff --git a/web/src/components/AppHeader.tsx b/web/src/components/AppHeader.tsx index a3a05f8..8fa84d4 100644 --- a/web/src/components/AppHeader.tsx +++ b/web/src/components/AppHeader.tsx @@ -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 (
- +
- +
{config.scenes.length > 0 && ( diff --git a/web/src/components/mobile/MobileBrowse.tsx b/web/src/components/mobile/MobileBrowse.tsx new file mode 100644 index 0000000..8923de1 --- /dev/null +++ b/web/src/components/mobile/MobileBrowse.tsx @@ -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 ( +
+
+
+ onMode("albums")}> + Alben + + onMode("tracks")}> + Titel + +
+
+ + {!effectiveSearch(typed) ? ( + Was möchtest du hören? + ) : mode === "tracks" ? ( + results.songs.length ? ( +
+ {results.songs.map((hit) => ( + onPlaySong(hit)} + /> + ))} +
+ ) : ( + Nichts gefunden — probier andere Buchstaben! + ) + ) : results.albums.length ? ( + + ) : ( + Nichts gefunden — probier andere Buchstaben! + )} +
+ ); + } + + if (category !== null) { + return ( +
+
+ +
{category}
+
+
+
+ {GROUP_ICON[group]} {GROUP_LABEL[group]} · {results.albums.length}{" "} + {results.albums.length === 1 ? "Eintrag" : "Einträge"} +
+ +
+ ); + } + + return ( +
+
+ +
Musik Delfin
+ {statusLabel && ( +
+ {statusLabel} +
+ )} +
+
+
+ {GROUPS.map((candidate) => ( + onEnterGroup(candidate, null)} + > + {GROUP_ICON[candidate]} {GROUP_LABEL[candidate]} + + ))} +
+
+ {results.categories.length ? ( +
+ {results.categories.map((entry) => ( + onEnterGroup(group, entry.key)} /> + ))} +
+ ) : ( + Noch nichts hier + )} +
+ ); +} + +function AlbumGrid({ + albums, + currentAlbumId, + onOpen, +}: { + albums: Album[]; + currentAlbumId: string | null; + onOpen: (album: Album, navIndex: number) => void; +}) { + return ( +
+ {albums.map((album, index) => { + const podcast = groupOf(album) === "podcasts"; + return ( + + ); + })} +
+ ); +} + +function SongRow({ hit, current, onClick }: { hit: SongHit; current: boolean; onClick: () => void }) { + return ( + + ); +} + +function Segment({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +function Hint({ children }: { children: ReactNode }) { + return ( +
+ +
{children}
+
+ ); +} + +function ChevronLeft() { + return ( + + + + ); +} + +/** 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); diff --git a/web/src/components/mobile/MobileDock.tsx b/web/src/components/mobile/MobileDock.tsx new file mode 100644 index 0000000..a3e681e --- /dev/null +++ b/web/src/components/mobile/MobileDock.tsx @@ -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(null); + const tabs = TABS.filter((tab) => tab.page !== "room" || showRoom); + const canSearch = page === "music"; + + return ( +
0}> + {state && album && !searching && ( + + )} + +
+ {searching ? ( + <> + +
{ + // Enter only puts the keyboard away - results are already live. + event.preventDefault(); + input.current?.blur(); + }} + > + + onSearchChange(event.target.value)} + /> + {typed && ( + + )} + + + ) : ( + <> + {tabs.length > 1 && ( + + )} + {canSearch && + (tabs.length > 1 ? ( + + ) : ( + + ))} + + )} +
+
+ ); +} + +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 ( +
+ + + +
+
+ ); +} + +function SearchIcon() { + return ( + + + + + ); +} + +function CloseIcon({ size = 16 }: { size?: number }) { + return ( + + + + ); +} + +function PlayIcon() { + return ( + + + + ); +} + +function PauseIcon() { + return ( + + + + + ); +} + +function NextIcon() { + return ( + + + + ); +} diff --git a/web/src/hooks/useKeyboardInset.ts b/web/src/hooks/useKeyboardInset.ts new file mode 100644 index 0000000..da2c9d3 --- /dev/null +++ b/web/src/hooks/useKeyboardInset.ts @@ -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; +} diff --git a/web/src/hooks/usePhoneLayout.ts b/web/src/hooks/usePhoneLayout.ts new file mode 100644 index 0000000..912360d --- /dev/null +++ b/web/src/hooks/usePhoneLayout.ts @@ -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; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 8194c57..e5a3d80 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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"; diff --git a/web/src/styles/mobile.css b/web/src/styles/mobile.css new file mode 100644 index 0000000..04a39de --- /dev/null +++ b/web/src/styles/mobile.css @@ -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; +}