126 lines
6.0 KiB
Markdown
126 lines
6.0 KiB
Markdown
# 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.5–2.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.
|