Files
jellytau/docs/specs/library-mosaic.md
T
dtourolle 3363ff7f08 Merge branch 'master' into worktree-mosaic-library
# Conflicts:
#	scripts/extract-traces.test.ts
2026-08-16 00:51:46 +02:00

126 lines
6.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Spec: Library mosaic (library overview + home shortcuts)
**Status:** Implemented
**Requirements:** UR-075 → DR-174, DR-175 (with UR-067 → DR-117 extended)
**UX spec:** [ux-flows.md](../ux-flows.md) §5C.2 (Favourites)
## Summary
The library overview and the home "Your Libraries" strip stop being fixed-shape
grids and become a **mosaic**: rows share one height, and each tile is as wide as
its own artwork is. A square music cover, a 16:9 library backdrop and a 2:3
poster sit in the same row at their own proportions instead of all three being
cropped into whichever box the grid picked. Favourites gain a tile per category,
placed beside the library that category belongs to, alongside the existing
cross-library entry.
## Motivation
Every surface here shows artwork of more than one shape. The grid resolved that
by choosing one shape and cropping to it — and the home strip said so out loud:
> Uniform 16:9 artwork so music (square) and video libraries line up at the same
> height in this mixed row.
Lining them up is right; cropping the covers to do it is not. Holding the
**height** fixed and letting the **width** vary achieves the same alignment with
no crop at all, which is the whole idea of a justified layout.
Favourites had one entry for everything. With per-category tiles, "my favourite
albums" is one tap from the library page rather than a tap plus a tab.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Collection type → favourites category (`movies` → Movies, `livetv` → none) | **Rust** | Jellyfin vocabulary. It changes when Jellyfin renames a collection type, never when this page is redesigned — the same test that put `SearchScope::item_types` in Rust. Shipping it in Svelte would have re-created the leak [scoped-search-boundary.md](scoped-search-boundary.md) exists to document. |
| Which scopes exist at all (`SearchScope`) | **Rust** | Already there; unchanged. |
| Row packing: heights, widths, justification, clamping | Frontend | Geometry of a rendered page. It changes when the layout is redesigned and never when the API does. |
| Assumed artwork shape before the image loads (music = square, else wide) | Frontend | The shape of a *picture*, not a taxonomy — and it is only a starting guess, overruled by the decoded image. |
| Tile labels, order, and showing a category's tile once | Frontend | Pure presentation: wording and placement. |
Borderline row: the "assumed artwork shape" is a per-collection-type default, and
any per-collection-type table deserves suspicion. The tie-breaker: it does not
decide *what a category means* or what is fetched — it seeds a pixel dimension
that the loaded bitmap immediately corrects. Getting it wrong costs one re-pack,
not a wrong result. The scope mapping, which does decide what is fetched, went to
Rust.
## Design
### Wire
`Library` gains one optional field, derived at construction:
```rust
pub struct Library {
pub id: String,
pub name: String,
pub collection_type: String,
pub image_tag: Option<String>,
pub favorites_scope: Option<SearchScope>, // ← new
}
impl SearchScope {
pub fn for_collection_type(collection_type: &str) -> Option<SearchScope>;
}
```
```ts
type Library = { ; favoritesScope?: SearchScope | null }
```
`Library::new` derives it, so the four construction sites (online views, two
offline cache reads, tests) cannot forget it. `None` is *omitted* from the JSON,
not sent as null. No new command, no new event.
### Layout
`src/lib/components/library/mosaic.ts` — pure, no DOM:
- `layoutMosaic(items, { containerWidth, targetHeight, gap })` → rows of tiles
with pixel boxes. Tiles join a row until the height needed to fill the width
drops to the target; the row closes there and is justified to the container
width, the rounding remainder absorbed by its widest tile. The **last row is
not justified** (one leftover tile would inflate into a banner) — it sits at
the target height, left-aligned.
- `layoutMosaicStrip(items, height)` → the same rule as one fixed-height row, for
a horizontally scrolling shelf.
- `mosaicTargetHeight(containerWidth)` → the row height chosen when the caller
doesn't pick one. Bounded so a phone still fits two tiles across and a desktop
doesn't turn each library into a billboard.
- Ratios are clamped to a band (0.52.5) so one panorama can't own a row.
`MosaicGrid.svelte` supplies the two things only the DOM knows — the measured
container width (`bind:clientWidth`) and the artwork's decoded ratio — and
renders the caller's `tile` snippet. `CachedImage` gained an `onNaturalSize`
callback for the second. Measured ratios are committed in one debounced batch
(120 ms): artwork arrives over several hundred milliseconds and re-packing per
image would shuffle the grid under the pointer.
`MosaicTile.svelte` draws one tile at an exact pixel box, with its label written
**over** the bottom of the artwork. A caption below the box would add height the
layout didn't compute, and a caption that wrapped to two lines would break the
row alignment the mosaic exists to provide.
### Composition
`libraryMosaic.ts` (pure, tested) builds the tile list: the cross-library
favourites entry first, then each library followed by its own category tile. A
category appears **once** — two movie libraries share one favourites list, so a
tile each would be two tiles to the same place. A library whose `favoritesScope`
is absent (Live TV, channels, books) gets no tile rather than one opening an
unfiltered list.
Home uses the same tiles in `layout="strip"` but **without** the favourites tiles:
home already carries Favourite Movies / Shows / Music rows of its own, and a
second entry point in the strip above them would be redundant.
## Out of scope
- The item grids inside a library (`/library/movies`, `/library/music/albums`, …).
Those show one item type each, so a uniform grid crops nothing; the mosaic buys
them nothing but reflow.
- Backdrop/collage artwork for libraries with no image of their own.
- Reordering or pinning libraries.