From 0bd95b03b93abcff0732f14d8d1768ce3f5da616 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 16 Sep 2026 20:31:03 +0200 Subject: [PATCH] Add an alphabet index to the library page: sticky letter headings + a right-edge A-Z rail Groups an already-alphabetical library into per-letter sections (accents folded, so an item is bucketed under its base letter) with a sticky heading before each - still one chunked-rendered grid, not a big synchronous write, so a 1000+-item library stays smooth. The right-edge rail jumps to a letter on tap or drag (pointer-capture, same shape as the mobile nav sheet's own drag handle), dims letters with nothing in them, and tracks the header/music-bar's real height instead of a guessed offset so it never sits under either. Stacked on feature/library-sticky-header (Library.css's .libraryHeader, not yet merged) rather than master, per CLAUDE.md's sequential-PRs rule. Co-Authored-By: Claude Sonnet 5 --- README.md | 3 + client/src/alphaIndex.css | 77 +++++++++++ client/src/alphaIndex.ts | 206 ++++++++++++++++++++++++++++ client/src/views/library/Library.ts | 104 ++++++++------ docs/architecture.md | 99 +++++++++++++ 5 files changed, 450 insertions(+), 39 deletions(-) create mode 100644 client/src/alphaIndex.css create mode 100644 client/src/alphaIndex.ts diff --git a/README.md b/README.md index b9eceeb..30cdf93 100644 --- a/README.md +++ b/README.md @@ -369,6 +369,9 @@ Then put it behind your existing reverse proxy like everything else docs/architecture.md - [x] Library page title now stays pinned alongside the search bar while 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 casting to a real Chromecast (only the SDK's own bootstrap is confirmed here — no real device/session) diff --git a/client/src/alphaIndex.css b/client/src/alphaIndex.css new file mode 100644 index 0000000..bfae618 --- /dev/null +++ b/client/src/alphaIndex.css @@ -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; } +} diff --git a/client/src/alphaIndex.ts b/client/src/alphaIndex.ts new file mode 100644 index 0000000..0c7174a --- /dev/null +++ b/client/src/alphaIndex.ts @@ -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 += `
${bucket}
`; + 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('.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 `${letter}`; + }).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 | 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('[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); + }; +} diff --git a/client/src/views/library/Library.ts b/client/src/views/library/Library.ts index 70a64d2..19918e7 100644 --- a/client/src/views/library/Library.ts +++ b/client/src/views/library/Library.ts @@ -3,8 +3,9 @@ import html from './Library.html'; import { getArtists, getLibraries, getLibraryItems } from '../../api/embyApi'; import { createParty } from '../../api/party'; import { navigate } from '../../router'; -import type { RouteParams } from '../../router'; -import { renderTilesInChunks, wireSearchFilter, wireTileButtons } from '../../tiles'; +import type { CleanupFn, RouteParams } from '../../router'; +import { renderGroupedTilesInChunks, wireAlphaRail, wireSectionSearchSync } from '../../alphaIndex'; +import { wireSearchFilter, wireTileButtons } from '../../tiles'; import { LOADING_HINT } from '../../loading'; /** @@ -18,48 +19,73 @@ import { LOADING_HINT } from '../../loading'; * endpoint, not a generic library listing - see * GET /api/catalog/libraries/:id/artists) - Album -> Tracks is one * 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 { +export function renderLibrary(root: HTMLElement, params: RouteParams): CleanupFn { root.innerHTML = LOADING_HINT; + let cancelled = false; + let cleanup: CleanupFn = () => {}; - let libraries; - try { - libraries = await getLibraries(); - } catch { - root.innerHTML = '

Impossible de charger les bibliothèques.

'; - return; - } + (async () => { + let libraries; + try { + libraries = await getLibraries(); + } catch { + if (!cancelled) root.innerHTML = '

Impossible de charger les bibliothèques.

'; + return; + } + if (cancelled) return; - const library = libraries.find((l) => l.id === params.id); - if (!library) { - root.innerHTML = '

Bibliothèque introuvable.

'; - return; - } + const library = libraries.find((l) => l.id === params.id); + if (!library) { + root.innerHTML = '

Bibliothèque introuvable.

'; + return; + } - root.innerHTML = html; - root.querySelector('h1')!.textContent = library.name; + root.innerHTML = html; + root.querySelector('h1')!.textContent = library.name; - const grid = root.querySelector('#grid') as HTMLElement; - let items; - try { - items = library.collectionType === 'music' - ? await getArtists(library.id) - : await getLibraryItems(library.id); - } catch { - grid.innerHTML = '

Impossible de charger cette bibliothèque.

'; - root.querySelector('#searchInput')!.remove(); - return; - } - if (!items.length) { - grid.innerHTML = '

Bibliothèque vide.

'; - root.querySelector('#searchInput')!.remove(); - return; - } + const grid = root.querySelector('#grid') as HTMLElement; + let items; + try { + items = library.collectionType === 'music' + ? await getArtists(library.id) + : await getLibraryItems(library.id); + } catch { + if (!cancelled) { + grid.innerHTML = '

Impossible de charger cette bibliothèque.

'; + root.querySelector('#searchInput')!.remove(); + } + return; + } + if (cancelled) return; + if (!items.length) { + grid.innerHTML = '

Bibliothèque vide.

'; + root.querySelector('#searchInput')!.remove(); + return; + } - renderTilesInChunks(grid, items); - wireTileButtons(grid, async (itemId) => { - const party = await createParty(itemId); - navigate(`/party/${party.partyId}`); - }); - wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.tile'); + renderGroupedTilesInChunks(grid, items); + wireTileButtons(grid, async (itemId) => { + const party = await createParty(itemId); + navigate(`/party/${party.partyId}`); + }); + const searchInput = root.querySelector('#searchInput') as HTMLInputElement; + wireSearchFilter(searchInput, root, '.tile'); + wireSectionSearchSync(searchInput, grid); + cleanup = wireAlphaRail(root, grid, items); + })(); + + return () => { + cancelled = true; + cleanup(); + }; } diff --git a/docs/architecture.md b/docs/architecture.md index f7ef0ca..f7431d0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3963,6 +3963,105 @@ scrolling underneath, opaque background confirmed; on the 375px frame `.libraryHeader`'s top edge landed exactly at `.mobileTopBar`'s bottom 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` 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 - Playback always goes through `hls.js` against whatever Emby's -- 2.45.2