watchparty/client/src/alphaIndex.ts
Nicolas 6b6a57a5f7
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Fix the alphabet index bucketing by display name instead of Emby SortName
Emby orders (and this app already asked it to) by SortName, which
strips leading articles - an item like The Bunker sorts among the Bs
there, but the index was bucketing by the raw display name, throwing a
spurious T heading into the middle of that run. getLibraryItems/
getArtists now also fetch SortName, ItemDto carries it through, and the
index buckets by that instead - the same key Emby actually used to
order the array, not a guess that usually matches it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 21:36:37 +02:00

218 lines
9.2 KiB
TypeScript

// 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))];
/**
* An item's index bucket: the first letter of its `sortName` - NOT
* `name` - with 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 "#".
*
* `sortName`, deliberately, not the display name: `items` arrives in
* Emby's own SortName order (getLibraryItems/getArtists both request
* `SortBy: SortName`), and that order strips leading articles ("Le
* Voyage" sorts as "Voyage") - bucketing by the raw display name instead
* put a "L" heading in the middle of a "V" run the one time an article
* did that, reported directly as headers in no sensible order at all.
* Bucketing by the exact same key Emby used to order the array is what
* guarantees a bucket is always one contiguous run, not a guess that it
* happens to line up.
*/
function bucketFor(item: ItemDto): string {
const base = (item.sortName || item.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 ordered by `sortName` (see bucketFor) - 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);
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)));
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);
};
}