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>
218 lines
9.2 KiB
TypeScript
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);
|
|
};
|
|
}
|