This commit is contained in:
2026-08-27 23:46:19 +02:00
parent a8ed350aec
commit 8aed3b022b
16 changed files with 602 additions and 186 deletions

View File

@@ -1 +1,2 @@
config.yml config.yml
/.musicmouse-cache

File diff suppressed because one or more lines are too long

View File

@@ -97,6 +97,7 @@ class PlayerBase:
self._playlist: Playlist | None = None self._playlist: Playlist | None = None
self._index = 0 self._index = 0
self._playing = False self._playing = False
self._playlist_changed = False
@classmethod @classmethod
def volume_kwargs(cls, config: GeneralConfig) -> dict[str, int]: def volume_kwargs(cls, config: GeneralConfig) -> dict[str, int]:
@@ -167,6 +168,7 @@ class PlayerBase:
if playing == self._playing: if playing == self._playing:
return return
self._playing = playing self._playing = playing
self._playlist_changed = False
self._bus.emit( self._bus.emit(
PlaybackChanged( PlaybackChanged(
playing=playing, figure=figure, playlist=self._playlist, source="player" playing=playing, figure=figure, playlist=self._playlist, source="player"
@@ -174,11 +176,25 @@ class PlayerBase:
) )
def _set_index(self, index: int) -> None: def _set_index(self, index: int) -> None:
if index == self._index: # A playlist swap while already playing resets the index to 0 without going
# through here (see `_load_playlist`), so the following `play_track(0)` looks
# like a no-op index change. Force it through in that case - `_set_playing`
# will not itself announce anything, since it was already playing before and
# after. Starting from idle needs no such push: `_set_playing`'s own True
# transition already covers the broadcast.
force = self._playlist_changed and self._playing
if index == self._index and not force:
return return
self._playlist_changed = False
self._index = index self._index = index
self._bus.emit(TrackChanged(index=index, track=self.current_track, source="player")) self._bus.emit(TrackChanged(index=index, track=self.current_track, source="player"))
def _load_playlist(self, playlist: Playlist) -> None:
"""Bookkeeping shared by every player's ``set_playlist``."""
self._playlist = playlist
self._index = 0
self._playlist_changed = True
def _announce_volume(self, source: EventSource = "player") -> None: def _announce_volume(self, source: EventSource = "player") -> None:
self._bus.emit(VolumeChanged(volume=self._volume, source=source)) self._bus.emit(VolumeChanged(volume=self._volume, source=source))
@@ -247,8 +263,7 @@ class VlcPlayer(PlayerBase):
self._list_player.set_media_list(media_list) self._list_player.set_media_list(media_list)
self._list_player.set_playback_mode(self._vlc.PlaybackMode.default) self._list_player.set_playback_mode(self._vlc.PlaybackMode.default)
self._playlist = playlist self._load_playlist(playlist)
self._index = 0
_log.info("Playlist %r loaded (%d tracks)", playlist.name, len(playlist)) _log.info("Playlist %r loaded (%d tracks)", playlist.name, len(playlist))
def play(self) -> None: def play(self) -> None:

View File

@@ -36,7 +36,7 @@ __all__ = ["Fingerprint", "LibraryCache"]
#: worked out - not just when the JSON shape does. A cached entry is reused whenever its #: worked out - not just when the JSON shape does. A cached entry is reused whenever its
#: files are untouched, so otherwise a change to that logic is invisible until somebody #: files are untouched, so otherwise a change to that logic is invisible until somebody
#: edits their music folder. #: edits their music folder.
_INDEX_VERSION = 3 _INDEX_VERSION = 4
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)

View File

@@ -103,6 +103,22 @@ def _cover_for(
return None, None return None, None
def _cover_for_episode(
folder: Path, path: Path, identifier: str, cache: LibraryCache
) -> tuple[Path | None, bytes | None]:
"""The reverse priority from :func:`_cover_for`: with one album per episode, the
episode's own embedded art is the more specific and more correct source. The
folder's shared cover is the fallback for an episode whose file carries none."""
art = _embedded_art(path)
if art is not None:
return cache.store_cover(identifier, art), art
for name in _COVER_NAMES:
candidate = folder / name
if candidate.is_file():
return candidate, candidate.read_bytes()
return None, None
def scan_album( def scan_album(
folder: Path, folder: Path,
*, *,
@@ -176,6 +192,59 @@ def scan_album(
return album, fingerprint return album, fingerprint
def scan_episodes(
folder: Path,
*,
root: Path,
section_name: str,
section: Section,
extensions: frozenset[str],
cache: LibraryCache,
known: dict[str, tuple[Album, Fingerprint]],
) -> dict[str, tuple[Album, Fingerprint]]:
"""One album per audio file, for a section whose folder is a show rather than a
single release - a podcast feed's hundreds of episodes, most obviously.
Fingerprinted per file rather than per folder, so a new episode landing in an
already-scanned show only costs scanning that one file, not the whole show.
"""
paths = _audio_files(folder, extensions)
if section.order == "newest_first":
paths.reverse()
out: dict[str, tuple[Album, Fingerprint]] = {}
for path in paths:
identifier = album_id(root, path)
fingerprint = Fingerprint.of([path])
cached = known.get(identifier)
if cached is not None and cached[1] == fingerprint:
out[identifier] = cached
continue
tags, duration = _tags(path)
title = tags.get("title") or path.stem
cover, art = _cover_for_episode(folder, path, identifier, cache)
colors = colors_from_cover(art, identifier) if art else colors_from_id(identifier)
album = Album(
id=identifier,
section=section_name,
kind=section.kind,
title=title,
artist=folder.name,
# The folder is the show; every episode in it groups under the same series,
# exactly like an audiobook's chapters group under its book.
series=folder.name,
figure=None,
colors=colors,
folder=folder,
cover=cover,
tracks=(LibraryTrack(path=path, title=title, duration=duration),),
)
out[identifier] = (album, fingerprint)
return out
def scan_library( def scan_library(
root: Path, root: Path,
extensions: frozenset[str], extensions: frozenset[str],
@@ -203,6 +272,23 @@ def scan_library(
for folder in sorted(section_root.iterdir(), key=lambda path: path.name): for folder in sorted(section_root.iterdir(), key=lambda path: path.name):
if not folder.is_dir() or folder.name.startswith("."): if not folder.is_dir() or folder.name.startswith("."):
continue continue
if section.album_unit == "episode":
# Fingerprinted per episode inside scan_episodes; the whole-folder
# shortcut below does not apply since one folder yields many albums.
out.update(
scan_episodes(
folder,
root=root,
section_name=section_name,
section=section,
extensions=extensions,
cache=cache,
known=known,
)
)
continue
identifier = album_id(root, folder) identifier = album_id(root, folder)
cached = known.get(identifier) cached = known.get(identifier)
if cached is not None: if cached is not None:

View File

@@ -16,6 +16,7 @@ type AlbumKind = Literal["music", "book"]
type TrackOrder = Literal["filename", "newest_first"] type TrackOrder = Literal["filename", "newest_first"]
type TitleSource = Literal["tags", "folder"] type TitleSource = Literal["tags", "folder"]
type ArtistSource = Literal["tags", "folder"] type ArtistSource = Literal["tags", "folder"]
type AlbumUnit = Literal["folder", "episode"]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -26,6 +27,13 @@ class Section:
order: TrackOrder = "filename" order: TrackOrder = "filename"
title_from: TitleSource = "tags" title_from: TitleSource = "tags"
artist_from: ArtistSource = "tags" artist_from: ArtistSource = "tags"
#: "folder" (default): one album per folder, every audio file a track/chapter of it.
#: "episode": one album per audio *file* - a show folder groups its episodes rather
#: than being one giant album itself. `title_from`/`artist_from` are not consulted
#: for this unit: an episode's title always comes from its own tags (or filename),
#: and the folder always supplies the artist/series, so episodes of the same show
#: still group together everywhere the browse view groups by category.
album_unit: AlbumUnit = "folder"
#: ``Kinderpodcasts`` is the odd one out twice over. Its ``artist`` tag is the full #: ``Kinderpodcasts`` is the odd one out twice over. Its ``artist`` tag is the full
@@ -40,7 +48,5 @@ SECTIONS: Final[dict[str, Section]] = {
"Figuren": Section(figures=True, title_from="folder"), "Figuren": Section(figures=True, title_from="folder"),
"Musik": Section(), "Musik": Section(),
"Hörbücher": Section(kind="book"), "Hörbücher": Section(kind="book"),
"Kinderpodcasts": Section( "Kinderpodcasts": Section(kind="book", order="newest_first", album_unit="episode"),
kind="book", order="newest_first", title_from="folder", artist_from="folder"
),
} }

View File

@@ -62,8 +62,7 @@ class FakePlayer(PlayerBase):
def set_playlist(self, playlist: Playlist) -> None: def set_playlist(self, playlist: Playlist) -> None:
self._cancel_timer() self._cancel_timer()
self._playlist = playlist self._load_playlist(playlist)
self._index = 0
self._remaining = self.track_duration self._remaining = self.track_duration
_log.info("Playlist %r loaded (%d tracks)", playlist.name, len(playlist)) _log.info("Playlist %r loaded (%d tracks)", playlist.name, len(playlist))

View File

@@ -42,7 +42,8 @@ async def test_every_section_is_scanned(config_dir: Path) -> None:
"Eule", "Eule",
"Kinderparty Lieder", "Kinderparty Lieder",
"Conni in den Bergen", "Conni in den Bergen",
"Wissen macht Ah", "Alt",
"Neu",
} }
@@ -80,17 +81,27 @@ async def test_audiobooks_group_by_the_name_before_the_comma(config_dir: Path) -
assert album.category == "Conni" assert album.category == "Conni"
async def test_podcasts_are_named_after_the_folder_not_the_tags(config_dir: Path) -> None: async def test_podcast_episodes_are_their_own_albums_grouped_by_the_show(
album = album_named(await build(config_dir), "Wissen macht Ah") config_dir: Path,
# The tags say album="Wissen macht Ah! - Podcast" and artist=<six presenters>. ) -> None:
assert album.kind == "book" """One podcast mp3 behaves like a whole audiobook: its own album, its own cover,
assert album.artist == "Wissen macht Ah" rather than a chapter buried in one album named after the entire feed."""
assert album.series == "Wissen macht Ah" library = await build(config_dir)
episodes = [a for a in library.albums if a.category == "Wissen macht Ah"]
assert {a.title for a in episodes} == {"Alt", "Neu"}
# The tags say album="Wissen macht Ah! - Podcast" and artist=<six presenters> -
# neither groups usefully, so the folder (the show) wins for artist/series, same
# as it always did; only the *title* now comes from the episode's own tags.
assert all(a.kind == "book" for a in episodes)
assert all(a.artist == "Wissen macht Ah" for a in episodes)
assert all(a.series == "Wissen macht Ah" for a in episodes)
assert all(len(a.tracks) == 1 for a in episodes)
async def test_podcast_episodes_are_newest_first(config_dir: Path) -> None: async def test_podcast_episodes_are_newest_first(config_dir: Path) -> None:
album = album_named(await build(config_dir), "Wissen macht Ah") library = await build(config_dir)
assert [track.title for track in album.tracks] == ["Neu", "Alt"] episodes = [a for a in library.albums if a.category == "Wissen macht Ah"]
assert [a.title for a in episodes] == ["Neu", "Alt"]
async def test_other_sections_keep_filename_order(config_dir: Path) -> None: async def test_other_sections_keep_filename_order(config_dir: Path) -> None:
@@ -117,11 +128,12 @@ async def test_durations_come_from_the_files(config_dir: Path) -> None:
async def test_scratch_files_never_reach_a_playlist(config_dir: Path) -> None: async def test_scratch_files_never_reach_a_playlist(config_dir: Path) -> None:
album = album_named(await build(config_dir), "Wissen macht Ah") library = await build(config_dir)
names = {track.path.name for track in album.tracks} episodes = [a for a in library.albums if a.category == "Wissen macht Ah"]
names = {episode.tracks[0].path.name for episode in episodes}
assert "archive.json" not in names assert "archive.json" not in names
assert not any(name.startswith(".") for name in names) assert not any(name.startswith(".") for name in names)
assert len(album.tracks) == 2 assert len(episodes) == 2
async def test_unknown_top_level_folders_are_ignored(config_dir: Path) -> None: async def test_unknown_top_level_folders_are_ignored(config_dir: Path) -> None:
@@ -141,7 +153,7 @@ async def test_a_missing_section_warns_rather_than_failing(
library = await build(config_dir) library = await build(config_dir)
assert "Musik" in caplog.text assert "Musik" in caplog.text
assert len(library.albums) == 4 assert len(library.albums) == 5
async def test_an_empty_album_folder_is_not_an_album(config_dir: Path) -> None: async def test_an_empty_album_folder_is_not_an_album(config_dir: Path) -> None:
@@ -199,6 +211,27 @@ async def test_a_changed_folder_is_rescanned(config_dir: Path) -> None:
assert len(album_named(library, "Eule").tracks) == 3 assert len(album_named(library, "Eule").tracks) == 3
async def test_a_new_episode_only_costs_scanning_that_one_file(config_dir: Path) -> None:
"""The point of fingerprinting a podcast per episode rather than per folder: a
show's other, untouched episodes come straight out of the cache, ids and all."""
first = await build(config_dir)
before = {a.id: a for a in first.albums if a.category == "Wissen macht Ah"}
assert len(before) == 2
write_track(
config_dir / "music" / "Kinderpodcasts" / "Wissen macht Ah" / "20260201 - Neuer.mp3",
title="Neuer",
album="Wissen macht Ah! - Podcast",
albumartist="Ein Name, Noch Einer, Und Einer",
)
second = await build(config_dir)
after = {a.id: a for a in second.albums if a.category == "Wissen macht Ah"}
assert len(after) == 3
# The two pre-existing episodes kept their identity across the rescan.
assert set(before) <= set(after)
async def test_a_rescan_leaves_analysis_alone(config_dir: Path) -> None: async def test_a_rescan_leaves_analysis_alone(config_dir: Path) -> None:
"""The whole reason the cache is a directory rather than one file.""" """The whole reason the cache is a directory rather than one file."""
library = await build(config_dir) library = await build(config_dir)
@@ -238,7 +271,7 @@ async def test_a_corrupt_index_is_rebuilt_rather_than_fatal(config_dir: Path) ->
(config_dir / ".cache" / "index.json").write_text("{ not json") (config_dir / ".cache" / "index.json").write_text("{ not json")
library = await build(config_dir) library = await build(config_dir)
assert len(library.albums) == 5 assert len(library.albums) == 6
def test_album_ids_are_stable_and_path_derived(tmp_path: Path) -> None: def test_album_ids_are_stable_and_path_derived(tmp_path: Path) -> None:

View File

@@ -125,6 +125,29 @@ async def test_play_from_start_starts_the_first_track(
assert only(seen, PlaybackChanged)[-1].playing is True assert only(seen, PlaybackChanged)[-1].playing is True
async def test_switching_albums_while_playing_announces_the_new_track(
bus: EventBus, player: FakePlayer, seen: list[Event]
) -> None:
"""Regression: starting a new playlist from track 0 while already playing track 0
of the previous one used to look like a no-op index change, so no ``TrackChanged``
went out and front-ends never learned the album had switched."""
player.set_playlist(playlist(name="fuchs"))
player.play_from_start()
await bus.drain()
player.set_playlist(playlist(name="eule"))
player.play_from_start()
await bus.drain()
assert player.is_playing
assert player.playlist is not None
assert player.playlist.name == "eule"
changes = only(seen, TrackChanged)
assert changes[-1].index == 0
assert changes[-1].track is not None
assert changes[-1].track.path.parent.name == "eule"
async def test_playing_an_empty_playlist_does_nothing( async def test_playing_an_empty_playlist_does_nothing(
bus: EventBus, player: FakePlayer, caplog: pytest.LogCaptureFixture bus: EventBus, player: FakePlayer, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:

View File

@@ -1,8 +1,11 @@
/** The browse screen: filter pills, the search pill, and whichever of the three result /** The browse screen. At the root (no group chosen) this is three horizontally
* lists applies. Selection indices run flat across songs, then categories, then albums, * scrolling shelves - music, audiobooks, podcasts - each showing its own category
* which is what lets one pair of arrow keys walk the whole page. */ * tiles. Once a group is open it behaves like before: category tiles, or the
* albums-within-a-category grid, or the track list, with selection indices running
* flat across songs, then categories, then albums so one pair of arrow keys walks
* the whole page. */
import { useEffect, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import type { Album } from "../api/types"; import type { Album } from "../api/types";
import { import {
@@ -14,48 +17,86 @@ import {
unitLabel, unitLabel,
} from "../lib/covers"; } from "../lib/covers";
import { clock } from "../lib/format"; import { clock } from "../lib/format";
import type { Filter, Results, SongHit } from "../lib/search"; import type { Category, Group, Results, SongHit } from "../lib/search";
import { categoryMatches, groupOf } from "../lib/search";
import { Cover } from "./Cover"; import { Cover } from "./Cover";
interface Props { interface Props {
results: Results; results: Results;
filter: Filter; group: Group | null;
albums: Album[];
mode: "albums" | "tracks"; mode: "albums" | "tracks";
search: string; search: string;
category: string | null; category: string | null;
selIndex: number; selIndex: number;
currentAlbumId: string | null; currentAlbumId: string | null;
gridRef: (element: HTMLElement | null) => void; gridRef: (element: HTMLElement | null) => void;
onFilter: (filter: Filter) => void; onEnterGroup: (group: Group, category: string | null) => void;
onBackToRoot: () => void;
onCategory: (key: string | null) => void; onCategory: (key: string | null) => void;
onOpenAlbum: (album: Album, navIndex: number) => void; onOpenAlbum: (album: Album, navIndex: number) => void;
onPlaySong: (hit: SongHit) => void; onPlaySong: (hit: SongHit) => void;
} }
const FILTERS: Array<[Filter, string]> = [ const GROUPS: Group[] = ["music", "audiobooks", "podcasts"];
["all", "Alles"],
["music", "🎵 Musik"], const GROUP_TITLE: Record<Group, string> = {
["book", "📖 Hörbücher"], music: "🎵 Musik",
]; audiobooks: "📖 Hörbücher",
podcasts: "🎙️ Podcasts",
};
/** Heading over the category tiles inside a group. */
const GROUP_CATEGORY_LABEL: Record<Group, string> = {
music: "Künstler",
audiobooks: "Figuren",
podcasts: "Sendungen",
};
/** Heading over the album grid while searching inside a group. */
const GROUP_SECTION_LABEL: Record<Group, string> = {
music: "Alben",
audiobooks: "Hörbücher",
podcasts: "Episoden",
};
export function BrowseView({ export function BrowseView({
results, results,
filter, group,
albums,
mode, mode,
search, search,
category, category,
selIndex, selIndex,
currentAlbumId, currentAlbumId,
gridRef, gridRef,
onFilter, onEnterGroup,
onBackToRoot,
onCategory, onCategory,
onOpenAlbum, onOpenAlbum,
onPlaySong, onPlaySong,
}: Props) { }: Props) {
const scroller = useRef<HTMLDivElement | null>(null); const scroller = useRef<HTMLDivElement | null>(null);
const { songs, categories, albums, total } = results; const { songs, categories, albums: albumResults, total } = results;
const selected = total ? Math.min(selIndex, total - 1) : -1; const selected = total ? Math.min(selIndex, total - 1) : -1;
const isRootShelf = group === null && mode !== "tracks" && !search;
const shelves = useMemo(
() =>
GROUPS.map((shelfGroup) => ({
group: shelfGroup,
categories: categoryMatches({
albums,
search: "",
mode: "albums",
category: null,
group: shelfGroup,
}),
})),
[albums],
);
// Keep the selection on screen as the arrow keys walk past the fold. // Keep the selection on screen as the arrow keys walk past the fold.
useEffect(() => { useEffect(() => {
const box = scroller.current; const box = scroller.current;
@@ -70,42 +111,96 @@ export function BrowseView({
} }
}, [selected]); }, [selected]);
if (isRootShelf) {
return (
<div ref={scroller} style={{ flex: 1, overflow: "auto", minHeight: 0, padding: "14px 32px 250px" }}>
{shelves.map(({ group: shelfGroup, categories: shelfCategories }) => (
<div key={shelfGroup} style={{ marginBottom: 34 }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
gap: 12,
marginBottom: 12,
}}
>
<div style={{ fontSize: 20, fontWeight: 900, color: "var(--paper)" }}>
{GROUP_TITLE[shelfGroup]}
</div>
<button
className="round"
onClick={() => onEnterGroup(shelfGroup, null)}
aria-label={`${GROUP_TITLE[shelfGroup]} durchsuchen`}
title="Nur hier suchen"
style={{
width: 34,
height: 34,
fontSize: 15,
background: "oklch(97% 0.01 210 / .18)",
color: "var(--paper)",
}}
>
🔎
</button>
</div>
{shelfCategories.length === 0 ? (
<div
style={{
textAlign: "center",
fontSize: 14,
fontWeight: 700,
color: "oklch(88% 0.02 210 / .6)",
}}
>
Noch nichts hier
</div>
) : (
<ShelfRow>
{shelfCategories.map((entry) => (
<CategoryTile
key={entry.key}
entry={entry}
onClick={() => onEnterGroup(shelfGroup, entry.key)}
/>
))}
</ShelfRow>
)}
</div>
))}
</div>
);
}
const showSearchBar = search.length > 0 || mode === "tracks"; const showSearchBar = search.length > 0 || mode === "tracks";
const countLabel = const countLabel =
mode === "tracks" mode === "tracks"
? `${songs.length} Titel gefunden` ? `${songs.length} Titel gefunden`
: albums.length : albumResults.length
? `${albums.length} Album${albums.length === 1 ? "" : "en"} gefunden` ? `${albumResults.length} Album${albumResults.length === 1 ? "" : "en"} gefunden`
: "nichts gefunden"; : "nichts gefunden";
const sectionLabel =
filter === "book" ? "Hörbücher" : filter === "music" ? "Alben" : "Alben & Hörbücher";
const categoryLabel =
filter === "book" ? "Figuren" : filter === "music" ? "Künstler" : "Figuren & Künstler";
return ( return (
<> <>
<div {group !== null && (
style={{ <div
display: "flex", style={{
alignItems: "center", display: "flex",
justifyContent: "center", alignItems: "center",
gap: 10, justifyContent: "center",
padding: "4px 32px 6px", gap: 10,
flex: "none", padding: "4px 32px 6px",
}} flex: "none",
> }}
{FILTERS.map(([value, label]) => ( >
<button <button className="pill" onClick={onBackToRoot}>
key={value} Start
className="pill"
data-active={filter === value}
onClick={() => onFilter(value)}
>
{label}
</button> </button>
))} <div style={{ fontSize: 18, fontWeight: 900, color: "var(--paper)" }}>
</div> {GROUP_TITLE[group]}
</div>
</div>
)}
{showSearchBar && ( {showSearchBar && (
<div <div
@@ -232,108 +327,27 @@ export function BrowseView({
</div> </div>
)} )}
{categories.length > 0 && ( {categories.length > 0 && group !== null && (
<div> <div>
<SectionTitle centered>{categoryLabel}</SectionTitle> <SectionTitle centered>{GROUP_CATEGORY_LABEL[group]}</SectionTitle>
<div className="grid" ref={gridRef}> <div className="grid" ref={gridRef}>
{categories.map((entry, index) => { {categories.map((entry, index) => {
const navIndex = songs.length + index; const navIndex = songs.length + index;
const books = entry.albums.filter(isBook).length;
const allBooks = books === entry.albums.length;
const mostlyBooks = books * 2 > entry.albums.length;
const shown = entry.albums.slice(0, 4);
return ( return (
<button <CategoryTile
key={entry.key} key={entry.key}
className="card" entry={entry}
data-nav-index={navIndex} navIndex={navIndex}
data-selected={selected === navIndex} selected={selected === navIndex}
onClick={() => onCategory(entry.key)} onClick={() => onCategory(entry.key)}
style={{ />
background: allBooks
? "oklch(93% 0.055 88 / .7)"
: "oklch(95% 0.015 210 / .66)",
borderRadius: allBooks ? "6px 18px 18px 6px" : "16px",
boxShadow: "0 6px 18px var(--shadow)",
}}
>
<div
style={{
display: "grid",
// A category of one gets one full-bleed cover rather than a
// 2x2 grid with three empty holes in it.
// Always a full 2x2 for more than one, so every cell has the
// same shape as the tile and the covers fill it exactly. Two
// albums in a single row would each be twice as wide as their
// cell and spill out of it.
gridTemplateColumns: shown.length === 1 ? "1fr" : "1fr 1fr",
gridTemplateRows: shown.length === 1 ? "1fr" : "1fr 1fr",
gap: 4,
padding: 8,
// The tile takes the shape of what it holds, so a shelf of
// audiobooks is visibly taller than a shelf of albums.
aspectRatio: aspectOfKind(mostlyBooks ? "book" : "music"),
}}
>
{shown.map((album) => (
<div
key={album.id}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 0,
minWidth: 0,
overflow: "hidden",
}}
>
<Cover
album={album}
size={shown.length === 1 ? 160 : 70}
fit="height"
radius="4px"
label={shown.length === 1}
/>
</div>
))}
</div>
<div style={{ padding: "4px 12px 14px" }}>
<div
style={{
fontSize: 16,
fontWeight: 800,
color: "oklch(22% 0.03 210)",
lineHeight: 1.2,
}}
>
{entry.key}
</div>
<div
style={{
fontSize: 12,
fontWeight: 800,
color: "oklch(40% 0.17 340)",
marginTop: 5,
}}
>
{entry.albums.length}{" "}
{allBooks
? entry.albums.length === 1
? "Hörbuch"
: "Hörbücher"
: entry.albums.length === 1
? "Album"
: "Alben"}
</div>
</div>
</button>
); );
})} })}
</div> </div>
</div> </div>
)} )}
{albums.length > 0 && ( {albumResults.length > 0 && (
<div> <div>
{category !== null && !search && ( {category !== null && !search && (
<div <div
@@ -357,10 +371,13 @@ export function BrowseView({
</div> </div>
</div> </div>
)} )}
{search.length > 0 && <SectionTitle centered>{sectionLabel}</SectionTitle>} {search.length > 0 && group !== null && (
<SectionTitle centered>{GROUP_SECTION_LABEL[group]}</SectionTitle>
)}
<div className="grid" ref={gridRef}> <div className="grid" ref={gridRef}>
{albums.map((album, index) => { {albumResults.map((album, index) => {
const navIndex = songs.length + categories.length + index; const navIndex = songs.length + categories.length + index;
const podcast = groupOf(album) === "podcasts";
return ( return (
<button <button
key={album.id} key={album.id}
@@ -410,7 +427,7 @@ export function BrowseView({
marginTop: 6, marginTop: 6,
}} }}
> >
{unitLabel(album, album.tracks.length)} {podcast ? clock(album.duration) : unitLabel(album, album.tracks.length)}
{album.figure && " · 🧸"} {album.figure && " · 🧸"}
</div> </div>
</div> </div>
@@ -440,6 +457,172 @@ export function BrowseView({
); );
} }
/** A `.shelf-row` that turns a vertical wheel gesture into horizontal scroll, and lets
* a mouse click-and-drag scroll it too - the point of hovering/dragging a shelf with a
* mouse rather than a touchscreen, which already scrolls it natively by touch.
*
* The wheel listener has to be a real (non-passive) one: React's own `onWheel` is
* passive by default, so calling `preventDefault` there would only log a warning and
* still scroll the page. Drag-to-scroll is plain mouse events on `window` rather than
* pointer capture, so a plain click still reaches the tile underneath - capturing the
* pointer on the row would retarget even a non-dragging click's events to the row. */
function ShelfRow({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
const onWheel = (event: WheelEvent) => {
if (event.deltaY === 0) return;
element.scrollLeft += event.deltaY;
event.preventDefault();
};
element.addEventListener("wheel", onWheel, { passive: false });
return () => element.removeEventListener("wheel", onWheel);
}, []);
const onMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
const element = ref.current;
if (event.button !== 0 || !element) return;
event.preventDefault(); // no text-selection/ghost-drag while panning
const startX = event.clientX;
const startScroll = element.scrollLeft;
let dragged = false;
const onMove = (moveEvent: MouseEvent) => {
const dx = moveEvent.clientX - startX;
if (Math.abs(dx) > 4) {
dragged = true;
element.classList.add("dragging");
}
element.scrollLeft = startScroll - dx;
};
const onUp = () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
element.classList.remove("dragging");
if (dragged) {
// This was a pan, not a click - swallow the click a tile would otherwise get.
window.addEventListener(
"click",
(clickEvent) => clickEvent.stopPropagation(),
{ capture: true, once: true },
);
}
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
};
return (
<div className="shelf-row" ref={ref} onMouseDown={onMouseDown}>
{children}
</div>
);
}
/** One category's 2x2 cover preview + name + count. Used both by the root shelves
* (pointer-only) and by the in-group category grid (keyboard-navigable, hence the
* optional `navIndex`/`selected`). */
function CategoryTile({
entry,
navIndex,
selected,
onClick,
}: {
entry: Category;
navIndex?: number;
selected?: boolean;
onClick: () => void;
}) {
const books = entry.albums.filter(isBook).length;
const allBooks = books === entry.albums.length;
const mostlyBooks = books * 2 > entry.albums.length;
const shown = entry.albums.slice(0, 4);
return (
<button
className="card"
data-nav-index={navIndex}
data-selected={selected}
onClick={onClick}
style={{
background: allBooks ? "oklch(93% 0.055 88 / .7)" : "oklch(95% 0.015 210 / .66)",
borderRadius: allBooks ? "6px 18px 18px 6px" : "16px",
boxShadow: "0 6px 18px var(--shadow)",
}}
>
<div
style={{
display: "grid",
// A category of one gets one full-bleed cover rather than a 2x2 grid with
// three empty holes in it. Always a full 2x2 for more than one, so every
// cell has the same shape as the tile and the covers fill it exactly. Two
// albums in a single row would each be twice as wide as their cell and
// spill out of it.
gridTemplateColumns: shown.length === 1 ? "1fr" : "1fr 1fr",
gridTemplateRows: shown.length === 1 ? "1fr" : "1fr 1fr",
gap: 4,
padding: 8,
// The tile takes the shape of what it holds, so a shelf of audiobooks is
// visibly taller than a shelf of albums.
aspectRatio: aspectOfKind(mostlyBooks ? "book" : "music"),
}}
>
{shown.map((album) => (
<div
key={album.id}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 0,
minWidth: 0,
overflow: "hidden",
}}
>
<Cover
album={album}
size={shown.length === 1 ? 160 : 70}
fit="height"
radius="4px"
label={shown.length === 1}
/>
</div>
))}
</div>
<div style={{ padding: "4px 12px 14px" }}>
<div
style={{
fontSize: 16,
fontWeight: 800,
color: "oklch(22% 0.03 210)",
lineHeight: 1.2,
}}
>
{entry.key}
</div>
<div
style={{
fontSize: 12,
fontWeight: 800,
color: "oklch(40% 0.17 340)",
marginTop: 5,
}}
>
{entry.albums.length}{" "}
{allBooks
? entry.albums.length === 1
? "Hörbuch"
: "Hörbücher"
: entry.albums.length === 1
? "Album"
: "Alben"}
</div>
</div>
</button>
);
}
function SectionTitle({ function SectionTitle({
children, children,
centered, centered,

View File

@@ -9,7 +9,7 @@ const KEYS: Array<[caps: string[], label: string]> = [
[["A-Z"], "Album oder Hörbuch suchen"], [["A-Z"], "Album oder Hörbuch suchen"],
[["?"], "Einzelne Titel suchen"], [["?"], "Einzelne Titel suchen"],
[["F1"], "Diese Hilfe"], [["F1"], "Diese Hilfe"],
[["TAB"], "Musik / Hörbücher / alles"], [["TAB"], "Musik / Hörbücher / Podcasts"],
[["← ↑ ↓ →"], "Auswahl bewegen"], [["← ↑ ↓ →"], "Auswahl bewegen"],
[["ENTER"], "Auswahl abspielen"], [["ENTER"], "Auswahl abspielen"],
[["ESC"], "Schließen / Suche löschen"], [["ESC"], "Schließen / Suche löschen"],

View File

@@ -1,8 +1,10 @@
/** The full-screen now-playing view. */ /** The full-screen now-playing view. */
import type { Album, PlayerState } from "../api/types"; import type { Album, PlayerState } from "../api/types";
import { usePlaybackClock } from "../hooks/usePlaybackClock";
import { albumLine, isBook } from "../lib/covers"; import { albumLine, isBook } from "../lib/covers";
import { clock, remainingInAlbum } from "../lib/format"; import { clock, remainingInAlbum } from "../lib/format";
import { groupOf } from "../lib/search";
import { Cover } from "./Cover"; import { Cover } from "./Cover";
import { ProgressBar } from "./ProgressBar"; import { ProgressBar } from "./ProgressBar";
import { Transport } from "./Transport"; import { Transport } from "./Transport";
@@ -11,7 +13,6 @@ import { VolumeBars } from "./VolumeBars";
interface Props { interface Props {
state: PlayerState; state: PlayerState;
album: Album | null; album: Album | null;
position: number;
onToggle: () => void; onToggle: () => void;
onNext: () => void; onNext: () => void;
onPrevious: () => void; onPrevious: () => void;
@@ -19,12 +20,12 @@ interface Props {
onVolume: (percent: number) => void; onVolume: (percent: number) => void;
onMute: () => void; onMute: () => void;
onBrowse: () => void; onBrowse: () => void;
onOpenAlbum: () => void;
} }
export function PlayView({ export function PlayView({
state, state,
album, album,
position,
onToggle, onToggle,
onNext, onNext,
onPrevious, onPrevious,
@@ -32,8 +33,11 @@ export function PlayView({
onVolume, onVolume,
onMute, onMute,
onBrowse, onBrowse,
onOpenAlbum,
}: Props) { }: Props) {
const position = usePlaybackClock(state.position, state.playing);
const book = album ? isBook(album) : false; const book = album ? isBook(album) : false;
const podcast = album ? groupOf(album) === "podcasts" : false;
const remaining = album const remaining = album
? remainingInAlbum( ? remainingInAlbum(
album.tracks.map((track) => track.duration), album.tracks.map((track) => track.duration),
@@ -84,13 +88,26 @@ export function PlayView({
}} }}
> >
{album ? ( {album ? (
<Cover <button
album={album} onClick={onOpenAlbum}
size={340} aria-label="Titelliste anzeigen"
fit="height" style={{
radius={book ? "18px 34px 34px 18px" : "28px"} border: "none",
label background: "none",
/> padding: 0,
cursor: "pointer",
display: "flex",
height: "100%",
}}
>
<Cover
album={album}
size={340}
fit="height"
radius={book ? "18px 34px 34px 18px" : "28px"}
label
/>
</button>
) : ( ) : (
<div <div
style={{ style={{
@@ -144,6 +161,7 @@ export function PlayView({
position={position} position={position}
duration={state.duration} duration={state.duration}
onSeek={onSeek} onSeek={onSeek}
resetKey={`${state.album_id ?? ""}:${state.track_index}`}
height={14} height={14}
interactive interactive
/> />
@@ -163,7 +181,7 @@ export function PlayView({
{state.duration ? `${clock(state.duration - position)}` : ""} {state.duration ? `${clock(state.duration - position)}` : ""}
</span> </span>
<span> <span>
{album ? `Noch ${clock(remaining)} ${book ? "im Hörbuch" : "im Album"}` : ""} {album && !podcast ? `Noch ${clock(remaining)} ${book ? "im Hörbuch" : "im Album"}` : ""}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
/** The bar along the bottom of the browse screen. */ /** The bar along the bottom of the browse screen. */
import type { Album, PlayerState } from "../api/types"; import type { Album, PlayerState } from "../api/types";
import { usePlaybackClock } from "../hooks/usePlaybackClock";
import { albumLine, isBook } from "../lib/covers"; import { albumLine, isBook } from "../lib/covers";
import { Cover } from "./Cover"; import { Cover } from "./Cover";
import { ProgressBar } from "./ProgressBar"; import { ProgressBar } from "./ProgressBar";
@@ -10,7 +11,6 @@ import { VolumeBars } from "./VolumeBars";
interface Props { interface Props {
state: PlayerState; state: PlayerState;
album: Album | null; album: Album | null;
position: number;
onToggle: () => void; onToggle: () => void;
onNext: () => void; onNext: () => void;
onPrevious: () => void; onPrevious: () => void;
@@ -23,7 +23,6 @@ interface Props {
export function PlayerBar({ export function PlayerBar({
state, state,
album, album,
position,
onToggle, onToggle,
onNext, onNext,
onPrevious, onPrevious,
@@ -32,6 +31,7 @@ export function PlayerBar({
onMute, onMute,
onOpenPlayView, onOpenPlayView,
}: Props) { }: Props) {
const position = usePlaybackClock(state.position, state.playing);
return ( return (
<div <div
style={{ style={{
@@ -130,6 +130,7 @@ export function PlayerBar({
position={position} position={position}
duration={state.duration} duration={state.duration}
onSeek={onSeek} onSeek={onSeek}
resetKey={`${state.album_id ?? ""}:${state.track_index}`}
height={10} height={10}
interactive interactive
/> />

View File

@@ -8,12 +8,30 @@ interface Props {
onSeek: (position: number) => void; onSeek: (position: number) => void;
height: number; height: number;
interactive?: boolean; interactive?: boolean;
/** Change this when the track itself changes (not just its position) - the fill
* snaps straight to the new position instead of visibly gliding across the whole
* bar the way a normal, small position correction does. */
resetKey?: string | number;
} }
export function ProgressBar({ position, duration, onSeek, height, interactive }: Props) { export function ProgressBar({
position,
duration,
onSeek,
height,
interactive,
resetKey,
}: Props) {
const track = useRef<HTMLDivElement | null>(null); const track = useRef<HTMLDivElement | null>(null);
const [dragging, setDragging] = useState<number | null>(null); const [dragging, setDragging] = useState<number | null>(null);
// Comparing against the previous render's key, right here rather than in an effect,
// is what lets this first paint after a track change already skip the transition -
// an effect would only turn it off one paint too late.
const lastResetKey = useRef(resetKey);
const justReset = lastResetKey.current !== resetKey;
lastResetKey.current = resetKey;
const positionAt = useCallback( const positionAt = useCallback(
(clientX: number): number => { (clientX: number): number => {
const box = track.current?.getBoundingClientRect(); const box = track.current?.getBoundingClientRect();
@@ -61,8 +79,10 @@ export function ProgressBar({ position, duration, onSeek, height, interactive }:
borderRadius: 999, borderRadius: 999,
background: "var(--accent)", background: "var(--accent)",
width: `${percent}%`, width: `${percent}%`,
// No easing while dragging, or the fill lags the finger. // No easing while dragging, or the fill lags the finger; none either right
transition: dragging === null ? "width .25s linear" : "none", // after a track change, or it visibly glides across the whole bar back to
// wherever the new track starts.
transition: dragging === null && !justReset ? "width .25s linear" : "none",
}} }}
/> />
</div> </div>

View File

@@ -6,7 +6,7 @@ instant - which is the point of a keyboard-first UI. Ported from the design mock
import type { Album } from "../api/types"; import type { Album } from "../api/types";
export type Filter = "all" | "music" | "book"; export type Group = "music" | "audiobooks" | "podcasts";
export type Mode = "albums" | "tracks"; export type Mode = "albums" | "tracks";
export interface SongHit { export interface SongHit {
@@ -30,21 +30,28 @@ export function normalize(value: string): string {
.replace(/[^a-z0-9]/g, ""); .replace(/[^a-z0-9]/g, "");
} }
export function inFilter(album: Album, filter: Filter): boolean { /** Which of the three shelves an album belongs on. A podcast is exactly a
if (filter === "book") return album.kind === "book"; * `Kinderpodcasts`-section album; everything else buckets by `kind`, so a Figuren
if (filter === "music") return album.kind === "music"; * album joins whichever of music/audiobooks matches what it actually holds. */
return true; export function groupOf(album: Album): Group {
if (album.section === "Kinderpodcasts") return "podcasts";
return album.kind === "book" ? "audiobooks" : "music";
} }
export function pool(albums: Album[], filter: Filter): Album[] { export function inGroup(album: Album, group: Group): boolean {
return albums.filter((album) => inFilter(album, filter)); return groupOf(album) === group;
}
/** `null` means unrestricted - typing at the root searches every group at once. */
export function pool(albums: Album[], group: Group | null): Album[] {
return group === null ? albums : albums.filter((album) => inGroup(album, group));
} }
export interface BrowseQuery { export interface BrowseQuery {
albums: Album[]; albums: Album[];
search: string; search: string;
mode: Mode; mode: Mode;
filter: Filter; group: Group | null;
category: string | null; category: string | null;
} }
@@ -56,9 +63,11 @@ export function listMode(query: BrowseQuery): "tracks" | "albums" | "categories"
} }
export function categoryMatches(query: BrowseQuery): Category[] { export function categoryMatches(query: BrowseQuery): Category[] {
if (listMode(query) !== "categories") return []; // `group === null` is the bare root screen, rendered as three shelves instead of a
// flat category list - each shelf calls this again with its own group filled in.
if (listMode(query) !== "categories" || query.group === null) return [];
const map = new Map<string, Category>(); const map = new Map<string, Category>();
for (const album of pool(query.albums, query.filter)) { for (const album of pool(query.albums, query.group)) {
const key = album.category; const key = album.category;
let entry = map.get(key); let entry = map.get(key);
if (!entry) { if (!entry) {
@@ -73,7 +82,7 @@ export function categoryMatches(query: BrowseQuery): Category[] {
export function albumMatches(query: BrowseQuery): Album[] { export function albumMatches(query: BrowseQuery): Album[] {
if (query.mode === "tracks") return []; if (query.mode === "tracks") return [];
const needle = normalize(query.search); const needle = normalize(query.search);
let candidates = pool(query.albums, query.filter); let candidates = pool(query.albums, query.group);
if (!needle && !query.category) return []; if (!needle && !query.category) return [];
if (query.category) candidates = candidates.filter((a) => a.category === query.category); if (query.category) candidates = candidates.filter((a) => a.category === query.category);
if (!needle) return candidates; if (!needle) return candidates;
@@ -87,7 +96,7 @@ export function songMatches(query: BrowseQuery): SongHit[] {
if (query.mode !== "tracks") return []; if (query.mode !== "tracks") return [];
const needle = normalize(query.search); const needle = normalize(query.search);
const hits: SongHit[] = []; const hits: SongHit[] = [];
for (const album of pool(query.albums, query.filter)) { for (const album of pool(query.albums, query.group)) {
album.tracks.forEach((track, index) => { album.tracks.forEach((track, index) => {
if (!needle || normalize(track.title).includes(needle)) { if (!needle || normalize(track.title).includes(needle)) {
hits.push({ album, index, title: track.title, duration: track.duration }); hits.push({ album, index, title: track.title, duration: track.duration });

View File

@@ -146,6 +146,28 @@ button {
margin: 0 auto; margin: 0 auto;
} }
.shelf-row {
display: flex;
gap: 20px;
overflow-x: auto;
padding: 4px 4px 16px;
cursor: grab;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* old Edge */
}
.shelf-row::-webkit-scrollbar {
display: none; /* Chrome, Safari */
}
.shelf-row.dragging {
cursor: grabbing;
}
.shelf-row .card {
flex: 0 0 180px;
}
.round { .round {
border-radius: 999px; border-radius: 999px;
border: none; border: none;