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 += `
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