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

6.0 KiB
Raw Permalink Blame History

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 §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 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:

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>;
}
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.