Compare commits
4 Commits
683cda35cb
...
6981890be0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6981890be0 | |||
| 5af5c54c62 | |||
| 0f08e1d16c | |||
| 61104d1e25 |
@@ -116,6 +116,9 @@ _FFMPEG_TIMEOUT: Final = 600.0
|
|||||||
#: Where a show folder remembers episodes that failed to download, so a permanently
|
#: 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.
|
#: dead enclosure (e.g. pulled from the host's CDN) isn't retried every poll.
|
||||||
_FAILED_DOWNLOADS_FILENAME: Final = ".failed-downloads.json"
|
_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
|
#: 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
|
#: 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.
|
#: 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
|
return removed
|
||||||
|
|
||||||
|
|
||||||
def _load_failed_downloads(folder: Path) -> dict[str, datetime]:
|
def _load_failed(folder: Path, record_name: str) -> dict[str, datetime]:
|
||||||
"""Filename -> when it last failed to download, for episodes ``sync_show`` should
|
"""Filename -> when it last failed, for episodes ``sync_show`` should leave alone
|
||||||
leave alone until :data:`_RETRY_BACKOFF` has passed.
|
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
|
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
|
to raise over, matching the module's overall rule that bad input is logged and
|
||||||
skipped rather than fatal.
|
skipped rather than fatal.
|
||||||
"""
|
"""
|
||||||
path = folder / _FAILED_DOWNLOADS_FILENAME
|
path = folder / record_name
|
||||||
try:
|
try:
|
||||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -402,12 +406,12 @@ def _load_failed_downloads(folder: Path) -> dict[str, datetime]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _save_failed_downloads(folder: Path, failed: dict[str, datetime]) -> None:
|
def _save_failed(folder: Path, record_name: str, failed: dict[str, datetime]) -> None:
|
||||||
path = folder / _FAILED_DOWNLOADS_FILENAME
|
path = folder / record_name
|
||||||
if not failed:
|
if not failed:
|
||||||
path.unlink(missing_ok=True)
|
path.unlink(missing_ok=True)
|
||||||
return
|
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()}
|
payload = {filename: when.isoformat() for filename, when in failed.items()}
|
||||||
temp_path.write_text(json.dumps(payload), encoding="utf-8")
|
temp_path.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
temp_path.replace(path)
|
temp_path.replace(path)
|
||||||
@@ -529,6 +533,65 @@ async def resolve_episode_cover(client: httpx2.AsyncClient, episode: Episode) ->
|
|||||||
return _extract_og_image(response.text)
|
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(
|
async def sync_show(
|
||||||
client: httpx2.AsyncClient,
|
client: httpx2.AsyncClient,
|
||||||
folder: Path,
|
folder: Path,
|
||||||
@@ -542,9 +605,9 @@ async def sync_show(
|
|||||||
Both halves use the same ``keep``, which is what makes the folder settle - see
|
Both halves use the same ``keep``, which is what makes the folder settle - see
|
||||||
:data:`DEFAULT_EPISODE_LIMIT`.
|
:data:`DEFAULT_EPISODE_LIMIT`.
|
||||||
|
|
||||||
Returns whether anything changed, deletions included: a pruned folder needs a
|
Returns whether anything changed, deletions and backfilled covers included: a
|
||||||
rescan just as much as a downloaded episode does, or the library keeps offering
|
pruned folder needs a rescan just as much as a downloaded episode does, or the
|
||||||
tracks whose files are gone.
|
library keeps offering tracks whose files are gone.
|
||||||
|
|
||||||
Errors - an unreachable feed, a malformed one, a single broken enclosure - are
|
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
|
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)
|
_log.warning("Could not fetch podcast feed %s for %s: %s", feed_url, folder.name, exc)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
pending = missing_episodes(folder, newest_episodes(parse_feed(response.content), keep))
|
window = newest_episodes(parse_feed(response.content), keep)
|
||||||
failed = _load_failed_downloads(folder)
|
pending = missing_episodes(folder, window)
|
||||||
|
failed = _load_failed(folder, _FAILED_DOWNLOADS_FILENAME)
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
changed = False
|
changed = False
|
||||||
for episode, filename in pending:
|
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)
|
_log.info("Downloaded new episode %r for %s", episode.title, folder.name)
|
||||||
changed = True
|
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}
|
pending_filenames = {filename for _episode, filename in pending}
|
||||||
failed = {filename: when for filename, when in failed.items() if filename in pending_filenames}
|
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
|
# 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.
|
# and must be counted when deciding what falls off the end.
|
||||||
|
|||||||
@@ -262,7 +262,11 @@ def scan_episodes(
|
|||||||
out: dict[str, tuple[Album, Fingerprint]] = {}
|
out: dict[str, tuple[Album, Fingerprint]] = {}
|
||||||
for path in paths:
|
for path in paths:
|
||||||
identifier = album_id(root, path)
|
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)
|
cached = known.get(identifier)
|
||||||
if cached is not None and cached[1] == fingerprint:
|
if cached is not None and cached[1] == fingerprint:
|
||||||
out[identifier] = cached
|
out[identifier] = cached
|
||||||
|
|||||||
@@ -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"
|
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
|
# --------------------------------------------------------------------------- cache
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
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
|
# ---------------------------------------------------------------------- video shows
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -224,14 +224,17 @@ if [[ $DRY_RUN -eq 1 ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ssh "$SSH_TARGET" "
|
# Through `sh -s` rather than as an ssh command string: that string is run by the
|
||||||
echo 'library: '\$(du -sh '$DEST' 2>/dev/null | cut -f1)
|
# remote user's login shell, and on the devices root's is fish, which rejects
|
||||||
echo 'free: '\$(df -Ph '$DEST' | awk 'NR==2 {print \$4}')
|
# `if ...; then`. The heredoc expands $DEST here; the escaped \$ ones run remotely.
|
||||||
if [ -d '${DEST}/.musicmouse-cache' ]; then
|
ssh "$SSH_TARGET" sh -s <<EOF
|
||||||
echo 'covers: '\$(find '${DEST}/.musicmouse-cache/covers' -type f 2>/dev/null | wc -l)' files'
|
echo "library: \$(du -sh '$DEST' 2>/dev/null | cut -f1)"
|
||||||
echo 'analysis: '\$(find '${DEST}/.musicmouse-cache/analysis' -type f 2>/dev/null | wc -l)' files'
|
echo "free: \$(df -Ph '$DEST' | awk 'NR==2 {print \$4}')"
|
||||||
fi
|
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
|
cat <<EOF
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -609,6 +663,31 @@ export function App() {
|
|||||||
)}
|
)}
|
||||||
{PERF_ENABLED && perfOpen && <PerfPanel onClose={() => setPerfOpen(false)} />}
|
{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} />}
|
{(!state || library.loading) && <Splash error={library.error} />}
|
||||||
|
|
||||||
{debugDynamicUI && debugSnapshot && (
|
{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 } }) {
|
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,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,30 +1,61 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { SHOW_APP_TITLE } from "../lib/theme";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title?: string;
|
title?: string;
|
||||||
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) {
|
||||||
|
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 (
|
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)",
|
||||||
|
|||||||
@@ -225,15 +225,20 @@ function BrowseViewImpl({
|
|||||||
? "4px solid var(--paper)"
|
? "4px solid var(--paper)"
|
||||||
: undefined,
|
: undefined,
|
||||||
outlineOffset: 2,
|
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]),
|
...glassTint(GROUP_HUE[shelfGroup]),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(SHOW_ROW_TITLES || SHOW_ROW_ICONS) && (
|
{SHOW_ROW_TITLES && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: SHOW_ROW_TITLES && SHOW_ROW_ICONS ? 10 : 6,
|
gap: 10,
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: 900,
|
fontWeight: 900,
|
||||||
@@ -241,39 +246,44 @@ function BrowseViewImpl({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{SHOW_ROW_ICONS && <span className="group-icon">{GROUP_ICON[shelfGroup]}</span>}
|
{SHOW_ROW_ICONS && <span className="group-icon">{GROUP_ICON[shelfGroup]}</span>}
|
||||||
{SHOW_ROW_TITLES && GROUP_LABEL[shelfGroup]}
|
{GROUP_LABEL[shelfGroup]}
|
||||||
{SHOW_ROW_TITLES && <span style={{ opacity: 0.6, fontSize: 17 }}>›</span>}
|
<span style={{ opacity: 0.6, fontSize: 17 }}>›</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{shelfCategories.length === 0 ? (
|
{SHOW_ROW_ICONS && !SHOW_ROW_TITLES && (
|
||||||
<div
|
<span className="group-icon">{GROUP_ICON[shelfGroup]}</span>
|
||||||
style={{
|
|
||||||
textAlign: "center",
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "oklch(88% 0.02 210 / .6)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Noch nichts hier
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ShelfRow focusedIndex={focusedRow === index ? focusedCol : null}>
|
|
||||||
{shelfCategories.map((entry, tileIndex) => (
|
|
||||||
<CategoryTile
|
|
||||||
key={entry.key}
|
|
||||||
entry={entry}
|
|
||||||
navIndex={tileIndex}
|
|
||||||
selected={focusedRow === index && focusedCol === tileIndex}
|
|
||||||
onClick={(event) => {
|
|
||||||
// Otherwise this bubbles to the panel's own onClick, which would
|
|
||||||
// open the group root instead of the category just clicked.
|
|
||||||
event.stopPropagation();
|
|
||||||
onEnterGroup(shelfGroup, entry.key);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ShelfRow>
|
|
||||||
)}
|
)}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{shelfCategories.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "oklch(88% 0.02 210 / .6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Noch nichts hier
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ShelfRow focusedIndex={focusedRow === index ? focusedCol : null}>
|
||||||
|
{shelfCategories.map((entry, tileIndex) => (
|
||||||
|
<CategoryTile
|
||||||
|
key={entry.key}
|
||||||
|
entry={entry}
|
||||||
|
navIndex={tileIndex}
|
||||||
|
selected={focusedRow === index && focusedCol === tileIndex}
|
||||||
|
onClick={(event) => {
|
||||||
|
// Otherwise this bubbles to the panel's own onClick, which would
|
||||||
|
// open the group root instead of the category just clicked.
|
||||||
|
event.stopPropagation();
|
||||||
|
onEnterGroup(shelfGroup, entry.key);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ShelfRow>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -727,7 +737,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,
|
||||||
|
|||||||
@@ -1,66 +1,104 @@
|
|||||||
/** The Zaubertasten sheet (F1). Mirrors lib/keyboard.ts - if a binding changes there,
|
/** The Zaubertasten sheet (F1). Mirrors lib/keyboard.ts - if a binding changes there,
|
||||||
* it changes here. */
|
* it changes here. */
|
||||||
|
|
||||||
const KEYS: Array<[caps: string[], label: string]> = [
|
import { Fragment } from "react";
|
||||||
[["LEER"], "Play / Pause"],
|
|
||||||
[["SHIFT+H", "SHIFT+L"], "Song zurück / vor"],
|
const SECTIONS: Array<[title: string, rows: Array<[caps: string[], label: string]>]> = [
|
||||||
[["SHIFT+J", "SHIFT+K"], "Leiser / lauter"],
|
[
|
||||||
[["SHIFT+M"], "Stumm"],
|
"Abspielen",
|
||||||
[["⇧←", "⇧→"], "15 Sekunden zurück / vor"],
|
[
|
||||||
[["← ↑ ↓ →"], "Auswahl bewegen"],
|
[["LEER"], "Play / Pause"],
|
||||||
[["CTRL+H/J/K/L"], "Auswahl bewegen (Raster oder Titelliste)"],
|
[["⇧H", "⇧L"], "Song zurück / vor"],
|
||||||
[["CTRL+D", "CTRL+U"], "Eine halbe Seite weiter / zurück"],
|
[["⇧J", "⇧K"], "Leiser / lauter"],
|
||||||
[["A-Z"], "Album oder Hörbuch suchen"],
|
[["⇧M"], "Stumm"],
|
||||||
[["?"], "Einzelne Titel suchen"],
|
[["⇧←", "⇧→"], "15 Sekunden zurück / vor"],
|
||||||
[["TAB"], "Musik / Hörbücher / Podcasts"],
|
],
|
||||||
[["ENTER"], "Auswahl abspielen"],
|
],
|
||||||
[["⇧ENTER"], "Titelliste öffnen"],
|
[
|
||||||
[["A", "0-9"], "Aktuellen Titel einer Fernbedienungs-Taste zuweisen"],
|
"Auswählen",
|
||||||
[["F1"], "Diese Hilfe"],
|
[
|
||||||
[["ESC"], "Schließen / Suche löschen"],
|
[["← ↑ ↓ →"], "Auswahl bewegen"],
|
||||||
|
[["STRG+HJKL"], "Auswahl bewegen"],
|
||||||
|
[["ENTER"], "Auswahl abspielen"],
|
||||||
|
[["⇧ENTER"], "Titelliste öffnen"],
|
||||||
|
[["TAB"], "Musik / Hörbücher / Podcasts"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Suchen",
|
||||||
|
[
|
||||||
|
[["A-Z"], "Album oder Hörbuch suchen"],
|
||||||
|
[["?"], "Einzelne Titel suchen"],
|
||||||
|
[["/"], "Suche öffnen / Alben ↔ Titel"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Sonstiges",
|
||||||
|
[
|
||||||
|
[["A", "0-9"], "Titel auf Fernbedienungs-Taste legen"],
|
||||||
|
[["F1"], "Diese Hilfe"],
|
||||||
|
[["ESC"], "Schließen / Suche löschen"],
|
||||||
|
],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const CAP_STYLE = { minWidth: 0, padding: "3px 7px", fontSize: 11 } as const;
|
||||||
|
|
||||||
export function HelpOverlay({ onClose }: { onClose: () => void }) {
|
export function HelpOverlay({ onClose }: { onClose: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="overlay" style={{ zIndex: 5 }} onClick={onClose}>
|
<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
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 17,
|
fontSize: 15,
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
color: "var(--ink)",
|
color: "var(--ink)",
|
||||||
marginBottom: 14,
|
marginBottom: 4,
|
||||||
letterSpacing: 0.3,
|
letterSpacing: 0.3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
⌨️ Zaubertasten
|
⌨️ Zaubertasten
|
||||||
</div>
|
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: 16,
|
display: "grid",
|
||||||
fontSize: 12,
|
gridTemplateColumns: "max-content max-content",
|
||||||
fontWeight: 600,
|
columnGap: 14,
|
||||||
color: "oklch(50% 0.03 210 / .8)",
|
rowGap: 5,
|
||||||
textAlign: "center",
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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", () => {
|
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([]);
|
expect(press("l", { ...initialUiState, view: "play" }, { ctrlKey: true })).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -85,9 +85,6 @@ export const GROUP_ORDER: Group[] = ["music", "audiobooks", "podcasts"];
|
|||||||
|
|
||||||
export const VOLUME_STEP = 10;
|
export const VOLUME_STEP = 10;
|
||||||
export const SEEK_STEP = 15;
|
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
|
/** 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
|
* 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
|
// 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
|
// 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
|
// already do, just reachable without leaving the home row.
|
||||||
// half-page jump, applied to rows of cards.
|
|
||||||
if (event.ctrlKey || event.metaKey) {
|
if (event.ctrlKey || event.metaKey) {
|
||||||
if (state.openAlbumId !== null) {
|
if (state.openAlbumId !== null) {
|
||||||
switch (key.toLowerCase()) {
|
switch (key.toLowerCase()) {
|
||||||
@@ -317,7 +313,7 @@ export function handleKey(
|
|||||||
}
|
}
|
||||||
if (state.view !== "browse" || state.showHelp) return [];
|
if (state.view !== "browse" || state.showHelp) return [];
|
||||||
const lower = key.toLowerCase();
|
const lower = key.toLowerCase();
|
||||||
if (!"hjkldu".includes(lower)) return [];
|
if (!"hjkl".includes(lower)) return [];
|
||||||
if (isRootShelf(state)) {
|
if (isRootShelf(state)) {
|
||||||
switch (lower) {
|
switch (lower) {
|
||||||
case "h":
|
case "h":
|
||||||
@@ -325,10 +321,8 @@ export function handleKey(
|
|||||||
case "l":
|
case "l":
|
||||||
return moveRootShelf(state, 1, 0, rootShelves);
|
return moveRootShelf(state, 1, 0, rootShelves);
|
||||||
case "j":
|
case "j":
|
||||||
case "d":
|
|
||||||
return moveRootShelf(state, 0, 1, rootShelves);
|
return moveRootShelf(state, 0, 1, rootShelves);
|
||||||
case "k":
|
case "k":
|
||||||
case "u":
|
|
||||||
return moveRootShelf(state, 0, -1, rootShelves);
|
return moveRootShelf(state, 0, -1, rootShelves);
|
||||||
default:
|
default:
|
||||||
return [];
|
return [];
|
||||||
@@ -343,10 +337,6 @@ export function handleKey(
|
|||||||
return moveSelection(state, results, 0, 1);
|
return moveSelection(state, results, 0, 1);
|
||||||
case "k":
|
case "k":
|
||||||
return moveSelection(state, results, 0, -1);
|
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:
|
default:
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,19 @@ import type { UiState } from "./keyboard";
|
|||||||
export const SHOW_ROW_TINT = false;
|
export const SHOW_ROW_TINT = false;
|
||||||
|
|
||||||
/** Show each shelf's title text ("Musik", "Hörbücher", "Podcasts") above its tiles. */
|
/** 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 the small icon badge (🎵/📖/🎙️) beside the title. With `SHOW_ROW_TITLES`
|
||||||
* `SHOW_ROW_TITLES` off (an icon-only header), but the original look needs both. */
|
* 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;
|
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. */
|
/** Vertical gap between shelves on the root browse screen, in px. */
|
||||||
export const ROW_SPACING = 25;
|
export const ROW_SPACING = 25;
|
||||||
|
|
||||||
|
|||||||
@@ -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