4 Commits

Author SHA1 Message Date
6981890be0 Web frontend: hide app title and row titles, regroup help sheet, drop Ctrl+d/u
- New SHOW_APP_TITLE switch (off): the mascot + title header goes, the status pill
  stays on a slim row of its own.
- SHOW_ROW_TITLES off; with only icons, the shelf icon moves into a column left of
  the tiles so an icon-only shelf is no taller than one without.
- Help sheet regrouped into sections in a compact two-column grid, and reachable
  from a small keyboard button (not on phones or the typing page).
- Ctrl+d/Ctrl+u half-page jumps removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:53:06 +02:00
5af5c54c62 Backfill podcast covers for episodes already on disk
A podcast's episode cover was only fetched right after the feed sync downloaded that
episode, so anything placed by hand (or downloaded before the og:image fallback
existed) never got one - in Wissen macht Ah only the three newest episodes had their
own art. The sync now fetches the cover of every on-disk episode in the feed window
that lacks a sidecar image. One that can't be found or fetched is recorded in
.missing-covers.json and left alone for the same 7-day backoff as failed downloads,
so a feed without per-episode art doesn't cost a page fetch per episode every poll.

The scanner's per-episode fingerprint now includes the sidecar cover, so one added
after the episode was first scanned invalidates its cached entry instead of being
ignored until the index is rebuilt. Episodes without a sidecar fingerprint exactly
as before, so existing caches stay valid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:53:06 +02:00
0f08e1d16c 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>
2026-10-02 21:33:26 +02:00
61104d1e25 sync-library.sh: run the closing report under sh, not the remote login shell
The report was an ssh command string, which the remote user's login shell runs - and
on the devices root's is fish, which rejects `if ...; then`. The copy itself finished,
but the script then exited 127 at the very end, so a successful sync looked failed.
Feed the report to `sh -s` on stdin instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:22:21 +02:00
20 changed files with 1604 additions and 170 deletions

View File

@@ -116,6 +116,9 @@ _FFMPEG_TIMEOUT: Final = 600.0
#: Where a show folder remembers episodes that failed to download, so a permanently
#: dead enclosure (e.g. pulled from the host's CDN) isn't retried every poll.
_FAILED_DOWNLOADS_FILENAME: Final = ".failed-downloads.json"
#: The same, for episodes on disk whose cover could not be found or fetched - so an
#: episode with no art anywhere doesn't cost a page fetch on every poll.
_MISSING_COVERS_FILENAME: Final = ".missing-covers.json"
#: How long a failed episode is left alone before it's given another chance - long
#: enough to stop hammering a dead URL every 6 hours, short enough that a genuinely
#: transient failure (a host outage, a flaky network) still recovers on its own.
@@ -379,15 +382,16 @@ def prune_show(folder: Path, keep: int | None, extensions: frozenset[str] | None
return removed
def _load_failed_downloads(folder: Path) -> dict[str, datetime]:
"""Filename -> when it last failed to download, for episodes ``sync_show`` should
leave alone until :data:`_RETRY_BACKOFF` has passed.
def _load_failed(folder: Path, record_name: str) -> dict[str, datetime]:
"""Filename -> when it last failed, for episodes ``sync_show`` should leave alone
until :data:`_RETRY_BACKOFF` has passed. ``record_name`` picks which record: failed
downloads, or covers that could not be found.
A missing or corrupt record is just an empty one - nothing here is precious enough
to raise over, matching the module's overall rule that bad input is logged and
skipped rather than fatal.
"""
path = folder / _FAILED_DOWNLOADS_FILENAME
path = folder / record_name
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
@@ -402,12 +406,12 @@ def _load_failed_downloads(folder: Path) -> dict[str, datetime]:
return {}
def _save_failed_downloads(folder: Path, failed: dict[str, datetime]) -> None:
path = folder / _FAILED_DOWNLOADS_FILENAME
def _save_failed(folder: Path, record_name: str, failed: dict[str, datetime]) -> None:
path = folder / record_name
if not failed:
path.unlink(missing_ok=True)
return
temp_path = folder / f".{_FAILED_DOWNLOADS_FILENAME}.tmp"
temp_path = folder / f".{record_name}.tmp"
payload = {filename: when.isoformat() for filename, when in failed.items()}
temp_path.write_text(json.dumps(payload), encoding="utf-8")
temp_path.replace(path)
@@ -529,6 +533,65 @@ async def resolve_episode_cover(client: httpx2.AsyncClient, episode: Episode) ->
return _extract_og_image(response.text)
def _has_sidecar_cover(audio_path: Path) -> bool:
return any(audio_path.with_suffix(ext).is_file() for ext in _IMAGE_EXTENSIONS)
async def _sync_covers(
client: httpx2.AsyncClient, folder: Path, episodes: list[Episode], now: datetime
) -> bool:
"""Fetch the sidecar cover of every episode in ``episodes`` that is on disk without
one. Returns whether any cover was added.
Covers every episode on disk, not just the ones this pass downloaded: episodes
placed by hand, or downloaded before a cover route existed (Wissen macht Ah's
``og:image`` fallback), would otherwise never get theirs. An episode whose cover
can't be found or fetched is remembered and left alone for :data:`_RETRY_BACKOFF`,
like a failed download, so a feed with no per-episode art doesn't cost one page
fetch per episode on every poll.
"""
missing = _load_failed(folder, _MISSING_COVERS_FILENAME)
on_disk: set[str] = set()
added = False
for episode in episodes:
filename = episode_filename(
episode.published, episode.title, episode.enclosure_type, episode.enclosure_url
)
audio_path = folder / filename
if not audio_path.is_file():
continue
on_disk.add(filename)
if _has_sidecar_cover(audio_path):
missing.pop(filename, None)
continue
last_miss = missing.get(filename)
if last_miss is not None and now - last_miss < _RETRY_BACKOFF:
continue
cover_url = await resolve_episode_cover(client, episode)
if cover_url is None:
missing[filename] = now
continue
cover_filename = episode_cover_filename(episode.published, episode.title, cover_url)
try:
await download_episode_cover(client, folder, cover_url, cover_filename)
except (httpx2.HTTPError, OSError) as exc:
_log.warning(
"Could not download cover for episode %r in %s: %s",
episode.title,
folder.name,
exc,
)
missing[filename] = now
continue
missing.pop(filename, None)
added = True
missing = {filename: when for filename, when in missing.items() if filename in on_disk}
_save_failed(folder, _MISSING_COVERS_FILENAME, missing)
return added
async def sync_show(
client: httpx2.AsyncClient,
folder: Path,
@@ -542,9 +605,9 @@ async def sync_show(
Both halves use the same ``keep``, which is what makes the folder settle - see
:data:`DEFAULT_EPISODE_LIMIT`.
Returns whether anything changed, deletions included: a pruned folder needs a
rescan just as much as a downloaded episode does, or the library keeps offering
tracks whose files are gone.
Returns whether anything changed, deletions and backfilled covers included: a
pruned folder needs a rescan just as much as a downloaded episode does, or the
library keeps offering tracks whose files are gone.
Errors - an unreachable feed, a malformed one, a single broken enclosure - are
logged and swallowed here so one bad show never stops the others or takes down the
@@ -559,8 +622,9 @@ async def sync_show(
_log.warning("Could not fetch podcast feed %s for %s: %s", feed_url, folder.name, exc)
return False
pending = missing_episodes(folder, newest_episodes(parse_feed(response.content), keep))
failed = _load_failed_downloads(folder)
window = newest_episodes(parse_feed(response.content), keep)
pending = missing_episodes(folder, window)
failed = _load_failed(folder, _FAILED_DOWNLOADS_FILENAME)
now = datetime.now(UTC)
changed = False
for episode, filename in pending:
@@ -579,23 +643,12 @@ async def sync_show(
_log.info("Downloaded new episode %r for %s", episode.title, folder.name)
changed = True
cover_url = await resolve_episode_cover(client, episode)
if cover_url:
cover_filename = episode_cover_filename(episode.published, episode.title, cover_url)
if not (folder / cover_filename).exists():
try:
await download_episode_cover(client, folder, cover_url, cover_filename)
except (httpx2.HTTPError, OSError) as exc:
_log.warning(
"Could not download cover for episode %r in %s: %s",
episode.title,
folder.name,
exc,
)
pending_filenames = {filename for _episode, filename in pending}
failed = {filename: when for filename, when in failed.items() if filename in pending_filenames}
_save_failed_downloads(folder, failed)
_save_failed(folder, _FAILED_DOWNLOADS_FILENAME, failed)
if await _sync_covers(client, folder, window, now):
changed = True
# After downloading, not before: an episode that just arrived is one of the newest
# and must be counted when deciding what falls off the end.

View File

@@ -262,7 +262,11 @@ def scan_episodes(
out: dict[str, tuple[Album, Fingerprint]] = {}
for path in paths:
identifier = album_id(root, path)
fingerprint = Fingerprint.of([path])
# The sidecar cover counts too: one fetched after the episode was first scanned
# (``podcast_feeds.py`` backfills them) must invalidate the cached entry, or the
# episode keeps showing the show's shared cover.
sidecars = [path.with_suffix(ext) for ext in _SIDECAR_COVER_EXTENSIONS]
fingerprint = Fingerprint.of([path, *(p for p in sidecars if p.is_file())])
cached = known.get(identifier)
if cached is not None and cached[1] == fingerprint:
out[identifier] = cached

View File

@@ -284,6 +284,25 @@ async def test_an_episodes_sidecar_cover_wins_over_the_shows_shared_one(config_d
assert red < 60 and blue > red, "falls back to the show's shared cover"
async def test_a_sidecar_cover_added_later_replaces_the_cached_one(config_dir: Path) -> None:
"""`podcast_feeds.py` backfills covers for episodes already on disk - the episode's
audio is untouched, so only the sidecar can tell the cache its entry is stale."""
from PIL import Image
podcast = config_dir / "music" / "Kinderpodcasts" / "Wissen macht Ah"
Image.new("RGB", (32, 32), (10, 20, 30)).save(podcast / "cover.jpg")
first = album_named(await build(config_dir), "Alt")
alt_audio = first.tracks[0].path
Image.new("RGB", (32, 32), (200, 40, 30)).save(alt_audio.with_suffix(".jpg"))
second = album_named(await build(config_dir), "Alt")
assert second.cover is not None
with Image.open(second.cover) as image:
red, green, _blue = image.convert("RGB").getpixel((0, 0)) # type: ignore[misc]
assert red > 150 and green < 90, "the new sidecar should replace the shared cover"
# --------------------------------------------------------------------------- cache

View File

@@ -411,6 +411,83 @@ async def test_sync_show_survives_a_failed_cover_download(tmp_path: Path) -> Non
assert not (folder / "20250101 - Unique Cover.jpg").exists()
_FEED_WITH_LINKS = b"""<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>Link Show</title>
<link>http://example.com/show</link>
<item>
<title>Old One</title>
<link>http://example.com/episodes/old</link>
<pubDate>Wed, 01 Jan 2025 08:00:00 GMT</pubDate>
<enclosure url="http://example.com/ep1.mp3" length="100" type="audio/mpeg" />
<guid>ep1</guid>
</item>
</channel>
</rss>
"""
@pytest.mark.asyncio
async def test_sync_show_backfills_the_cover_of_an_episode_already_on_disk(
tmp_path: Path,
) -> None:
# Placed by hand (or downloaded before covers were fetched): the audio is there,
# so nothing is downloaded - but its cover still has to be fetched.
folder = tmp_path / "show"
folder.mkdir()
(folder / "20250101 - Old One.mp3").write_bytes(b"audio-bytes")
requests: list[str] = []
def handler(request: httpx2.Request) -> httpx2.Response:
url = str(request.url)
requests.append(url)
if url == "http://example.com/feed.xml":
return httpx2.Response(200, content=_FEED_WITH_LINKS)
if url == "http://example.com/episodes/old":
html = '<meta property="og:image" content="http://example.com/old.png">'
return httpx2.Response(200, text=html)
if url == "http://example.com/old.png":
return httpx2.Response(200, content=b"cover-bytes")
return httpx2.Response(500)
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
changed = await sync_show(client, folder, "http://example.com/feed.xml")
requests.clear()
changed_again = await sync_show(client, folder, "http://example.com/feed.xml")
assert changed is True
assert (folder / "20250101 - Old One.png").read_bytes() == b"cover-bytes"
# Once the sidecar exists, a later poll doesn't go looking for it again.
assert changed_again is False
assert requests == ["http://example.com/feed.xml"]
@pytest.mark.asyncio
async def test_sync_show_does_not_look_for_a_missing_cover_again_within_the_backoff(
tmp_path: Path,
) -> None:
folder = tmp_path / "show"
folder.mkdir()
(folder / "20250101 - Old One.mp3").write_bytes(b"audio-bytes")
page_requests: list[str] = []
def handler(request: httpx2.Request) -> httpx2.Response:
url = str(request.url)
if url == "http://example.com/feed.xml":
return httpx2.Response(200, content=_FEED_WITH_LINKS)
page_requests.append(url)
return httpx2.Response(200, text="<html>no og:image here</html>")
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
changed = await sync_show(client, folder, "http://example.com/feed.xml")
await sync_show(client, folder, "http://example.com/feed.xml")
assert changed is False
assert page_requests == ["http://example.com/episodes/old"]
assert (folder / podcast_feeds._MISSING_COVERS_FILENAME).exists()
# ---------------------------------------------------------------------- video shows

View File

@@ -224,14 +224,17 @@ if [[ $DRY_RUN -eq 1 ]]; then
exit 0
fi
ssh "$SSH_TARGET" "
echo 'library: '\$(du -sh '$DEST' 2>/dev/null | cut -f1)
echo 'free: '\$(df -Ph '$DEST' | awk 'NR==2 {print \$4}')
if [ -d '${DEST}/.musicmouse-cache' ]; then
echo 'covers: '\$(find '${DEST}/.musicmouse-cache/covers' -type f 2>/dev/null | wc -l)' files'
echo 'analysis: '\$(find '${DEST}/.musicmouse-cache/analysis' -type f 2>/dev/null | wc -l)' files'
fi
"
# Through `sh -s` rather than as an ssh command string: that string is run by the
# remote user's login shell, and on the devices root's is fish, which rejects
# `if ...; then`. The heredoc expands $DEST here; the escaped \$ ones run remotely.
ssh "$SSH_TARGET" sh -s <<EOF
echo "library: \$(du -sh '$DEST' 2>/dev/null | cut -f1)"
echo "free: \$(df -Ph '$DEST' | awk 'NR==2 {print \$4}')"
if [ -d '${DEST}/.musicmouse-cache' ]; then
echo "covers: \$(find '${DEST}/.musicmouse-cache/covers' -type f 2>/dev/null | wc -l) files"
echo "analysis: \$(find '${DEST}/.musicmouse-cache/analysis' -type f 2>/dev/null | wc -l) files"
fi
EOF
cat <<EOF

View File

@@ -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}
@@ -609,6 +663,31 @@ export function App() {
)}
{PERF_ENABLED && perfOpen && <PerfPanel onClose={() => setPerfOpen(false)} />}
{/* The F1 sheet, reachable without knowing about F1. Not on a phone (no keyboard)
or the typing page (TippenApp has its own help and owns every key there). */}
{!phone && ui.page !== "typing" && !ui.showHelp && (
<button
onClick={() => setUi((previous) => ({ ...previous, showHelp: true }))}
aria-label="Tastenkürzel"
style={{
position: "absolute",
left: PERF_ENABLED && !perfOpen ? 48 : 8,
bottom: 8,
zIndex: 5,
width: 32,
height: 32,
border: "none",
borderRadius: 999,
cursor: "pointer",
background: "oklch(15% 0.03 210 / .35)",
color: "#fff",
opacity: 0.5,
}}
>
⌨
</button>
)}
{(!state || library.loading) && <Splash error={library.error} />}
{debugDynamicUI && debugSnapshot && (
@@ -669,8 +748,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

View File

@@ -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,
}}
>

View File

@@ -1,30 +1,61 @@
import type { ReactNode } from "react";
import { SHOW_APP_TITLE } from "../lib/theme";
interface Props {
title?: string;
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) {
if (!SHOW_APP_TITLE) {
// Still clear the translucent standalone-mode status bar, and keep the status
// pill - it is the only place an offline connection shows on these screens.
return (
<div
style={{
display: "flex",
justifyContent: "center",
padding: status
? `calc(${compact ? 8 : 10}px + env(safe-area-inset-top)) 16px 0`
: "env(safe-area-inset-top) 0 0",
flex: "none",
}}
>
{status}
</div>
);
}
const mascotSize = compact ? 44 : 64;
return (
<div
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)",

View File

@@ -225,15 +225,20 @@ function BrowseViewImpl({
? "4px solid var(--paper)"
: undefined,
outlineOffset: 2,
// Without a title the icon has no header to sit in, so it takes a column
// of its own beside the tiles rather than a row above them.
...(SHOW_ROW_ICONS && !SHOW_ROW_TITLES
? { display: "flex", alignItems: "center", gap: 12 }
: {}),
...glassTint(GROUP_HUE[shelfGroup]),
}}
>
{(SHOW_ROW_TITLES || SHOW_ROW_ICONS) && (
{SHOW_ROW_TITLES && (
<div
style={{
display: "flex",
alignItems: "center",
gap: SHOW_ROW_TITLES && SHOW_ROW_ICONS ? 10 : 6,
gap: 10,
marginBottom: 12,
fontSize: 20,
fontWeight: 900,
@@ -241,10 +246,14 @@ function BrowseViewImpl({
}}
>
{SHOW_ROW_ICONS && <span className="group-icon">{GROUP_ICON[shelfGroup]}</span>}
{SHOW_ROW_TITLES && GROUP_LABEL[shelfGroup]}
{SHOW_ROW_TITLES && <span style={{ opacity: 0.6, fontSize: 17 }}>›</span>}
{GROUP_LABEL[shelfGroup]}
<span style={{ opacity: 0.6, fontSize: 17 }}>›</span>
</div>
)}
{SHOW_ROW_ICONS && !SHOW_ROW_TITLES && (
<span className="group-icon">{GROUP_ICON[shelfGroup]}</span>
)}
<div style={{ flex: 1, minWidth: 0 }}>
{shelfCategories.length === 0 ? (
<div
style={{
@@ -275,6 +284,7 @@ function BrowseViewImpl({
</ShelfRow>
)}
</div>
</div>
))}
</div>
</>
@@ -727,7 +737,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,

View File

@@ -1,66 +1,104 @@
/** The Zaubertasten sheet (F1). Mirrors lib/keyboard.ts - if a binding changes there,
* it changes here. */
const KEYS: Array<[caps: string[], label: string]> = [
import { Fragment } from "react";
const SECTIONS: Array<[title: string, rows: Array<[caps: string[], label: string]>]> = [
[
"Abspielen",
[
[["LEER"], "Play / Pause"],
[["SHIFT+H", "SHIFT+L"], "Song zurück / vor"],
[["SHIFT+J", "SHIFT+K"], "Leiser / lauter"],
[["SHIFT+M"], "Stumm"],
[["⇧H", "⇧L"], "Song zurück / vor"],
[["⇧J", "⇧K"], "Leiser / lauter"],
[["⇧M"], "Stumm"],
[["⇧←", "⇧→"], "15 Sekunden zurück / vor"],
],
],
[
"Auswählen",
[
[["← ↑ ↓ →"], "Auswahl bewegen"],
[["CTRL+H/J/K/L"], "Auswahl bewegen (Raster oder Titelliste)"],
[["CTRL+D", "CTRL+U"], "Eine halbe Seite weiter / zurück"],
[["A-Z"], "Album oder Hörbuch suchen"],
[["?"], "Einzelne Titel suchen"],
[["TAB"], "Musik / Hörbücher / Podcasts"],
[["STRG+HJKL"], "Auswahl bewegen"],
[["ENTER"], "Auswahl abspielen"],
[["⇧ENTER"], "Titelliste öffnen"],
[["A", "0-9"], "Aktuellen Titel einer Fernbedienungs-Taste zuweisen"],
[["TAB"], "Musik / Hörbücher / Podcasts"],
],
],
[
"Suchen",
[
[["A-Z"], "Album oder Hörbuch suchen"],
[["?"], "Einzelne Titel suchen"],
[["/"], "Suche öffnen / Alben ↔ Titel"],
],
],
[
"Sonstiges",
[
[["A", "0-9"], "Titel auf Fernbedienungs-Taste legen"],
[["F1"], "Diese Hilfe"],
[["ESC"], "Schließen / Suche löschen"],
],
],
];
const CAP_STYLE = { minWidth: 0, padding: "3px 7px", fontSize: 11 } as const;
export function HelpOverlay({ onClose }: { onClose: () => void }) {
return (
<div className="overlay" style={{ zIndex: 5 }} onClick={onClose}>
<div className="sheet" style={{ padding: "26px 30px", width: 330 }}>
<div className="sheet" style={{ padding: "18px 22px" }}>
<div
style={{
fontSize: 17,
fontSize: 15,
fontWeight: 800,
color: "var(--ink)",
marginBottom: 14,
marginBottom: 4,
letterSpacing: 0.3,
}}
>
⌨️ Zaubertasten
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{KEYS.map(([caps, label]) => (
<div key={label} style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ display: "flex", gap: 4 }}>
{caps.map((cap) => (
<div key={cap} className="key-cap" style={{ minWidth: caps.length > 1 ? 0 : 56 }}>
{cap}
</div>
))}
</div>
<div style={{ fontSize: 14, fontWeight: 700, color: "oklch(35% 0.03 210)" }}>
{label}
</div>
</div>
))}
</div>
<div
style={{
marginTop: 16,
fontSize: 12,
fontWeight: 600,
color: "oklch(50% 0.03 210 / .8)",
textAlign: "center",
display: "grid",
gridTemplateColumns: "max-content max-content",
columnGap: 14,
rowGap: 5,
alignItems: "center",
}}
>
Tippe irgendwohin, um zu schließen
{SECTIONS.map(([title, rows]) => (
<Fragment key={title}>
<div
style={{
gridColumn: "1 / -1",
marginTop: 10,
fontSize: 10,
fontWeight: 800,
textTransform: "uppercase",
letterSpacing: 1,
color: "oklch(50% 0.03 210 / .8)",
}}
>
{title}
</div>
{rows.map(([caps, label]) => (
<Fragment key={label + caps.join()}>
<div style={{ display: "flex", gap: 3, justifyContent: "flex-end" }}>
{caps.map((cap) => (
<span key={cap} className="key-cap" style={CAP_STYLE}>
{cap}
</span>
))}
</div>
<div style={{ fontSize: 13, fontWeight: 600, color: "oklch(35% 0.03 210)" }}>
{label}
</div>
</Fragment>
))}
</Fragment>
))}
</div>
</div>
</div>

View File

@@ -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 && (

View 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);

View 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>
);
}

View 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;
}

View 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;
}

View File

@@ -122,23 +122,6 @@ describe("keyboard", () => {
);
});
it("jumps a half-page with Ctrl+d/Ctrl+u", () => {
const ui = { ...initialUiState, group: "music" as const, cols: 2 };
const grid: Results = {
songs: [],
categories: Array.from({ length: 10 }, (_, i) => ({ key: String(i), albums: [] })),
albums: [],
total: 10,
};
// 3 rows of 2 columns each = 6.
expect(handleKey(key("d", { ctrlKey: true }), { ...ui, selIndex: 0 }, grid)).toEqual([
{ type: "ui", patch: { selIndex: 6 } },
]);
expect(handleKey(key("u", { ctrlKey: true }), { ...ui, selIndex: 8 }, grid)).toEqual([
{ type: "ui", patch: { selIndex: 2 } },
]);
});
it("does nothing with Ctrl+hjkl outside the browse grid, so it never fights the play view's own bindings", () => {
expect(press("l", { ...initialUiState, view: "play" }, { ctrlKey: true })).toEqual([]);
});

View File

@@ -85,9 +85,6 @@ export const GROUP_ORDER: Group[] = ["music", "audiobooks", "podcasts"];
export const VOLUME_STEP = 10;
export const SEEK_STEP = 15;
/** How far Ctrl+d/Ctrl+u jump through the grid - vim's own half-page scroll, applied to
* rows of cards instead of lines of text. */
export const PAGE_ROWS = 3;
/** Shift+h/j/k/l/m: transport, reachable from anywhere, matching the muscle memory of
* every other vim-ish media app. Keyed on the exact shifted letter, so this only ever
@@ -292,8 +289,7 @@ export function handleKey(
// Ctrl+hjkl moves the highlight in whichever list is on screen - the open album's
// track list if one is open, otherwise the browse grid - the same job the arrow keys
// already do, just reachable without leaving the home row. Ctrl+d/u are vim's own
// half-page jump, applied to rows of cards.
// already do, just reachable without leaving the home row.
if (event.ctrlKey || event.metaKey) {
if (state.openAlbumId !== null) {
switch (key.toLowerCase()) {
@@ -317,7 +313,7 @@ export function handleKey(
}
if (state.view !== "browse" || state.showHelp) return [];
const lower = key.toLowerCase();
if (!"hjkldu".includes(lower)) return [];
if (!"hjkl".includes(lower)) return [];
if (isRootShelf(state)) {
switch (lower) {
case "h":
@@ -325,10 +321,8 @@ export function handleKey(
case "l":
return moveRootShelf(state, 1, 0, rootShelves);
case "j":
case "d":
return moveRootShelf(state, 0, 1, rootShelves);
case "k":
case "u":
return moveRootShelf(state, 0, -1, rootShelves);
default:
return [];
@@ -343,10 +337,6 @@ export function handleKey(
return moveSelection(state, results, 0, 1);
case "k":
return moveSelection(state, results, 0, -1);
case "d":
return moveSelection(state, results, 0, PAGE_ROWS);
case "u":
return moveSelection(state, results, 0, -PAGE_ROWS);
default:
return [];
}

View File

@@ -21,12 +21,19 @@ import type { UiState } from "./keyboard";
export const SHOW_ROW_TINT = false;
/** Show each shelf's title text ("Musik", "Hörbücher", "Podcasts") above its tiles. */
export const SHOW_ROW_TITLES = true;
export const SHOW_ROW_TITLES = false;
/** Show the small icon badge (🎵/📖/🎙️) beside the title. Stands alone fine with
* `SHOW_ROW_TITLES` off (an icon-only header), but the original look needs both. */
/** Show the small icon badge (🎵/📖/🎙️) beside the title. With `SHOW_ROW_TITLES`
* off it moves out of the header into a column left of the tiles, so an icon-only
* shelf is no taller than one with neither; the original look needs both. */
export const SHOW_ROW_ICONS = true;
/** Show the mascot + title header ("Musik Delfin", "Mein Zimmer") at the top of the
* browse and room screens. Off reclaims its vertical space; the connection/figure
* status pill it carries still shows, on a slim row of its own, whenever it has
* something to say. */
export const SHOW_APP_TITLE = false;
/** Vertical gap between shelves on the root browse screen, in px. */
export const ROW_SPACING = 25;

View File

@@ -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
View 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;
}