Merge pull request 'Add an alphabet index to the library page' (#15) from feature/library-alphabet-index into master
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 6s

This commit is contained in:
kyuno 2026-09-16 21:24:32 +02:00
commit b34e1ce8ff
5 changed files with 450 additions and 39 deletions

View file

@ -369,6 +369,9 @@ Then put it behind your existing reverse proxy like everything else
docs/architecture.md docs/architecture.md
- [x] Library page title now stays pinned alongside the search bar while - [x] Library page title now stays pinned alongside the search bar while
scrolling, instead of just the search bar — see docs/architecture.md scrolling, instead of just the search bar — see docs/architecture.md
- [x] Library alphabet index: sticky per-letter section headings plus a
right-edge A-Z rail to jump straight to one, drag-to-scrub like
iOS's own Contacts — see docs/architecture.md
- [ ] Try a full party end-to-end with two real accounts (still the main gap) - [ ] Try a full party end-to-end with two real accounts (still the main gap)
- [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is - [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
confirmed here — no real device/session) confirmed here — no real device/session)

77
client/src/alphaIndex.css Normal file
View file

@ -0,0 +1,77 @@
/* ---- Alphabet index: sticky letter headings inside .grid + the
right-edge A-Z rail that jumps to one - see alphaIndex.ts. ---- */
/* Full-width row inside tiles.css's own .grid (still just
`repeat(auto-fill, minmax(...))` - unchanged), interspersed with
normal tile children rather than a separate nested grid per letter. */
.letterHeading {
grid-column: 1 / -1;
position: sticky;
top: var(--section-sticky-top, 0px);
z-index: 2;
background: var(--bg);
color: var(--accent-bright);
font-size: 13px;
font-weight: 700;
padding: 6px 2px;
scroll-margin-top: var(--section-sticky-top, 0px);
}
.alphaRail {
position: fixed;
right: 2px;
z-index: 4;
width: 20px;
display: flex;
flex-direction: column;
justify-content: space-evenly;
align-items: center;
padding: 6px 0;
/* This app's own pointer handlers (alphaIndex.ts) drive the drag -
without this, the browser reads a vertical drag on a touch device
as its own gesture (scroll/pull-to-refresh) instead, the same
conflict shell/Shell.css's mobile nav sheet had to solve once
already for its own drag handle. */
touch-action: none;
}
.railLetter {
font-size: 9px;
font-weight: 700;
color: var(--muted);
line-height: 1;
width: 100%;
text-align: center;
user-select: none;
}
.railLetter.active { color: var(--accent-bright); }
.railLetterEmpty { color: rgba(255, 255, 255, .15); }
.alphaBubble {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(.7);
width: 84px;
height: 84px;
border-radius: 50%;
background: rgba(20, 18, 22, .88);
border: 1px solid rgba(255, 255, 255, .12);
display: flex;
align-items: center;
justify-content: center;
font-size: 36px;
font-weight: 700;
color: var(--accent-bright);
opacity: 0;
pointer-events: none;
transition: opacity .12s ease, transform .12s ease;
z-index: 6;
}
.alphaBubble.show { opacity: 1; transform: translate(-50%, -50%) scale(1); }
@media (prefers-reduced-motion: reduce) {
.alphaBubble { transition: none; }
}

206
client/src/alphaIndex.ts Normal file
View file

@ -0,0 +1,206 @@
// Alphabet index for a library grid: sticky per-letter section headings
// inside the grid itself, plus a right-edge A-Z rail to jump straight to
// one - see docs/architecture.md. Library-only for now (that's the only
// page that asked), but nothing below assumes anything Library-specific
// beyond the two elements it measures (.libraryHeader, shell/Shell.html's
// .bottomStack) - reusable elsewhere later without change.
import './alphaIndex.css';
import { tileHtml } from './tiles';
import type { CleanupFn } from './router';
import type { ItemDto } from './types';
const ALL_BUCKETS = ['#', ...Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i))];
/** A name's index bucket: its first letter, accents folded to their base
* (so "Écho" lands on "E", matching how a French speaker would expect
* the index to group it, not off in its own "É"/"#" bucket) - anything
* else (a digit, empty) falls back to "#". */
function bucketFor(name: string): string {
const base = (name || '').trim().normalize('NFD').replace(/[̀-ͯ]/g, '');
const c = base.charAt(0).toUpperCase();
return c >= 'A' && c <= 'Z' ? c : '#';
}
/**
* Same job as tiles.ts's renderTilesInChunks (chunked insertAdjacentHTML,
* not one big innerHTML write - see that function's own comment for why
* that matters on a 1000+-item library), plus a `.letterHeading` inserted
* right before the first tile of each new bucket. `items` is assumed
* already alphabetical (embyApi's getLibraryItems/getArtists both ask
* Emby for SortBy: SortName) - a chunk boundary landing mid-bucket is
* fine, it's still one contiguous run either way.
*/
export function renderGroupedTilesInChunks(container: HTMLElement, items: ItemDto[], chunkSize = 40): void {
container.innerHTML = '';
let i = 0;
let lastBucket: string | null = null;
function renderNextChunk(): void {
const chunk = items.slice(i, i + chunkSize);
if (!chunk.length) return;
let html = '';
for (const item of chunk) {
const bucket = bucketFor(item.name);
if (bucket !== lastBucket) {
html += `<div class="letterHeading" data-letter-anchor="${bucket}">${bucket}</div>`;
lastBucket = bucket;
}
html += tileHtml(item);
}
container.insertAdjacentHTML('beforeend', html);
i += chunkSize;
if (i < items.length) setTimeout(renderNextChunk, 0);
}
renderNextChunk();
}
/**
* Hides a `.letterHeading` whenever every tile in its run is currently
* `.hide`'d by tiles.ts's wireSearchFilter - without this, searching
* down to matches in only one bucket would leave every other bucket's
* heading floating above nothing. Call once, after wireSearchFilter
* itself is wired to the same input (listener order = registration
* order, so this always runs after .hide has already been updated).
*/
export function wireSectionSearchSync(input: HTMLInputElement, grid: HTMLElement): void {
input.addEventListener('input', () => {
grid.querySelectorAll<HTMLElement>('.letterHeading').forEach((heading) => {
let sib = heading.nextElementSibling;
let anyVisible = false;
while (sib && !sib.classList.contains('letterHeading')) {
if (!sib.classList.contains('hide')) { anyVisible = true; break; }
sib = sib.nextElementSibling;
}
heading.classList.toggle('hide', !anyVisible);
});
});
}
/**
* The right-edge A-Z rail: tap or drag it to jump the page scroll to a
* letter's section heading, with a big center-screen bubble showing the
* current letter while doing so (fades out on release) - same
* pointerdown/move/up-on-a-dedicated-element and touch-action: none shape
* as shell/Shell.ts's own nav-sheet grabber handle, so a vertical drag
* here doesn't get read as the browser's pull-to-refresh either. Letters
* with nothing in `items` render dimmed and inert.
*
* The rail and every letterHeading's sticky offset are measured live off
* .libraryHeader and .bottomStack rather than hardcoded: .bottomStack
* (the music bar) can appear/resize while this page stays open - a
* listen party starting - and it floats above the grid on desktop too,
* not just mobile, so both need to stay clear of it regardless of
* breakpoint. `getComputedStyle(libraryHeader).top` picks up whichever
* of Library.css's own two breakpoint values is active without this
* module needing to duplicate that 720px rule itself.
*
* Returns a CleanupFn (see router.ts) - the two ResizeObservers and the
* window-level scroll/resize listeners this sets up would otherwise keep
* running after the user navigates away, the exact leak class
* docs/architecture.md already documents this app getting bitten by
* once before.
*/
export function wireAlphaRail(root: HTMLElement, grid: HTMLElement, items: ItemDto[]): CleanupFn {
const present = new Set(items.map((it) => bucketFor(it.name)));
const libraryHeader = root.querySelector('.libraryHeader') as HTMLElement;
const bottomStack = document.querySelector('.bottomStack') as HTMLElement | null;
const rail = document.createElement('div');
rail.className = 'alphaRail';
rail.innerHTML = ALL_BUCKETS.map((letter) => {
const has = present.has(letter);
return `<span class="railLetter${has ? '' : ' railLetterEmpty'}" data-letter="${letter}" role="button" aria-label="Aller à ${letter}" aria-disabled="${!has}">${letter}</span>`;
}).join('');
root.appendChild(rail);
const bubble = document.createElement('div');
bubble.className = 'alphaBubble';
root.appendChild(bubble);
function recomputeOffsets(): void {
const top = parseFloat(getComputedStyle(libraryHeader).top) || 0;
const stickyBottom = top + libraryHeader.offsetHeight;
grid.style.setProperty('--section-sticky-top', `${stickyBottom}px`);
rail.style.top = `${stickyBottom}px`;
rail.style.bottom = `${bottomStack ? bottomStack.offsetHeight : 0}px`;
}
recomputeOffsets();
const resizeObserver = new ResizeObserver(recomputeOffsets);
resizeObserver.observe(libraryHeader);
if (bottomStack) resizeObserver.observe(bottomStack);
window.addEventListener('resize', recomputeOffsets);
let bubbleFadeTimer: ReturnType<typeof setTimeout> | null = null;
function showBubble(letter: string): void {
bubble.textContent = letter;
bubble.classList.add('show');
if (bubbleFadeTimer) clearTimeout(bubbleFadeTimer);
}
function scheduleHideBubble(): void {
if (bubbleFadeTimer) clearTimeout(bubbleFadeTimer);
bubbleFadeTimer = setTimeout(() => bubble.classList.remove('show'), 260);
}
function setActive(letter: string): void {
rail.querySelectorAll('.railLetter').forEach((el) => {
el.classList.toggle('active', el.getAttribute('data-letter') === letter);
});
}
function letterAt(clientY: number): string {
const rect = rail.getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (clientY - rect.top) / rect.height));
return ALL_BUCKETS[Math.min(ALL_BUCKETS.length - 1, Math.floor(ratio * ALL_BUCKETS.length))] as string;
}
function jumpTo(letter: string): void {
setActive(letter);
showBubble(letter);
const target = grid.querySelector(`[data-letter-anchor="${letter}"]`);
if (target) target.scrollIntoView({ block: 'start' });
}
let dragging = false;
function onPointerDown(e: PointerEvent): void {
dragging = true;
rail.setPointerCapture(e.pointerId);
jumpTo(letterAt(e.clientY));
}
function onPointerMove(e: PointerEvent): void {
if (!dragging) return;
jumpTo(letterAt(e.clientY));
}
function endDrag(): void {
if (!dragging) return;
dragging = false;
scheduleHideBubble();
}
rail.addEventListener('pointerdown', onPointerDown);
rail.addEventListener('pointermove', onPointerMove);
rail.addEventListener('pointerup', endDrag);
rail.addEventListener('pointercancel', endDrag);
let scrollSpyScheduled = false;
function onWindowScroll(): void {
if (dragging || scrollSpyScheduled) return;
scrollSpyScheduled = true;
requestAnimationFrame(() => {
scrollSpyScheduled = false;
const stickyBottom = parseFloat(grid.style.getPropertyValue('--section-sticky-top')) || 0;
let current = ALL_BUCKETS[0] as string;
grid.querySelectorAll<HTMLElement>('[data-letter-anchor]').forEach((el) => {
if (el.getBoundingClientRect().top - stickyBottom <= 4) current = el.getAttribute('data-letter-anchor') as string;
});
setActive(current);
});
}
window.addEventListener('scroll', onWindowScroll, { passive: true });
setActive(ALL_BUCKETS[0] as string);
return () => {
resizeObserver.disconnect();
window.removeEventListener('resize', recomputeOffsets);
window.removeEventListener('scroll', onWindowScroll);
if (bubbleFadeTimer) clearTimeout(bubbleFadeTimer);
};
}

View file

@ -3,8 +3,9 @@ import html from './Library.html';
import { getArtists, getLibraries, getLibraryItems } from '../../api/embyApi'; import { getArtists, getLibraries, getLibraryItems } from '../../api/embyApi';
import { createParty } from '../../api/party'; import { createParty } from '../../api/party';
import { navigate } from '../../router'; import { navigate } from '../../router';
import type { RouteParams } from '../../router'; import type { CleanupFn, RouteParams } from '../../router';
import { renderTilesInChunks, wireSearchFilter, wireTileButtons } from '../../tiles'; import { renderGroupedTilesInChunks, wireAlphaRail, wireSectionSearchSync } from '../../alphaIndex';
import { wireSearchFilter, wireTileButtons } from '../../tiles';
import { LOADING_HINT } from '../../loading'; import { LOADING_HINT } from '../../loading';
/** /**
@ -18,48 +19,73 @@ import { LOADING_HINT } from '../../loading';
* endpoint, not a generic library listing - see * endpoint, not a generic library listing - see
* GET /api/catalog/libraries/:id/artists) - Album -> Tracks is one * GET /api/catalog/libraries/:id/artists) - Album -> Tracks is one
* level further in (views/artist, then views/album). * level further in (views/artist, then views/album).
*
* Synchronous wrapper around an async IIFE, not `async function`
* directly - router.ts's cleanup contract needs a CleanupFn back
* *synchronously*, which an async function returning a Promise can't
* do (see router.ts's own comment, and views/player/Player.ts's
* renderPartyPlayer for the same shape). `cancelled` guards every
* resume-after-await point so a still-in-flight fetch from a library
* the user already navigated away from can't clobber whatever route
* rendered after it.
*/ */
export async function renderLibrary(root: HTMLElement, params: RouteParams): Promise<void> { export function renderLibrary(root: HTMLElement, params: RouteParams): CleanupFn {
root.innerHTML = LOADING_HINT; root.innerHTML = LOADING_HINT;
let cancelled = false;
let cleanup: CleanupFn = () => {};
let libraries; (async () => {
try { let libraries;
libraries = await getLibraries(); try {
} catch { libraries = await getLibraries();
root.innerHTML = '<p class="error">Impossible de charger les bibliothèques.</p>'; } catch {
return; if (!cancelled) root.innerHTML = '<p class="error">Impossible de charger les bibliothèques.</p>';
} return;
}
if (cancelled) return;
const library = libraries.find((l) => l.id === params.id); const library = libraries.find((l) => l.id === params.id);
if (!library) { if (!library) {
root.innerHTML = '<p class="error">Bibliothèque introuvable.</p>'; root.innerHTML = '<p class="error">Bibliothèque introuvable.</p>';
return; return;
} }
root.innerHTML = html; root.innerHTML = html;
root.querySelector('h1')!.textContent = library.name; root.querySelector('h1')!.textContent = library.name;
const grid = root.querySelector('#grid') as HTMLElement; const grid = root.querySelector('#grid') as HTMLElement;
let items; let items;
try { try {
items = library.collectionType === 'music' items = library.collectionType === 'music'
? await getArtists(library.id) ? await getArtists(library.id)
: await getLibraryItems(library.id); : await getLibraryItems(library.id);
} catch { } catch {
grid.innerHTML = '<p class="error">Impossible de charger cette bibliothèque.</p>'; if (!cancelled) {
root.querySelector('#searchInput')!.remove(); grid.innerHTML = '<p class="error">Impossible de charger cette bibliothèque.</p>';
return; root.querySelector('#searchInput')!.remove();
} }
if (!items.length) { return;
grid.innerHTML = '<p class="hint">Bibliothèque vide.</p>'; }
root.querySelector('#searchInput')!.remove(); if (cancelled) return;
return; if (!items.length) {
} grid.innerHTML = '<p class="hint">Bibliothèque vide.</p>';
root.querySelector('#searchInput')!.remove();
return;
}
renderTilesInChunks(grid, items); renderGroupedTilesInChunks(grid, items);
wireTileButtons(grid, async (itemId) => { wireTileButtons(grid, async (itemId) => {
const party = await createParty(itemId); const party = await createParty(itemId);
navigate(`/party/${party.partyId}`); navigate(`/party/${party.partyId}`);
}); });
wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.tile'); const searchInput = root.querySelector('#searchInput') as HTMLInputElement;
wireSearchFilter(searchInput, root, '.tile');
wireSectionSearchSync(searchInput, grid);
cleanup = wireAlphaRail(root, grid, items);
})();
return () => {
cancelled = true;
cleanup();
};
} }

View file

@ -3963,6 +3963,105 @@ scrolling underneath, opaque background confirmed; on the 375px frame
`.libraryHeader`'s top edge landed exactly at `.mobileTopBar`'s bottom `.libraryHeader`'s top edge landed exactly at `.mobileTopBar`'s bottom
edge (49px) with zero overlap, both bars' text still visible together. edge (49px) with zero overlap, both bars' text still visible together.
## Alphabet index: jump straight to a letter in a library
Asked for directly, alongside the sticky header above: a way to jump to
a letter instead of scrolling a long library by hand. Proposed two
mockups first (a right-edge A-Z rail either bare or paired with sticky
per-letter section headings, both with the drag-to-scrub-plus-big-letter-
bubble feel of iOS's own Contacts index) - the section-headings version
was picked.
New `alphaIndex.ts`, deliberately not folded into `tiles.ts`: it's a
`views/library`-only concern (the ask was specifically "dans les
bibliothèques"), and `tiles.ts` stays exactly what every other page
still wants - one flat, ungrouped grid.
**Grouping** buckets each item by its first letter, accents folded to
their base (`normalize('NFD')` + stripping the combining marks) so
"Écho" lands under "E" like a French speaker would expect, not off in
its own accent-specific bucket. No client-side re-sort needed -
`embyApi`'s `getLibraryItems`/`getArtists` both already ask Emby for
`SortBy: SortName`, so a run of same-bucket items is already contiguous.
**Layout**: a `.letterHeading` (`grid-column: 1 / -1`) inserted before
each new bucket, directly inside the *same* `.grid` tiles.ts's own tiles
already populate - not a separate mini-grid per letter, which would have
needed its own column-count logic duplicated 20+ times over. Its `top`
(both for its own `position: sticky` and as every jump target's
`scroll-margin-top`, so a jump doesn't land a heading half-hidden behind
the still-stuck header above it) is measured off `.libraryHeader` at
runtime - `getComputedStyle(libraryHeader).top` picks up whichever of
Library.css's two breakpoint values (`0` desktop, `--mobile-topbar-h`
mobile - see the sticky-header section above) is currently active
without this module needing to know that breakpoint exists at all.
`renderGroupedTilesInChunks` mirrors `renderTilesInChunks`'s own chunked
`insertAdjacentHTML` (see that function's comment - a real library can
run past a thousand items, and one synchronous `innerHTML` write for all
of them is exactly the "front freezes while it loads" failure this app
already fixed once elsewhere); a chunk boundary landing mid-bucket is
harmless, headings and tiles just interleave in whatever order they're
inserted.
**The rail** (`.alphaRail`, fixed to the right edge) is 27 buckets
(`#` + A-Z), each dimmed and inert if `items` has nothing in it. Tap or
drag it - `pointerdown`/`move`/`up` on one dedicated element with
`touch-action: none` and `setPointerCapture`, the identical shape
`shell/Shell.ts`'s own nav-sheet drag handle already uses - and a
center-screen bubble shows the current letter while dragging, the same
"big transient label during a drag" idea as that handle's own gesture,
applied here to a very different one. Scrolling normally (not
dragging) instead runs a small scroll-spy on `window`'s scroll event,
highlighting whichever bucket's heading is currently at the top.
Both the rail and every heading's sticky offset track two elements'
*real* rendered size rather than a guessed constant - a `ResizeObserver`
on `.libraryHeader` (a very long title could wrap to two lines) and on
`shell/Shell.html`'s `.bottomStack` (the music bar floats there whenever
a listen party is active, on desktop as well as mobile, and can
start/stop while this page stays open) - so the rail's own `bottom`
always stops exactly above whichever of those is currently showing,
instead of a listen party's music bar quietly covering "X"-"Z".
**Search** still uses tiles.ts's existing `wireSearchFilter` unchanged
(`.tile`-level show/hide) - `wireSectionSearchSync` just walks each
`.letterHeading`'s following siblings up to the next one and hides it
too if every tile in that run is now `.hide`'d, registered on the same
input *after* `wireSearchFilter` so it always sees the up-to-date
`.hide` state (same-element listener order = registration order).
Without it, searching down to matches in one bucket would leave every
other bucket's heading floating above nothing.
**Cleanup**: `renderLibrary` itself had to change shape - from a plain
`async function` to the synchronous-wrapper-around-an-async-IIFE
`views/player/Player.ts`'s `renderPartyPlayer` already uses, returning
a real `CleanupFn` (see router.ts) instead of the `Promise<void>` an
`async function` can't help but return. Both the rail's two
`ResizeObserver`s and its `window` scroll/resize listeners live past
whatever DOM `router.ts`'s next `innerHTML = ''` clears, so without an
explicit teardown they'd keep running (and, for the scroll listener,
keep doing real work) after leaving the page - the exact leak class
"Realtime transport" up above already covers this app getting bitten by
once.
Verified: full `typecheck`/`build` (client only). No Emby session
available here, so the interaction itself was checked by bundling
`alphaIndex.ts` alone (a throwaway esbuild entry point, deleted after)
into a synthetic page reproducing the real Shell/Library DOM and real
compiled CSS, then driven for real rather than assumed: a genuine
browser click on a rail letter (not a script-dispatched one - dispatched
`PointerEvent`s never register as an active pointer, so `setPointerCapture`
itself only ever proved out under real input) jumped and highlighted
correctly with zero console errors; dragging across several letters
updated the active one and the bubble live at each step; tapping an
empty letter highlighted it but left scroll position untouched; typing
into the search box hid exactly the buckets left with no visible tiles
and none of the others; growing `#musicBar`'s height live moved the
rail's bottom edge with it and back; and calling the returned cleanup
stopped that last reaction cold, confirming the observer was actually
disconnected rather than just idle.
## Known limitations ## Known limitations
- Playback always goes through `hls.js` against whatever Emby's - Playback always goes through `hls.js` against whatever Emby's