Merge pull request 'Add an alphabet index to the library page' (#15) from feature/library-alphabet-index into master
This commit is contained in:
commit
b34e1ce8ff
5 changed files with 450 additions and 39 deletions
|
|
@ -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)
|
||||
|
|
|
|||
77
client/src/alphaIndex.css
Normal file
77
client/src/alphaIndex.css
Normal 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
206
client/src/alphaIndex.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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 = '<p class="error">Impossible de charger les bibliothèques.</p>';
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
let libraries;
|
||||
try {
|
||||
libraries = await getLibraries();
|
||||
} catch {
|
||||
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);
|
||||
if (!library) {
|
||||
root.innerHTML = '<p class="error">Bibliothèque introuvable.</p>';
|
||||
return;
|
||||
}
|
||||
const library = libraries.find((l) => l.id === params.id);
|
||||
if (!library) {
|
||||
root.innerHTML = '<p class="error">Bibliothèque introuvable.</p>';
|
||||
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 = '<p class="error">Impossible de charger cette bibliothèque.</p>';
|
||||
root.querySelector('#searchInput')!.remove();
|
||||
return;
|
||||
}
|
||||
if (!items.length) {
|
||||
grid.innerHTML = '<p class="hint">Bibliothèque vide.</p>';
|
||||
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 = '<p class="error">Impossible de charger cette bibliothèque.</p>';
|
||||
root.querySelector('#searchInput')!.remove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cancelled) return;
|
||||
if (!items.length) {
|
||||
grid.innerHTML = '<p class="hint">Bibliothèque vide.</p>';
|
||||
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();
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
|
||||
- Playback always goes through `hls.js` against whatever Emby's
|
||||
|
|
|
|||
Loading…
Reference in a new issue