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 { AppHeader } from "./components/AppHeader";
|
||||||
import { BrowseView } from "./components/BrowseView";
|
import { BrowseView } from "./components/BrowseView";
|
||||||
import { HelpOverlay } from "./components/HelpOverlay";
|
import { HelpOverlay } from "./components/HelpOverlay";
|
||||||
|
import { MobileBrowse } from "./components/mobile/MobileBrowse";
|
||||||
|
import { MobileDock } from "./components/mobile/MobileDock";
|
||||||
import { ParentPanel } from "./components/ParentPanel";
|
import { ParentPanel } from "./components/ParentPanel";
|
||||||
import { PlayerBar } from "./components/PlayerBar";
|
import { PlayerBar } from "./components/PlayerBar";
|
||||||
import { PlayView } from "./components/PlayView";
|
import { PlayView } from "./components/PlayView";
|
||||||
@@ -28,6 +30,7 @@ import { RemoteAssignPopup } from "./components/RemoteAssignPopup";
|
|||||||
import { TabRail } from "./components/TabRail";
|
import { TabRail } from "./components/TabRail";
|
||||||
import { useGridColumns } from "./hooks/useGridColumns";
|
import { useGridColumns } from "./hooks/useGridColumns";
|
||||||
import { useLibrary } from "./hooks/useLibrary";
|
import { useLibrary } from "./hooks/useLibrary";
|
||||||
|
import { usePhoneLayout } from "./hooks/usePhoneLayout";
|
||||||
import { usePlayerState } from "./hooks/usePlayerState";
|
import { usePlayerState } from "./hooks/usePlayerState";
|
||||||
import { DEFAULT_MANUAL_CONTROL, DEFAULT_TUNABLES } from "./lib/ambienceTunables";
|
import { DEFAULT_MANUAL_CONTROL, DEFAULT_TUNABLES } from "./lib/ambienceTunables";
|
||||||
import type { AmbienceTunables, ManualControl } from "./lib/ambienceTunables";
|
import type { AmbienceTunables, ManualControl } from "./lib/ambienceTunables";
|
||||||
@@ -35,7 +38,7 @@ import type { Action, UiState } from "./lib/keyboard";
|
|||||||
import { browseBackActions, GROUP_ORDER, handleKey, initialUiState, isRootShelf, toggleModeActions } from "./lib/keyboard";
|
import { browseBackActions, GROUP_ORDER, handleKey, initialUiState, isRootShelf, toggleModeActions } from "./lib/keyboard";
|
||||||
import { playPop } from "./lib/pop";
|
import { playPop } from "./lib/pop";
|
||||||
import { targetForAlbum } from "./lib/remote";
|
import { targetForAlbum } from "./lib/remote";
|
||||||
import type { Group, Results, SongHit } from "./lib/search";
|
import type { Group, Mode, Results, SongHit } from "./lib/search";
|
||||||
import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } 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";
|
import { SHOW_AMBIENCE, SHOW_CARD_BLUR, SHOW_GLASS_BLUR } from "./lib/theme";
|
||||||
|
|
||||||
@@ -64,6 +67,9 @@ export function App() {
|
|||||||
const library = useLibrary();
|
const library = useLibrary();
|
||||||
const connection = usePlayerState(library.reload);
|
const connection = usePlayerState(library.reload);
|
||||||
const [gridRef, columns] = useGridColumns();
|
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(
|
const [parentMode, setParentMode] = useState(
|
||||||
() => new URLSearchParams(location.search).get("parentMode") === "1",
|
() => 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 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 onNext = useCallback(() => run([{ type: "next" }]), [run]);
|
||||||
const onPrevious = useCallback(() => run([{ type: "previous" }]), [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
|
// 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
|
// 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.
|
// 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 = () => {
|
const onBack = () => {
|
||||||
if (ui.view === "play") {
|
if (ui.view === "play") {
|
||||||
setUi((previous) => ({ ...previous, view: "browse" }));
|
setUi((previous) => ({ ...previous, view: "browse" }));
|
||||||
@@ -430,6 +439,9 @@ export function App() {
|
|||||||
};
|
};
|
||||||
const onSelectPage = (page: UiState["page"]) =>
|
const onSelectPage = (page: UiState["page"]) =>
|
||||||
setUi((previous) => ({ ...previous, page: previous.page === page ? "music" : 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 (
|
return (
|
||||||
<div
|
<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
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
@@ -531,7 +564,7 @@ export function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
{ui.page === "room" && haConfig && <RoomView config={haConfig} />}
|
{ui.page === "room" && haConfig && <RoomView config={haConfig} phone={phone} />}
|
||||||
|
|
||||||
{ui.page === "typing" && (
|
{ui.page === "typing" && (
|
||||||
<TippenApp onExit={() => setUi((previous) => ({ ...previous, page: "music" }))} />
|
<TippenApp onExit={() => setUi((previous) => ({ ...previous, page: "music" }))} />
|
||||||
@@ -546,6 +579,7 @@ export function App() {
|
|||||||
selectedIndex={ui.modalTrackIndex}
|
selectedIndex={ui.modalTrackIndex}
|
||||||
onClose={() => setUi((previous) => ({ ...previous, openAlbumId: null }))}
|
onClose={() => setUi((previous) => ({ ...previous, openAlbumId: null }))}
|
||||||
onPlay={(trackIndex) => play(openAlbum.id, trackIndex)}
|
onPlay={(trackIndex) => play(openAlbum.id, trackIndex)}
|
||||||
|
phone={phone}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -563,13 +597,33 @@ export function App() {
|
|||||||
</CornerButton>
|
</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 && (
|
{ui.showHelp && (
|
||||||
<HelpOverlay onClose={() => setUi((previous) => ({ ...previous, showHelp: false }))} />
|
<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
|
<PlayerBar
|
||||||
state={state}
|
state={state}
|
||||||
album={currentAlbum}
|
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 } }) {
|
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;
|
if (!label) return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ interface Props {
|
|||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onPlay: (trackIndex: number) => void;
|
onPlay: (trackIndex: number) => void;
|
||||||
|
/** A bottom sheet with a smaller header, and no keyboard highlight to show. */
|
||||||
|
phone?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AlbumModal({
|
export function AlbumModal({
|
||||||
@@ -25,7 +27,9 @@ export function AlbumModal({
|
|||||||
selectedIndex,
|
selectedIndex,
|
||||||
onClose,
|
onClose,
|
||||||
onPlay,
|
onPlay,
|
||||||
|
phone = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const coverSize = phone ? 108 : 150;
|
||||||
const book = isBook(album);
|
const book = isBook(album);
|
||||||
const sheet = useRef<HTMLDivElement | null>(null);
|
const sheet = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -47,7 +51,11 @@ export function AlbumModal({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={ANIMATE_VIEW_TRANSITIONS ? "overlay backdrop-enter" : "overlay"}
|
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}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -56,20 +64,20 @@ export function AlbumModal({
|
|||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
background: "oklch(96% 0.012 210)",
|
background: "oklch(96% 0.012 210)",
|
||||||
borderRadius: 26,
|
borderRadius: phone ? "26px 26px 0 0" : 26,
|
||||||
padding: 26,
|
padding: phone ? "20px 16px calc(20px + env(safe-area-inset-bottom))" : 26,
|
||||||
width: 640,
|
width: phone ? "100%" : 640,
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
maxHeight: "100%",
|
maxHeight: "100%",
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
boxShadow: "0 24px 60px oklch(10% 0.04 210 / .55)",
|
boxShadow: "0 24px 60px oklch(10% 0.04 210 / .55)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", gap: 20, alignItems: "flex-start" }}>
|
<div style={{ display: "flex", gap: phone ? 14 : 20, alignItems: "flex-start" }}>
|
||||||
<div style={{ width: 150, flex: "none" }}>
|
<div style={{ width: coverSize, flex: "none" }}>
|
||||||
<Cover
|
<Cover
|
||||||
album={album}
|
album={album}
|
||||||
size={150}
|
size={coverSize}
|
||||||
radius={book ? "10px 18px 18px 10px" : "16px"}
|
radius={book ? "10px 18px 18px 10px" : "16px"}
|
||||||
label
|
label
|
||||||
locked={album.locked}
|
locked={album.locked}
|
||||||
@@ -78,7 +86,7 @@ export function AlbumModal({
|
|||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 26,
|
fontSize: phone ? 20 : 26,
|
||||||
fontWeight: 900,
|
fontWeight: 900,
|
||||||
color: "oklch(22% 0.03 210)",
|
color: "oklch(22% 0.03 210)",
|
||||||
lineHeight: 1.15,
|
lineHeight: 1.15,
|
||||||
@@ -117,9 +125,9 @@ export function AlbumModal({
|
|||||||
cursor: album.locked ? "default" : "pointer",
|
cursor: album.locked ? "default" : "pointer",
|
||||||
background: album.locked ? "oklch(80% 0.02 210)" : "var(--accent)",
|
background: album.locked ? "oklch(80% 0.02 210)" : "var(--accent)",
|
||||||
color: album.locked ? "oklch(40% 0.03 210)" : "#fff",
|
color: album.locked ? "oklch(40% 0.03 210)" : "#fff",
|
||||||
fontSize: 16,
|
fontSize: phone ? 15 : 16,
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
padding: "12px 22px",
|
padding: phone ? "10px 18px" : "12px 22px",
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -139,7 +147,7 @@ export function AlbumModal({
|
|||||||
clipPath: "polygon(6% 0%, 100% 50%, 6% 100%)",
|
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>
|
</button>
|
||||||
@@ -187,7 +195,7 @@ export function AlbumModal({
|
|||||||
background: current
|
background: current
|
||||||
? "oklch(70% 0.16 340 / .16)"
|
? "oklch(70% 0.16 340 / .16)"
|
||||||
: "oklch(30% 0.03 210 / .05)",
|
: "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,
|
outlineOffset: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,26 +5,36 @@ interface Props {
|
|||||||
mascot?: string;
|
mascot?: string;
|
||||||
/** Shown next to the title when the mouse is reachable but the firmware is not. */
|
/** Shown next to the title when the mouse is reachable but the firmware is not. */
|
||||||
status?: ReactNode;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
gap: 16,
|
gap: compact ? 10 : 16,
|
||||||
// The status bar is translucent in standalone mode, so make room for it.
|
// 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",
|
flex: "none",
|
||||||
position: "relative",
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 34,
|
fontSize: compact ? 28 : 34,
|
||||||
fontWeight: 900,
|
fontWeight: 900,
|
||||||
color: "var(--paper)",
|
color: "var(--paper)",
|
||||||
textShadow: "0 3px 14px oklch(15% 0.05 210 / .5)",
|
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
|
/** 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
|
* (pointer-only) and by the in-group category grid (keyboard-navigable, hence the
|
||||||
* optional `navIndex`/`selected`). */
|
* optional `navIndex`/`selected`). */
|
||||||
function CategoryTile({
|
export function CategoryTile({
|
||||||
entry,
|
entry,
|
||||||
navIndex,
|
navIndex,
|
||||||
selected,
|
selected,
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import { LightCard } from "./LightCard";
|
|||||||
import { SceneRow } from "./SceneRow";
|
import { SceneRow } from "./SceneRow";
|
||||||
import { ShutterCard } from "./ShutterCard";
|
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);
|
const ha = useHomeAssistant(config);
|
||||||
// Purely local: Home Assistant scenes have no "currently active" state of their own.
|
// Purely local: Home Assistant scenes have no "currently active" state of their own.
|
||||||
// Cleared by any manual device change, set by activating a scene.
|
// Cleared by any manual device change, set by activating a scene.
|
||||||
@@ -39,11 +41,18 @@ export function RoomView({ config }: { config: HaConfig }) {
|
|||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AppHeader title="Mein Zimmer" mascot="/dolphin-remote.png" />
|
<AppHeader title="Mein Zimmer" mascot="/dolphin-remote.png" compact={phone} />
|
||||||
<div
|
<div
|
||||||
// Right padding reserves room for the tab rail's own glass bar (see
|
// Right padding reserves room for the tab rail's own glass bar (see
|
||||||
// TAB_RAIL_CLEARANCE) so the device grid never renders under it.
|
// 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" }}>
|
<div style={{ maxWidth: 1180, margin: "0 auto" }}>
|
||||||
{config.scenes.length > 0 && (
|
{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 { App } from "./App";
|
||||||
import { PERF } from "./lib/perfSettings";
|
import { PERF } from "./lib/perfSettings";
|
||||||
import "./styles/app.css";
|
import "./styles/app.css";
|
||||||
|
import "./styles/mobile.css";
|
||||||
import "./styles/room.css";
|
import "./styles/room.css";
|
||||||
import "./styles/tippen.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