feat(library,home): lay libraries out as a mosaic, with favourites per category

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
This commit is contained in:
2026-08-15 23:57:09 +02:00
parent d49d027020
commit 0861523015
18 changed files with 6111 additions and 4303 deletions
+9
View File
@@ -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 `<video>` element and all three conditions were false. `enterPip` bailed with "Not entering PiP: no local video playing" every single time: the button was offered (gated only on OS capability) and could not work, however it was pressed. The manager now accepts either surface. The frontend reports the element through `AndroidPictureInPicture.setHtml5VideoState(active, width, height, playing)` — intrinsic size because the PiP window's aspect ratio came from the letterboxed surface's measured bounds, which do not exist here, and play state because `ExoPlayer.isPlaying` is false on this path and the PiP play/pause action would be frozen on "Play" mid-playback. Two behaviours invert when the WebView *is* the video: it must stay visible in PiP rather than be hidden (`hideWebView` is now gated on the native path — hiding it would leave an empty black window), and the play/pause `RemoteAction` has to reach the element, so the receiver dispatches `jellytau-pip-play`/`jellytau-pip-pause` DOM events instead of driving ExoPlayer. `jellytau-pip-entered`/`-exited` let the player strip its own chrome, since controls, title and gradients would otherwise be rendered into a window a couple of inches wide. The `<video>` is deregistered on teardown so PiP is never offered over a video that has gone | UI | UR-041 | Done (pending device verification) |
| DR-162 | Video streams are opened against a **bandwidth ceiling the user chose**, instead of a fixed allowance nobody could change. Every video URL carried `MaxStreamingBitrate=20000000`/`VideoBitrate=18000000`, `PlaybackInfo` negotiated at 20 Mbps, and the device profile advertised `999999999` — so on a metered or slow connection the only lever was not watching. `StreamingQuality` is a ladder of ceilings (Original, 20/10/8/4/2/1 Mbps, 720 kbps) in which a step is not a label but a bundle of transcode parameters: the total ceiling, the audio share of it, and the resolution that budget can carry. It lives in Rust because those numbers are Jellyfin encoding vocabulary — the frontend names a variant and reads labels back over `player_get_streaming_qualities`, the same arrangement as the EQ preset curves. The video bitrate is the total *minus* the audio share, so the two together honour the cap rather than overshooting it by the size of the audio track, and `MaxHeight` falls with the ladder so a small budget is not spent on pixels it cannot afford. The cap has to reach the **negotiation**, not only the transcode URL: `max_static_bitrate` in the device profile is what makes the server refuse to direct-play a source fatter than the ceiling, and without it a 30 Mbps remux is handed over untouched and every URL parameter downstream is moot — which is why it is applied at all four places that decide bandwidth (the HLS builder, `PlaybackInfo`, `open_live_stream`, and the background-audio handoff, which takes the lower of the cap and its own 384 kbps). The ceiling is process-wide rather than a field on `OnlineRepository`, mirroring `INCLUDE_CATALOG_BROWSE`: it is a preference about *this device's connection*, it must survive a repository rebuilt on re-login, and every builder plus the negotiation have to agree on it or the cap leaks. Settings owns the durable default and is the only writer to `app_settings` — persisted unlike the rest of `VideoSettings`, because a limit set for a metered connection that silently reverts to uncapped on the next launch spends the user's data with no changed setting to show for it — and it is restored at startup from the async runtime, defaulting to uncapped if the read fails so a database problem degrades to the old behaviour rather than to an arbitrary limit. The in-player menu is the per-video override: a cap is a property of the stream the server is producing, so it cannot be applied to one already in flight — `player_set_stream_quality` re-opens the stream at the new quality and resumes at the current position, reloading a native backend itself and handing HTML5 a URL for the same `reloadSource` primitive the audio-track switch uses, so no strategy branch lives in the UI. It deliberately does not persist. This gives UR-070 its resume-at-the-same-point mechanism; the server-offered per-item rendition list that requirement also asks for remains proposed | Playback | UR-074, UR-070 | Done |
| DR-163 | Tiles of mixed shapes are laid out **justified** rather than gridded. A CSS grid gives every cell one box, so on a page holding square music covers, 16:9 library backdrops and 2:3 posters at once, everything that is not the chosen shape is cropped to it — the home shortcut strip was explicitly forcing `aspect="video"` on music libraries for exactly this reason, which lined the row up by cutting the covers down. `layoutMosaic` packs tiles into rows of a **shared height** and gives each its own width from its own aspect ratio: it adds tiles to a row until the height needed to fill the container has fallen to the target, closes the row there (so rows land at or below the target, never above), and justifies the row to the container width by absorbing the rounding remainder into its widest tile, where a pixel is least visible. The last row is deliberately *not* justified — with one tile left over, filling the width would inflate it to a banner — so it sits at the target height, left-aligned. Ratios are clamped to a band, which costs a crop on genuine outliers and stops one panorama owning a row or one very tall image shrinking to a sliver. It is a pure module with no DOM: the component supplies only the two things the DOM knows — the measured container width, and the artwork's *decoded* aspect ratio, reported by `CachedImage` so the layout uses the shape an image actually has rather than the one its item type implies. Those measurements are committed in one debounced batch rather than per image, because artwork arrives over several hundred milliseconds and re-packing on each arrival would shuffle the grid under the pointer repeatedly. Labels are drawn *over* the bottom of each tile rather than beneath it: a caption below sits outside the computed box, and one that wraps to two lines would break the row alignment the layout exists to provide | UI | UR-075 | Done |
| DR-164 | A library knows which favourites category it belongs to, and the frontend does not work it out. The mosaic offers a favourites tile per category beside its library, which needs a collection-type → category answer; deriving it in Svelte would have re-created the exact leak `SearchScope::item_types` was extracted to close (docs/specs/scoped-search-boundary.md) — one table of Jellyfin vocabulary, differing only in which vocabulary. `SearchScope::for_collection_type` maps `movies`/`tvshows`/`music` and returns `None` for everything else, so a Live TV or books library gets no tile at all rather than one opening an unfiltered list; `All` is never derived from a library, being the cross-library entry offered beside them rather than a property of one. `Library::new` stamps the result onto every library at construction — a constructor rather than a struct literal precisely so a derived field cannot be forgotten at one of the four sites — and it rides to the frontend as an optional `favoritesScope`, absent rather than null when there is none. The UI's remaining share is presentation only: what to call the tile, where to put it, and showing a category's tile **once** however many libraries share it, since two movie libraries have one favourites list between them | UI | UR-075, UR-067 | Done |
| DR-161 | Native video is the default, so picture-in-picture has a real surface. DR-160 makes PiP work on the HTML5 path, but that path can only ever shrink the *UI* into the PiP window; showing the video itself needs the SurfaceView behind the WebView, which is what `experimentalNativeVideo` gates. The flag now defaults to on when the user has never chosen, with an explicit stored choice still winning in both directions so anyone who turned it off keeps it off. This is a deliberate acceptance of risk: the flag existed because the native path was an unfinished spike, and `VideoPlayer.scrubRegression.test.ts` documents its history — a native init that flipped to HTML5 mid-lifecycle and left seeks going down one path while ExoPlayer played on another. Those tests pin the **flag-off** interim override (native response overridden to HTML5, backend stopped once), which the default no longer selects, so they now mock the flag off rather than inherit it: they still guard that path, but they no longer describe what ships. The native scrub/seek path is consequently not covered by the suite and needs device verification | UI | UR-041, UR-003 | Needs device verification |
| DR-159 | The background-audio handoff stops leaking its relative timeline. The handoff plays the episode as a *relative* stream — the audio-only URL is built with `StartTimeTicks` = the position the screen was locked at, so ExoPlayer's zero is the handoff point — and `background_audio_base` holds the offset that turns one back into a real position. The base was a **display-only** correction, applied in exactly two places (the lockscreen scrubber and the internal truncation maths) while every other consumer worked in the relative timeline treating the number as absolute. Each crossing threw away exactly `base` seconds, which is why the jump-back distance varied with where the screen was locked and read as random. Three crossings were live: progress reporting to Jellyfin sent the relative position every 30s, so the server was told `real base` — and since DR-155 now mirrors the server's position back and refreshes on a cache hit, that regressed value returned as the resume point (lock at 40 min, listen to 90, reopen at 50); lockscreen seeks went out absolute and came back relative, against a chunked length-less transcode that cannot honour a seek at all, so a clamped seek landed at stream zero; and media3's own `seekToDefaultPosition`/`seekBack`/`seekForward` bypassed the `ForwardingPlayer` wrapper entirely, reaching the real ExoPlayer — `Util.handlePlayButtonAction` seeking an ended player to the relative zero being the same mechanism as DR-129's truncation bug through a different door. The fix converts **once, at the boundary**: `JellyTauPlayer`'s position tick adds the base (and shifts the duration with it, since the stream's own length is only what remains) before either `nativeOnPositionUpdate` or the lockscreen sees it, so position updates, progress reports, the frontend and the truncation check all speak the episode's timeline and none needs to know a handoff happened. The base is consequently *removed* from `claim_stream_resume`, `truncated_stream_resume_position` and `player_exit_background_audio`, where adding it now double-counts, and the lockscreen's `positionOffsetMs` addition goes with it (the field remains, read-only, as the tick's input). Inbound seeks go the other way: `seek_absolute` is the new boundary for every outside seek, re-opening the stream at the requested position via `resume_stream_at` when a handoff is active — which is what `onSeekTo` had claimed for months in a comment describing code that did not exist — and an ordinary seek otherwise. `seekToDefaultPosition` is swallowed rather than forwarded, since Rust already owns what "play after the stream ended" means and the `play()` that follows reaches it. Exit reads the position *before* clearing either base, or a tick landing in between hands back a relative one | Player | UR-040, UR-005, UR-025 | Done (pending device verification) |
| DR-158 | A watched toggle, on the episode row, the season header, the series and movie hero, and the Episode Focus View. Both halves of the backend already existed and neither had a caller: `mark_played` (`POST /PlayedItems`) was reachable only from the sync drain replaying rows the *reporter* had queued, and `clear_watch_history` (`DELETE /PlayedItems`) only from the destructive "erase this series' history" button — so the sole way to mark something watched was to play it. Jellyfin applies both recursively over a season or series, so the container case needs no client-side fan-out *online*. Offline it does: `storage_set_watched` writes the item **and its descendants** (drawn from `items` by `parent_id`/`album_id`/`season_id`/`series_id`, so an uncached id selects nothing and the statement no-ops instead of raising a foreign-key error), because otherwise marking a season watched with no server would tick the season and leave every episode inside it unwatched. It is deliberately separate from `storage_mark_played`, which stays the single-item "this finished playing" path that increments `play_count`. Un-marking clears the resume position as well as the flag, matching the server. `QueuedOp::MarkUnplayed` gives the queue the missing direction — pushing as `clear_watch_history` — so the toggle works offline both ways rather than only one; without it un-marking would have been the half that needed a connection. The button is an everyday toggle, so unlike `ClearHistoryButton` it does not confirm, and it holds an optimistic state because the caller's `watched` prop only catches up after a reload (a season means a round trip, during which the button would otherwise appear to ignore the tap) | UI | UR-073 | Done |
@@ -416,6 +419,7 @@ Internal architecture, components, and application logic.
| UR-072 | - | DR-156 |
| UR-073 | - | DR-158 |
| UR-074 | - | DR-162 |
| UR-075 | - | DR-163, DR-164 |
---
@@ -570,6 +574,11 @@ Internal architecture, components, and application logic.
| UT-154 | `mark_unplayed` parses to `QueuedOp::MarkUnplayed` and is rejected without an item id, and a queued un-mark drains to the server as `clear_watch_history` | DR-158 | Done |
| UT-156 | A capped step reaches the transcode URL as all four of its parts (total ceiling, the video/audio split summing to the cap, and a `MaxHeight`), the uncapped default keeps the historical 20/18 Mbps allowance and constrains no resolution, and the background-audio handoff takes the lower of the cap and its own 384 kbps | DR-162 | Done |
| UT-157 | The quality ladder is internally consistent — video + audio equals the cap at every step, audio never consumes the budget, only `Original` is uncapped — descends in bitrate, resolution and audio share together, and round-trips through the serde token it is persisted as | DR-162 | Done |
| UT-158 | Justified rows fill the container width exactly and never overflow it, every tile in a row shares one height, and each tile's width follows its own aspect ratio — a 16:9 tile coming out more than twice the width of a 2:3 tile at the same height | DR-163 | Done |
| UT-159 | The awkward cases of the packing: a short last row is left at the target height rather than stretched across the container, a last row that would overflow is brought down, an extreme ratio is clamped instead of taking a row to itself, a missing or nonsensical ratio falls back to square instead of collapsing the tile, an unmeasured container renders nothing rather than 1px tiles, and every tile is placed exactly once in order | DR-163 | Done |
| UT-160 | The default row height suits its container: it grows with the width, stays inside its bounds, and at phone width still fits two 16:9 tiles side by side | DR-163 | Done |
| UT-161 | A collection type maps to its favourites scope (`movies`/`tvshows`/`music`), every other kind — Live TV, channels, box sets, books, unknown — maps to none rather than to `All`, and a constructed library carries the scope across the wire as `favoritesScope`, omitted entirely when it has none | DR-164 | Done |
| UT-162 | The mosaic's composition: the cross-library favourites entry leads, each library is followed by its own category tile pointing at that category's tab, a category shared by two libraries still yields one tile, a library kind favourites do not carve up yields none, a scope the page offers no tab for is ignored, and every tile is uniquely keyed | DR-163, DR-164 | Done |
| UT-153 | Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container alone | DR-156 | Done |
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
+125
View File
@@ -0,0 +1,125 @@
# Spec: Library mosaic (library overview + home shortcuts)
**Status:** Implemented
**Requirements:** UR-075 → DR-163, DR-164 (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.
+4964 -4216
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -173,10 +173,10 @@ describe("live requirements.md", () => {
);
const defined = countDefinedRequirements(md);
expect(defined.UR).toBe(73);
expect(defined.UR).toBe(75);
expect(defined.IR).toBe(32);
expect(defined.DR).toBe(156);
expect(defined.DR).toBe(159);
expect(defined.JA).toBe(35);
expect(defined.total).toBe(296);
expect(defined.total).toBe(301);
});
});
+19 -26
View File
@@ -990,14 +990,13 @@ impl OfflineRepository {
self.db_service
.query_many(query, |row| {
Ok(Library {
id: row.get(0)?,
name: row.get(1)?,
collection_type: row
.get::<_, Option<String>>(2)?
Ok(Library::new(
row.get(0)?,
row.get(1)?,
row.get::<_, Option<String>>(2)?
.unwrap_or_else(|| "unknown".to_string()),
image_tag: row.get(3)?,
})
row.get(3)?,
))
})
.await
.map_err(|e| RepoError::Database { message: e })
@@ -1190,14 +1189,13 @@ impl MediaRepository for OfflineRepository {
self.db_service
.query_many(query, |row| {
Ok(Library {
id: row.get(0)?,
name: row.get(1)?,
collection_type: row
.get::<_, Option<String>>(2)?
Ok(Library::new(
row.get(0)?,
row.get(1)?,
row.get::<_, Option<String>>(2)?
.unwrap_or_else(|| "unknown".to_string()),
image_tag: row.get(3)?,
})
row.get(3)?,
))
})
.await
.map_err(|e| RepoError::Database { message: e })
@@ -3441,18 +3439,13 @@ mod tests {
// Simulate the online path persisting the server's library list.
let server_libs = vec![
Library {
id: "music".into(),
name: "Music".into(),
collection_type: "music".into(),
image_tag: None,
},
Library {
id: "movies".into(),
name: "Movies".into(),
collection_type: "movies".into(),
image_tag: Some("tag".into()),
},
Library::new("music".into(), "Music".into(), "music".into(), None),
Library::new(
"movies".into(),
"Movies".into(),
"movies".into(),
Some("tag".into()),
),
];
let saved = repo.save_libraries_to_cache(&server_libs).await.unwrap();
assert_eq!(saved, 2);
+7 -5
View File
@@ -946,11 +946,13 @@ impl MediaRepository for OnlineRepository {
Ok(response
.items
.into_iter()
.map(|lib| Library {
id: lib.id,
name: lib.name,
collection_type: lib.collection_type.unwrap_or_else(|| "unknown".to_string()),
image_tag: lib.image_tags.and_then(|tags| tags.primary()),
.map(|lib| {
Library::new(
lib.id,
lib.name,
lib.collection_type.unwrap_or_else(|| "unknown".to_string()),
lib.image_tags.and_then(|tags| tags.primary()),
)
})
.collect())
}
+101
View File
@@ -36,6 +36,38 @@ pub struct Library {
pub collection_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_tag: Option<String>,
/// The favourites scope this library's contents fall under, or `None` for a
/// library kind favourites does not carve up (Live TV, channels, books…).
///
/// Derived here rather than in the UI: which collection type maps to which
/// scope is Jellyfin vocabulary, and the frontend must not hold a
/// collection-type → category table any more than an item-type one. See
/// `SearchScope::for_collection_type`.
///
/// TRACES: UR-075 | DR-164
#[serde(default, skip_serializing_if = "Option::is_none")]
pub favorites_scope: Option<SearchScope>,
}
impl Library {
/// Build a library, deriving everything that follows from its collection
/// type. Prefer this over the struct literal so a new derived field cannot
/// be forgotten at one of the construction sites.
pub fn new(
id: String,
name: String,
collection_type: String,
image_tag: Option<String>,
) -> Self {
let favorites_scope = SearchScope::for_collection_type(&collection_type);
Self {
id,
name,
collection_type,
image_tag,
favorites_scope,
}
}
}
/// User-specific data for an item (playback state, favorites, etc.)
@@ -345,6 +377,27 @@ impl SearchScope {
),
}
}
/// The scope a library of this Jellyfin `CollectionType` belongs to, or
/// `None` when its contents are not something favourites are browsed by.
///
/// Same reasoning as `item_types`: this table is Jellyfin vocabulary and
/// changes when Jellyfin renames a collection type, not when the library
/// page is redesigned — so it lives here rather than in the UI that renders
/// a per-library favourites tile.
///
/// `All` is never returned: it is the *absence* of a category, offered
/// alongside the libraries rather than derived from one.
///
/// TRACES: UR-075 | DR-164 | UT-161
pub fn for_collection_type(collection_type: &str) -> Option<SearchScope> {
match collection_type {
"movies" => Some(SearchScope::Movies),
"tvshows" => Some(SearchScope::Tv),
"music" => Some(SearchScope::Music),
_ => None,
}
}
}
/// Options for search queries
@@ -653,6 +706,54 @@ mod search_scope_tests {
let all: SearchOptions = serde_json::from_str(r#"{"scope": "all"}"#).unwrap();
assert!(matches!(all.scope, Some(SearchScope::All)));
}
/// TRACES: DR-164 | UT-161
#[test]
fn test_collection_type_maps_to_its_favorites_scope() {
assert_eq!(
SearchScope::for_collection_type("movies"),
Some(SearchScope::Movies)
);
assert_eq!(
SearchScope::for_collection_type("tvshows"),
Some(SearchScope::Tv)
);
assert_eq!(
SearchScope::for_collection_type("music"),
Some(SearchScope::Music)
);
}
/// A library kind favourites are not browsed by gets no tile at all, rather
/// than one that opens an unfiltered list. `All` is never derived from a
/// library — it is the cross-library entry offered beside them.
///
/// TRACES: DR-164 | UT-161
#[test]
fn test_uncategorised_collection_types_have_no_favorites_scope() {
for collection_type in ["livetv", "channels", "boxsets", "books", "unknown", ""] {
assert_eq!(
SearchScope::for_collection_type(collection_type),
None,
"{collection_type} should not carry a favourites scope"
);
}
}
/// TRACES: DR-164 | UT-161
#[test]
fn test_library_carries_its_favorites_scope_to_the_frontend() {
let music = Library::new("1".into(), "Music".into(), "music".into(), None);
assert_eq!(music.favorites_scope, Some(SearchScope::Music));
let json = serde_json::to_value(&music).unwrap();
assert_eq!(json["favoritesScope"], "music");
// A library with no scope omits the field rather than sending null.
let livetv = Library::new("2".into(), "Live TV".into(), "livetv".into(), None);
let json = serde_json::to_value(&livetv).unwrap();
assert!(json.get("favoritesScope").is_none());
}
}
#[cfg(test)]
+13 -1
View File
@@ -2053,7 +2053,19 @@ export type JRayActor = { name: string; imdb_id?: string; tmdb_id?: string; jell
/**
* Library (media collection)
*/
export type Library = { id: string; name: string; collectionType: string; imageTag?: string | null }
export type Library = { id: string; name: string; collectionType: string; imageTag?: string | null;
/**
* The favourites scope this library's contents fall under, or `None` for a
* library kind favourites does not carve up (Live TV, channels, books).
*
* Derived here rather than in the UI: which collection type maps to which
* scope is Jellyfin vocabulary, and the frontend must not hold a
* collection-type category table any more than an item-type one. See
* `SearchScope::for_collection_type`.
*
* TRACES: UR-075 | DR-164
*/
favoritesScope?: SearchScope | null }
/**
* Live stream information returned from opening a Live TV / channel stream.
*
+19 -1
View File
@@ -11,6 +11,13 @@
maxHeight?: number;
class?: string;
alt?: string;
/**
* Called once the bitmap is decoded, with its intrinsic pixel size. Lets a
* layout that sizes boxes from artwork (the mosaic) use the shape the image
* actually has rather than the one its item type suggests.
* TRACES: UR-075 | DR-163
*/
onNaturalSize?: (width: number, height: number) => void;
}
let {
@@ -21,6 +28,7 @@
maxHeight,
class: className = "",
alt = "",
onNaturalSize,
}: Props = $props();
let imageUrl = $state<string | null>(null);
@@ -86,5 +94,15 @@
</svg>
</div>
{:else}
<img src={imageUrl} {alt} class={className} />
<img
src={imageUrl}
{alt}
class={className}
onload={(e) => {
const img = e.currentTarget as HTMLImageElement;
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
onNaturalSize?.(img.naturalWidth, img.naturalHeight);
}
}}
/>
{/if}
@@ -0,0 +1,97 @@
<!--
Justified mosaic of tiles: rows of a shared height, each tile as wide as its
own aspect ratio says it should be.
The geometry is `mosaic.ts` (pure, unit-tested); this component supplies the
two things only the DOM knows — how wide the container is, and what shape the
artwork turned out to be — and renders whatever the caller's `tile` snippet
draws.
Measured ratios are committed in one batch rather than per image: artwork
arrives over a few hundred milliseconds, and re-packing on each arrival would
shuffle the grid under the viewer's cursor several times over.
TRACES: UR-075 | DR-163
-->
<script lang="ts" generics="T extends { key: string; ratio: number }">
import type { Snippet } from "svelte";
import { onDestroy } from "svelte";
import {
layoutMosaic,
layoutMosaicStrip,
mosaicTargetHeight,
type MosaicTile,
} from "./mosaic";
interface Props {
items: T[];
/** Row height. Defaults to one suited to the container's width. */
targetHeight?: number;
gap?: number;
/**
* "rows" wraps into justified rows and fills the container.
* "strip" keeps one row at a fixed height and scrolls sideways — the same
* no-distortion rule applied to a shelf.
*/
layout?: "rows" | "strip";
tile: Snippet<[MosaicTile<T> & { reportRatio: (ratio: number) => void }]>;
}
let { items, targetHeight, gap = 8, layout = "rows", tile }: Props = $props();
let containerWidth = $state(0);
let measured = $state<Record<string, number>>({});
let pending: Record<string, number> = {};
let commitTimer: ReturnType<typeof setTimeout> | null = null;
const COMMIT_DELAY_MS = 120;
/** Below this, a measured ratio isn't worth a re-pack. */
const RATIO_EPSILON = 0.02;
function reportRatio(key: string, ratio: number) {
if (!Number.isFinite(ratio) || ratio <= 0) return;
const known = measured[key] ?? items.find((i) => i.key === key)?.ratio;
if (known !== undefined && Math.abs(known - ratio) / known < RATIO_EPSILON) return;
pending[key] = ratio;
if (commitTimer !== null) return;
commitTimer = setTimeout(() => {
commitTimer = null;
measured = { ...measured, ...pending };
pending = {};
}, COMMIT_DELAY_MS);
}
onDestroy(() => {
if (commitTimer !== null) clearTimeout(commitTimer);
});
const height = $derived(targetHeight ?? mosaicTargetHeight(containerWidth));
const sized = $derived(items.map((item) => ({ ...item, ratio: measured[item.key] ?? item.ratio })));
const rows = $derived(
layout === "strip"
? [{ height, tiles: layoutMosaicStrip(sized, height) }]
: layoutMosaic(sized, { containerWidth, targetHeight: height, gap }),
);
</script>
{#if layout === "strip"}
<!-- A strip is measured by the viewport it scrolls in, not by its content. -->
<div bind:clientWidth={containerWidth} class="overflow-x-auto pb-2">
<div class="flex w-max items-start" style="gap: {gap}px;">
{#each rows[0].tiles as placed (placed.key)}
{@render tile({ ...placed, reportRatio: (r: number) => reportRatio(placed.key, r) })}
{/each}
</div>
</div>
{:else}
<div bind:clientWidth={containerWidth} class="flex flex-col" style="gap: {gap}px;">
{#each rows as row, i (i)}
<div class="flex" style="gap: {gap}px;">
{#each row.tiles as placed (placed.key)}
{@render tile({ ...placed, reportRatio: (r: number) => reportRatio(placed.key, r) })}
{/each}
</div>
{/each}
</div>
{/if}
@@ -0,0 +1,90 @@
<!--
One tile of a mosaic: artwork at an exact pixel box, with its label written
over the bottom of the image rather than beneath it.
The label lives on the artwork on purpose — a caption below would add height
outside the box the layout computed, and a row whose captions wrap to two
lines would no longer line up with its neighbours. Keeping everything inside
the box is what lets `layoutMosaic` own the geometry completely.
TRACES: UR-075 | DR-163
-->
<script lang="ts">
import type { Snippet } from "svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props {
label: string;
width: number;
height: number;
/** Item whose Primary image is the artwork. Omit for an icon-only tile. */
itemId?: string;
imageTag?: string | null;
/** Drawn instead of artwork — favourites tiles have no image of their own. */
icon?: Snippet;
/** Tints an icon-only tile so it reads as a destination, not a broken image. */
accent?: boolean;
onclick?: () => void;
/**
* Reports the artwork's true aspect ratio once decoded, so the grid can
* re-pack against the shape the image actually has.
*/
onRatio?: (ratio: number) => void;
}
let {
label,
width,
height,
itemId,
imageTag,
icon,
accent = false,
onclick,
onRatio,
}: Props = $props();
// Request an image comfortably larger than the box so a wide tile is not
// upscaled, without refetching every time the container resizes (CachedImage
// keys its fetch on the item, not on this number).
const REQUEST_WIDTH = 480;
</script>
<button
type="button"
{onclick}
aria-label={label}
class="group/tile relative overflow-hidden rounded-lg bg-[var(--color-surface)] shadow-md
transition-transform duration-200 hover:z-10 hover:scale-[1.03] hover:shadow-2xl
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-jellyfin)]"
style="width: {width}px; height: {height}px;"
>
{#if icon}
<div
class="absolute inset-0 flex items-center justify-center
{accent
? 'bg-gradient-to-br from-[var(--color-jellyfin)]/40 to-[var(--color-jellyfin)]/5'
: 'bg-[var(--color-surface)]'}"
>
{@render icon()}
</div>
{:else if itemId}
<CachedImage
{itemId}
imageType="Primary"
tag={imageTag}
maxWidth={REQUEST_WIDTH}
alt={label}
class="absolute inset-0 w-full h-full object-cover transition-transform duration-300 group-hover/tile:scale-105"
onNaturalSize={(w, h) => onRatio?.(w / h)}
/>
{/if}
<!-- Legibility wash: only as tall as the caption needs, so artwork stays
artwork. -->
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/45 to-transparent pt-6 pb-2 px-2.5">
<p class="truncate text-left text-sm font-semibold text-white drop-shadow group-hover/tile:text-[var(--color-jellyfin)] transition-colors">
{label}
</p>
</div>
</button>
@@ -0,0 +1,98 @@
import { describe, it, expect } from "vitest";
import type { Library } from "$lib/api/types";
import { buildLibraryMosaic, assumedLibraryRatio } from "./libraryMosaic";
function lib(
id: string,
name: string,
collectionType: string,
favoritesScope?: Library["favoritesScope"],
): Library {
return { id, name, collectionType, favoritesScope } as Library;
}
const MOVIES = lib("1", "Movies", "movies", "movies");
const SHOWS = lib("2", "Shows", "tvshows", "tv");
const MUSIC = lib("3", "Music", "music", "music");
const LIVETV = lib("4", "Live TV", "livetv");
describe("buildLibraryMosaic", () => {
it("leads with the cross-library favourites entry", () => {
const entries = buildLibraryMosaic([MOVIES]);
expect(entries[0]).toMatchObject({
kind: "favorites",
scope: "all",
label: "Favourites",
href: "/library/favorites",
});
});
it("puts each library's own favourites tile right after it", () => {
const entries = buildLibraryMosaic([MOVIES, MUSIC]);
expect(entries.map((e) => e.label)).toEqual([
"Favourites",
"Movies",
"Favourite Movies",
"Music",
"Favourite Music",
]);
});
it("links a category tile to that category's favourites tab", () => {
const entries = buildLibraryMosaic([SHOWS]);
const tile = entries.find((e) => e.label === "Favourite Shows");
expect(tile).toMatchObject({ kind: "favorites", scope: "tv", href: "/library/favorites?scope=tv" });
});
it("offers a category's favourites once, however many libraries share it", () => {
const entries = buildLibraryMosaic([MOVIES, lib("5", "Kids Films", "movies", "movies")]);
expect(entries.filter((e) => e.kind === "favorites" && e.scope === "movies")).toHaveLength(1);
expect(entries.map((e) => e.label)).toEqual([
"Favourites",
"Movies",
"Favourite Movies",
"Kids Films",
]);
});
it("gives no favourites tile to a library kind favourites do not carve up", () => {
const entries = buildLibraryMosaic([LIVETV]);
expect(entries.map((e) => e.label)).toEqual(["Favourites", "Live TV"]);
});
it("ignores a scope the favourites page does not offer as a tab", () => {
const odd = lib("6", "Books", "books", "books" as Library["favoritesScope"]);
const entries = buildLibraryMosaic([odd]);
expect(entries.map((e) => e.label)).toEqual(["Favourites", "Books"]);
});
it("keeps every library, and keys tiles uniquely", () => {
const entries = buildLibraryMosaic([MOVIES, SHOWS, MUSIC, LIVETV]);
expect(entries.filter((e) => e.kind === "library")).toHaveLength(4);
expect(new Set(entries.map((e) => e.key)).size).toBe(entries.length);
});
it("has nothing but the favourites entry when there are no libraries", () => {
expect(buildLibraryMosaic([]).map((e) => e.key)).toEqual(["favorites:all"]);
});
it("gives a category tile the shape of the library it follows", () => {
const entries = buildLibraryMosaic([MUSIC, MOVIES]);
const musicFavorites = entries.find((e) => e.label === "Favourite Music")!;
const movieFavorites = entries.find((e) => e.label === "Favourite Movies")!;
expect(musicFavorites.ratio).toBe(assumedLibraryRatio(MUSIC));
expect(movieFavorites.ratio).toBe(assumedLibraryRatio(MOVIES));
});
});
describe("assumedLibraryRatio", () => {
it("assumes a square cover for music and a wide backdrop otherwise", () => {
expect(assumedLibraryRatio(MUSIC)).toBe(1);
expect(assumedLibraryRatio(MOVIES)).toBeCloseTo(16 / 9);
expect(assumedLibraryRatio(LIVETV)).toBeCloseTo(16 / 9);
});
});
@@ -0,0 +1,87 @@
// What the library overview mosaic is made of, and in what order.
//
// Pure: takes the libraries, returns the tiles to draw. No DOM, no stores — so
// the ordering and the de-duplication rules below are unit-testable rather than
// buried in markup.
//
// Note what is NOT decided here: which favourites category a library belongs to.
// That is Jellyfin vocabulary and arrives on the library itself as
// `favoritesScope` (Rust: `SearchScope::for_collection_type`). This file only
// decides what to *call* it and where to put it.
//
// TRACES: UR-075, UR-067 | DR-163, DR-164 | UT-162
import type { Library } from "$lib/api/types";
import {
FAVORITE_SCOPE_LABELS,
asFavoritesScope,
favoritesRouteUrl,
type FavoritesScope,
} from "$lib/utils/favoritesView";
/** Artwork shapes, as the source images generally arrive. A measured image
* overrides these (see MosaicGrid); they are the shape assumed until then. */
const SQUARE = 1;
const WIDE = 16 / 9;
export type LibraryMosaicEntry = {
/** Stable identity for the layout and for `{#each}` keying. */
key: string;
/** Assumed width / height until the artwork reports its own. */
ratio: number;
label: string;
} & (
| { kind: "library"; library: Library }
| { kind: "favorites"; scope: FavoritesScope; href: string }
);
/**
* A music library's artwork is a square cover; everything else is a wide
* backdrop. Presentation, not taxonomy: this is the shape of a picture, and it
* is a starting guess that the decoded image is allowed to overrule.
*/
export function assumedLibraryRatio(lib: Library): number {
return lib.collectionType === "music" ? SQUARE : WIDE;
}
/**
* The mosaic's tiles, in order: the cross-library favourites entry first, then
* each library followed by its own favourites tile.
*
* A category's favourites tile appears **once**, after the first library of that
* category two movie libraries ("Films", "Kids") share one favourites list, so
* a tile each would be two tiles going to the same place.
*/
export function buildLibraryMosaic(libraries: Library[]): LibraryMosaicEntry[] {
const entries: LibraryMosaicEntry[] = [
{
key: "favorites:all",
kind: "favorites",
scope: "all",
href: favoritesRouteUrl("all"),
ratio: WIDE,
label: "Favourites",
},
];
const seenScopes = new Set<FavoritesScope>(["all"]);
for (const lib of libraries) {
const ratio = assumedLibraryRatio(lib);
entries.push({ key: `library:${lib.id}`, kind: "library", library: lib, ratio, label: lib.name });
const scope = asFavoritesScope(lib.favoritesScope);
if (!scope || seenScopes.has(scope)) continue;
seenScopes.add(scope);
entries.push({
key: `favorites:${scope}`,
kind: "favorites",
scope,
href: favoritesRouteUrl(scope),
ratio,
label: `Favourite ${FAVORITE_SCOPE_LABELS[scope]}`,
});
}
return entries;
}
+179
View File
@@ -0,0 +1,179 @@
import { describe, it, expect } from "vitest";
import {
layoutMosaic,
layoutMosaicStrip,
mosaicTargetHeight,
type MosaicInput,
type MosaicRow,
} from "./mosaic";
const VIDEO = 16 / 9;
const SQUARE = 1;
const POSTER = 2 / 3;
function tiles(...ratios: number[]): MosaicInput[] {
return ratios.map((ratio, i) => ({ key: `t${i}`, ratio }));
}
function rowWidth(row: MosaicRow, gap: number): number {
return row.tiles.reduce((sum, t) => sum + t.width, 0) + gap * (row.tiles.length - 1);
}
/** Every tile bar the one that absorbs the rounding remainder keeps its ratio. */
function offRatioTiles(row: MosaicRow, tolerancePx = 1): number {
return row.tiles.filter((t) => Math.abs(t.width - t.ratio * t.height) > tolerancePx).length;
}
describe("layoutMosaic", () => {
const opts = { containerWidth: 1000, targetHeight: 160, gap: 8 };
it("fills the container width exactly on every row but the last", () => {
const rows = layoutMosaic(tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO, POSTER), opts);
expect(rows.length).toBeGreaterThan(1);
for (const row of rows.slice(0, -1)) {
expect(rowWidth(row, opts.gap)).toBe(opts.containerWidth);
}
});
it("never overflows the container, last row included", () => {
const rows = layoutMosaic(tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO, POSTER), opts);
for (const row of rows) {
expect(rowWidth(row, opts.gap)).toBeLessThanOrEqual(opts.containerWidth);
}
});
it("gives every tile in a row the same height", () => {
const rows = layoutMosaic(tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO), opts);
for (const row of rows) {
for (const tile of row.tiles) {
expect(tile.height).toBe(row.height);
}
}
});
it("honours each tile's aspect ratio — widths vary, nothing is squashed", () => {
const rows = layoutMosaic(tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO), opts);
for (const row of rows) {
// At most the single remainder-absorbing tile may be off, and only by the
// few pixels the row was short of the container width.
expect(offRatioTiles(row)).toBeLessThanOrEqual(1);
}
// A 16:9 tile is meaningfully wider than a 2:3 tile at the same height.
const all = rows.flatMap((r) => r.tiles);
const video = all.find((t) => t.key === "t0")!;
const poster = all.find((t) => t.key === "t2")!;
expect(video.width).toBeGreaterThan(poster.width * 2);
});
it("keeps rows at or below the target height", () => {
const rows = layoutMosaic(tiles(...Array(12).fill(VIDEO)), opts);
for (const row of rows) {
expect(row.height).toBeLessThanOrEqual(opts.targetHeight);
}
});
it("does not stretch a short last row across the whole container", () => {
// Two 16:9 tiles cannot fill 1000px at 160px tall (they want ~569px), so the
// last row must stay at the target height rather than blow up to fill.
const rows = layoutMosaic(tiles(VIDEO, VIDEO), opts);
expect(rows).toHaveLength(1);
expect(rows[0].height).toBe(opts.targetHeight);
expect(rowWidth(rows[0], opts.gap)).toBeLessThan(opts.containerWidth);
});
it("shrinks a last row that would otherwise overflow", () => {
// Five 16:9 tiles at 160px tall want ~1454px; the row has to come down.
const rows = layoutMosaic(tiles(VIDEO, VIDEO, VIDEO, VIDEO, VIDEO), {
...opts,
targetHeight: 400,
});
for (const row of rows) {
expect(rowWidth(row, opts.gap)).toBeLessThanOrEqual(opts.containerWidth);
}
});
it("clamps an extreme ratio instead of letting it own a row", () => {
const rows = layoutMosaic(tiles(20, SQUARE, SQUARE), { ...opts, maxRatio: 2.5 });
const panorama = rows.flatMap((r) => r.tiles).find((t) => t.key === "t0")!;
expect(panorama.width / panorama.height).toBeLessThanOrEqual(2.6);
});
it("treats a missing or nonsensical ratio as square rather than collapsing", () => {
const rows = layoutMosaic(
[
{ key: "nan", ratio: Number.NaN },
{ key: "zero", ratio: 0 },
{ key: "neg", ratio: -2 },
],
opts,
);
for (const tile of rows.flatMap((r) => r.tiles)) {
expect(tile.width).toBeCloseTo(tile.height, -1);
}
});
it("renders nothing before the container has been measured", () => {
expect(layoutMosaic(tiles(VIDEO, SQUARE), { ...opts, containerWidth: 0 })).toEqual([]);
expect(layoutMosaic(tiles(VIDEO, SQUARE), { ...opts, targetHeight: 0 })).toEqual([]);
expect(layoutMosaic([], opts)).toEqual([]);
});
it("places every tile exactly once, in order", () => {
const input = tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO, POSTER, SQUARE);
const placed = layoutMosaic(input, opts).flatMap((r) => r.tiles.map((t) => t.key));
expect(placed).toEqual(input.map((t) => t.key));
});
it("re-packs when the container narrows", () => {
const input = tiles(VIDEO, SQUARE, POSTER, VIDEO, SQUARE, VIDEO);
const wide = layoutMosaic(input, { ...opts, containerWidth: 1400 });
const narrow = layoutMosaic(input, { ...opts, containerWidth: 420 });
expect(narrow.length).toBeGreaterThan(wide.length);
});
});
describe("mosaicTargetHeight", () => {
it("still fits two 16:9 tiles across a phone", () => {
const width = 360;
const height = mosaicTargetHeight(width);
const rows = layoutMosaic(tiles(VIDEO, VIDEO, VIDEO), {
containerWidth: width,
targetHeight: height,
gap: 8,
});
expect(rows[0].tiles.length).toBeGreaterThanOrEqual(2);
});
it("grows with the container but stays within bounds", () => {
const widths = [0, 320, 600, 900, 1400, 3000];
const heights = widths.map(mosaicTargetHeight);
for (const h of heights) {
expect(h).toBeGreaterThanOrEqual(96);
expect(h).toBeLessThanOrEqual(190);
}
for (let i = 1; i < heights.length; i++) {
expect(heights[i]).toBeGreaterThanOrEqual(heights[i - 1]);
}
});
});
describe("layoutMosaicStrip", () => {
it("gives one height and ratio-derived widths", () => {
const strip = layoutMosaicStrip(tiles(VIDEO, SQUARE, POSTER), 140);
expect(strip.map((t) => t.height)).toEqual([140, 140, 140]);
expect(strip[0].width).toBe(Math.round(VIDEO * 140));
expect(strip[1].width).toBe(140);
expect(strip[2].width).toBe(Math.round(POSTER * 140));
});
it("returns nothing for a height it cannot draw", () => {
expect(layoutMosaicStrip(tiles(VIDEO), 0)).toEqual([]);
});
});
+203
View File
@@ -0,0 +1,203 @@
// Justified ("mosaic") tile layout — pure geometry, no DOM.
//
// The library overview and the home "Your Libraries" strip both show artwork of
// mixed shapes: square music covers next to 16:9 library backdrops next to 2:3
// posters. A CSS grid forces one box shape on all of them, so every tile that
// isn't that shape is cropped or letterboxed. This packs tiles into rows of a
// *shared height* and lets each keep its own width, so each tile is displayed at
// its own aspect ratio and nothing is distorted.
//
// Presentation only — nothing here knows what a library or a media item is.
//
// TRACES: UR-075 | DR-163 | UT-158, UT-159, UT-160
/** A tile to place: an opaque key and the aspect ratio (width / height) to honour. */
export interface MosaicInput {
key: string;
/** width / height. 1 = square, 16/9 ≈ 1.78, 2/3 ≈ 0.67. */
ratio: number;
}
/**
* A tile with its resolved pixel box. Generic so callers can hang whatever they
* need to render (the library, the label, a route) off the same object.
*/
export type MosaicTile<T extends MosaicInput = MosaicInput> = T & {
width: number;
height: number;
};
/** One row of tiles, all sharing `height`. */
export interface MosaicRow<T extends MosaicInput = MosaicInput> {
height: number;
tiles: MosaicTile<T>[];
}
export interface MosaicOptions {
/** Usable width in px (already net of the container's own padding). */
containerWidth: number;
/** The height rows aim for. Rows land at or below it; see `layoutMosaic`. */
targetHeight: number;
/** Gap between tiles in a row, in px. Rows are justified around it. */
gap?: number;
/**
* Ratios outside this band are clamped. An extreme tile would otherwise take a
* whole row to itself (very wide) or shrink to a sliver (very tall); clamping
* costs a little crop on the outliers and keeps the mosaic readable.
*/
minRatio?: number;
maxRatio?: number;
}
const DEFAULTS = {
gap: 8,
minRatio: 0.5,
maxRatio: 2.5,
} as const;
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
/** A ratio we can lay out: finite and positive, clamped into the band. */
function usableRatio(ratio: number, min: number, max: number): number {
if (!Number.isFinite(ratio) || ratio <= 0) return 1;
return clamp(ratio, min, max);
}
/**
* Give a row its pixel boxes.
*
* `justifyTo` is the width the row must fill *exactly* rounding each tile
* independently leaves the row a pixel or two short or long, which reads as a
* ragged right edge, so the remainder is absorbed by the widest tile (where one
* pixel is least visible). A `null` justifies nothing: the last row keeps its
* natural width and is left-aligned.
*/
function buildRow<T extends MosaicInput>(
items: T[],
height: number,
gap: number,
justifyTo: number | null,
): MosaicRow<T> {
const h = Math.max(1, Math.round(height));
const tiles: MosaicTile<T>[] = items.map((item) => ({
...item,
height: h,
width: Math.max(1, Math.round(item.ratio * h)),
}));
if (justifyTo !== null && tiles.length > 0) {
const used = tiles.reduce((sum, t) => sum + t.width, 0) + gap * (tiles.length - 1);
const delta = justifyTo - used;
if (delta !== 0) {
let widest = 0;
for (let i = 1; i < tiles.length; i++) {
if (tiles[i].width > tiles[widest].width) widest = i;
}
tiles[widest].width = Math.max(1, tiles[widest].width + delta);
}
}
return { height: h, tiles };
}
/**
* Pack `items` into justified rows.
*
* Tiles are added to a row until the height needed to fill `containerWidth` has
* fallen to `targetHeight` at which point the row is closed at that height, so
* rows come out at or slightly below the target rather than above it. The final
* row is never stretched to fill the width: with one tile left over, justifying
* would blow it up to the full container width. It sits at `targetHeight`
* instead (or lower, if its natural fit is already shorter), left-aligned.
*
* Returns `[]` for a container with no width a first paint before the element
* has been measured, which must render nothing rather than a row of 1px tiles.
*/
export function layoutMosaic<T extends MosaicInput>(
items: T[],
options: MosaicOptions,
): MosaicRow<T>[] {
const { containerWidth, targetHeight } = options;
const gap = options.gap ?? DEFAULTS.gap;
const minRatio = options.minRatio ?? DEFAULTS.minRatio;
const maxRatio = options.maxRatio ?? DEFAULTS.maxRatio;
if (containerWidth <= 0 || targetHeight <= 0 || items.length === 0) return [];
const normalized = items.map((item) => ({
...item,
ratio: usableRatio(item.ratio, minRatio, maxRatio),
}));
const rows: MosaicRow<T>[] = [];
let current: T[] = [];
let ratioSum = 0;
for (const item of normalized) {
current.push(item);
ratioSum += item.ratio;
// Width left for artwork once this row's gaps are paid for.
const available = containerWidth - gap * (current.length - 1);
const height = available / ratioSum;
if (height <= targetHeight) {
rows.push(buildRow(current, height, gap, containerWidth));
current = [];
ratioSum = 0;
}
}
if (current.length > 0) {
const available = containerWidth - gap * (current.length - 1);
const natural = available / ratioSum;
rows.push(buildRow(current, Math.min(natural, targetHeight), gap, null));
}
return rows;
}
/** Row height bounds a phone must still fit two tiles, a desktop must not
* turn each library into a billboard. */
const MIN_TARGET_HEIGHT = 96;
const MAX_TARGET_HEIGHT = 190;
/** Roughly this many tiles per row, before ratios pull the count around. */
const TILES_PER_ROW = 4;
const MIN_TILE_WIDTH = 150;
const MAX_TILE_WIDTH = 300;
/** The width/height a "typical" tile is sized against. */
const NOMINAL_RATIO = 1.6;
/**
* A row height that suits the container it is drawn in: tall enough on a desktop
* to be worth looking at, short enough on a phone that two tiles still fit side
* by side. Callers may override it; this is what the grid picks unasked.
*/
export function mosaicTargetHeight(containerWidth: number): number {
if (containerWidth <= 0) return MIN_TARGET_HEIGHT;
const tileWidth = clamp(containerWidth / TILES_PER_ROW, MIN_TILE_WIDTH, MAX_TILE_WIDTH);
return Math.round(clamp(tileWidth / NOMINAL_RATIO, MIN_TARGET_HEIGHT, MAX_TARGET_HEIGHT));
}
/**
* Lay tiles out as a single fixed-height row the shape a horizontally
* scrolling strip wants. Same principle as `layoutMosaic`: one height, natural
* widths, no distortion.
*/
export function layoutMosaicStrip<T extends MosaicInput>(
items: T[],
height: number,
options: Pick<MosaicOptions, "minRatio" | "maxRatio"> = {},
): MosaicTile<T>[] {
const minRatio = options.minRatio ?? DEFAULTS.minRatio;
const maxRatio = options.maxRatio ?? DEFAULTS.maxRatio;
if (height <= 0) return [];
const h = Math.max(1, Math.round(height));
return items.map((item) => {
const ratio = usableRatio(item.ratio, minRatio, maxRatio);
return { ...item, ratio, height: h, width: Math.max(1, Math.round(ratio * h)) };
});
}
+19
View File
@@ -33,6 +33,25 @@ export function resolveFavoritesScope(raw: string | null | undefined): Favorites
return (FAVORITE_SCOPES as readonly string[]).includes(raw) ? (raw as FavoritesScope) : "all";
}
/**
* Narrow a scope the backend supplied (e.g. `Library.favoritesScope`) to one
* this page actually offers as a tab, or `null` if it doesn't.
*
* Unlike `resolveFavoritesScope`, an unrecognised scope is *rejected* rather
* than folded into "all": a caller asking "which category is this?" wants no
* answer, not the cross-category one.
*
* TRACES: UR-075 | DR-164
*/
export function asFavoritesScope(
scope: SearchScope | null | undefined,
): FavoritesScope | null {
if (!scope) return null;
return (FAVORITE_SCOPES as readonly string[]).includes(scope)
? (scope as FavoritesScope)
: null;
}
/** URL for a tab. The default scope is omitted, keeping the base URL clean. */
export function favoritesRouteUrl(scope: FavoritesScope): string {
return scope === "all" ? "/library/favorites" : `/library/favorites?scope=${scope}`;
+30 -13
View File
@@ -9,7 +9,9 @@
import { currentMedia } from "$lib/stores/player";
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
import Carousel from "$lib/components/home/Carousel.svelte";
import MediaCard from "$lib/components/library/MediaCard.svelte";
import MosaicGrid from "$lib/components/library/MosaicGrid.svelte";
import MosaicTile from "$lib/components/library/MosaicTile.svelte";
import { assumedLibraryRatio } from "$lib/components/library/libraryMosaic";
import { useScrollRestore } from "$lib/utils/scrollContainer";
import type { MediaItem, Library } from "$lib/api/types";
@@ -113,6 +115,15 @@
$libraries.filter((lib) => lib.collectionType !== "playlists")
);
// The shortcut strip is a mosaic row: one height, each tile as wide as its own
// artwork. It used to force 16:9 on everything so square music covers lined up
// with wide backdrops — which lined them up by cropping the covers.
// TRACES: UR-075 | DR-163
const LIBRARY_STRIP_HEIGHT = 132;
const libraryTiles = $derived(
shortcutLibraries.map((lib) => ({ key: lib.id, ratio: assumedLibraryRatio(lib), library: lib }))
);
function handleLibraryClick(lib: Library) {
// Mirror /library routing: dedicated landing pages need currentLibrary set.
library.setCurrentLibrary(lib);
@@ -166,19 +177,25 @@
{#if shortcutLibraries.length > 0}
<div>
<h2 class="text-xl font-bold text-white mb-4 px-4">Your Libraries</h2>
<div class="flex gap-4 overflow-x-auto px-4 pb-2 items-start">
{#each shortcutLibraries as lib (lib.id)}
<div class="flex-shrink-0">
<!-- Uniform 16:9 artwork so music (square) and video libraries
line up at the same height in this mixed row. -->
<MediaCard
item={lib}
size="medium"
aspect="video"
onclick={() => handleLibraryClick(lib)}
<div class="px-4">
<MosaicGrid
items={libraryTiles}
layout="strip"
targetHeight={LIBRARY_STRIP_HEIGHT}
gap={12}
>
{#snippet tile(entry)}
<MosaicTile
label={entry.library.name}
width={entry.width}
height={entry.height}
itemId={entry.library.id}
imageTag={entry.library.imageTag}
onRatio={entry.reportRatio}
onclick={() => handleLibraryClick(entry.library)}
/>
</div>
{/each}
{/snippet}
</MosaicGrid>
</div>
</div>
{/if}
+38 -28
View File
@@ -6,8 +6,10 @@
import { isServerReachable } from "$lib/stores/connectivity";
import type { useScrollGuard } from "$lib/composables/useScrollGuard";
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
import MediaCard from "$lib/components/library/MediaCard.svelte";
import GenreFilter from "$lib/components/library/GenreFilter.svelte";
import MosaicGrid from "$lib/components/library/MosaicGrid.svelte";
import MosaicTile from "$lib/components/library/MosaicTile.svelte";
import { buildLibraryMosaic } from "$lib/components/library/libraryMosaic";
// Scroll guard from layout - prevents accidental taps during scrolling (Android)
const scrollGuard = getContext<ReturnType<typeof useScrollGuard>>("scrollGuard");
@@ -40,6 +42,12 @@
return $libraries.filter(lib => lib.collectionType !== "playlists");
});
// The overview is a mosaic: rows of one height, tiles of their own widths, so
// a square music cover sits beside a wide backdrop without either being
// cropped to the other's shape. Each category also gets a favourites tile of
// its own, beside the library it belongs to. TRACES: UR-075 | DR-163, DR-164
const mosaicEntries = $derived(buildLibraryMosaic(visibleLibraries));
// Track if we've done an initial load and previous server state
let hasLoadedOnce = false;
let previousServerReachable = false;
@@ -242,22 +250,23 @@
<p>No libraries found</p>
</div>
{:else}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
<!-- Favourites as a destination in its own right, not just the icon in
the header above. It cuts across every library, so it leads the
grid rather than sitting inside one — and a labelled tile at the
same weight as a library is the difference between a feature
<!-- Favourites are destinations in their own right, not just the icon in
the header above. The cross-library entry leads the mosaic and each
category's own favourites sits beside its library — a labelled tile
at the same weight as a library is the difference between a feature
people find and one they don't. ux-flows §5C.2.
TRACES: UR-067 | DR-117 -->
<button
onclick={() => goto('/library/favorites')}
class="group/card flex flex-col text-left transition-transform duration-200 hover:scale-105"
>
<div
class="relative aspect-video w-full overflow-hidden rounded-lg shadow-md
flex items-center justify-center
bg-gradient-to-br from-[var(--color-jellyfin)]/30 to-[var(--color-jellyfin)]/5"
TRACES: UR-067, UR-075 | DR-117, DR-163 -->
<MosaicGrid items={mosaicEntries} gap={8}>
{#snippet tile(entry)}
{#if entry.kind === "favorites"}
<MosaicTile
label={entry.label}
width={entry.width}
height={entry.height}
accent
onclick={() => goto(entry.href)}
>
{#snippet icon()}
<svg
class="w-10 h-10 text-[var(--color-jellyfin)]"
fill="currentColor"
@@ -266,20 +275,21 @@
>
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
</div>
<p class="mt-2 truncate text-sm font-medium text-white group-hover/card:text-[var(--color-jellyfin)] transition-colors">
Favourites
</p>
</button>
{#each visibleLibraries as lib (lib.id)}
<MediaCard
item={lib}
size="medium"
onclick={() => handleLibraryClick(lib)}
{/snippet}
</MosaicTile>
{:else}
<MosaicTile
label={entry.label}
width={entry.width}
height={entry.height}
itemId={entry.library.id}
imageTag={entry.library.imageTag}
onRatio={entry.reportRatio}
onclick={() => handleLibraryClick(entry.library)}
/>
{/each}
</div>
{/if}
{/snippet}
</MosaicGrid>
{/if}
</div>
{/if}