updates
This commit is contained in:
1
python-backend/.gitignore
vendored
1
python-backend/.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
config.yml
|
config.yml
|
||||||
|
/.musicmouse-cache
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user