From 08615230154cca8b280cc7d394dbbfffc03b5190 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 15 Aug 2026 23:57:09 +0200 Subject: [PATCH] feat(library,home): lay libraries out as a mosaic, with favourites per category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library overview and the home shortcut strip showed artwork of three different shapes — square music covers, 16:9 library backdrops, 2:3 posters — in grids that pick one box and crop everything to it. The home strip said so in a comment: it forced `aspect="video"` on music libraries so the row would line up, which lined it up by cutting the covers down. Both surfaces are now justified mosaics: rows share one height and each tile is as wide as its own artwork. `layoutMosaic` is a pure module — it packs tiles until the height needed to fill the container drops to the target, justifies the row by absorbing the rounding remainder into its widest tile, and deliberately leaves the last row unstretched so one leftover tile does not inflate into a banner. The component supplies only what the DOM knows: the measured container width, and the artwork's *decoded* aspect ratio (via a new `onNaturalSize` on CachedImage), committed in one debounced batch so the grid does not reshuffle once per image as artwork lands. Favourites gain a tile per category beside the library it belongs to, alongside the existing cross-library entry. Which collection type maps to which category is Jellyfin vocabulary, so it is derived in Rust — `SearchScope::for_collection_type`, stamped onto every `Library` by a new constructor and carried over as an optional `favoritesScope`. Deriving it in Svelte would have rebuilt the exact leak `SearchScope::item_types` was extracted to close. A category shows one tile however many libraries share it, and a library kind favourites do not carve up (Live TV, channels, books) gets none. Also corrects the requirements-count test, which the UR-074 commit left one behind. Spec: docs/specs/library-mosaic.md TRACES: UR-075, UR-067 | DR-163, DR-164 | UT-158..UT-162 --- docs/requirements.md | 9 + docs/specs/library-mosaic.md | 125 + docs/traceability.md | 9180 +++++++++-------- scripts/extract-traces.test.ts | 6 +- src-tauri/src/repository/offline.rs | 45 +- src-tauri/src/repository/online.rs | 12 +- src-tauri/src/repository/types.rs | 101 + src/lib/api/bindings.ts | 14 +- src/lib/components/common/CachedImage.svelte | 20 +- src/lib/components/library/MosaicGrid.svelte | 97 + src/lib/components/library/MosaicTile.svelte | 90 + .../components/library/libraryMosaic.test.ts | 98 + src/lib/components/library/libraryMosaic.ts | 87 + src/lib/components/library/mosaic.test.ts | 179 + src/lib/components/library/mosaic.ts | 203 + src/lib/utils/favoritesView.ts | 19 + src/routes/+page.svelte | 43 +- src/routes/library/+page.svelte | 86 +- 18 files changed, 6111 insertions(+), 4303 deletions(-) create mode 100644 docs/specs/library-mosaic.md create mode 100644 src/lib/components/library/MosaicGrid.svelte create mode 100644 src/lib/components/library/MosaicTile.svelte create mode 100644 src/lib/components/library/libraryMosaic.test.ts create mode 100644 src/lib/components/library/libraryMosaic.ts create mode 100644 src/lib/components/library/mosaic.test.ts create mode 100644 src/lib/components/library/mosaic.ts diff --git a/docs/requirements.md b/docs/requirements.md index b9bb4afc..65ed0ff3 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -84,6 +84,7 @@ For a narrative overview of the system design, see | UR-071 | Media the viewer is watching can be **kept**, by a whole-file download that runs in the background independently of playback and at its own quality, so it is unaffected by bitrate changes. Where the streamed bytes already are that file (direct play), they are kept rather than fetched twice. A completed download is then played from disk rather than streamed again | Medium | Proposed | | UR-073 | Watched state is something the viewer can **set**, not only something playback records. Any episode, season, series or movie can be marked watched — or unwatched again — from where it is shown, without sitting through it or erasing its history wholesale. Marking a season or series covers the episodes inside it, and works with the server unreachable | Medium | Done | | UR-072 | Each page opens where a page should open. Moving to a new screen starts at the top of it, and going Back returns the viewer to the place they left — their position in a long library grid or home screen, not the top of it. A page never inherits the scroll position of the page before it | Medium | Done | +| UR-075 | Artwork is shown at the shape it was made in. Where a screen presents a set of things side by side — the libraries on the library page and on home — they are laid out as a mosaic: rows of a common height in which each tile is as wide as its own picture, rather than a grid that crops every cover to one box. Favourites are reachable per category from that same mosaic, beside the library they belong to, not only as one undifferentiated list | Medium | Done | | UR-074 | Video streaming can be held to a **bandwidth budget the viewer sets**, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default | Medium | Done | --- @@ -324,6 +325,8 @@ Internal architecture, components, and application logic. | DR-156 | A page no longer inherits the previous page's scroll position. The shell keeps its scrollers alive across navigation by design — the root layout, the home page and the library layout each own a `flex-1 overflow-y-auto` box that outlives the route inside it, which is what lets `BottomUi` be a flex sibling rather than a measured overlay — but the element therefore never remounts and its `scrollTop` survives the route change. SvelteKit's own scroll restoration could not help: it saves and restores `window` scroll, and in this app the window never scrolls at all, so there was no scroll handling of any kind. The symptom was that opening an item from half-way down a library grid dropped the viewer half-way down the detail page, and returning to the grid landed at the top of it — exactly backwards. `ScrollMemory` (pure, one instance per container, keyed on path + query so a genre-filtered grid keeps its own place) records the offset a route is left at in `beforeNavigate` and decides in `afterNavigate`: `link`/`goto`/`form` reset to the top, `popstate` restores that route's saved offset, and the initial `enter` is left alone. Deciding does not consume the offset, so a route returned to more than once restores each time. Applied via the `scrollContainer` action on all three scrollers | UI | UR-072 | Done | | DR-160 | Picture-in-picture works on the path that actually plays video. PiP shrinks the whole *Activity*, so `canEnterPip` demanded a native ExoPlayer `SurfaceView` be attached and rendering — `isPlayingVideo() && getSurfaceView() != null && isVideoSurfaceAttached()`. But the native path sits behind `experimentalNativeVideo`, which defaulted to **off**, so in the shipping configuration video played in the WebView's `