Add a spinner to every loading state
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 7s

Every "Chargement..." spot across the app was plain text with no
visual motion - easy to mistake a still-fetching page for a stuck one.

New .spinner in styles/base.css - a plain CSS ring (border +
border-top-color, no image/icon-font asset), sized and vertically
aligned to drop into an existing text node inline instead of needing
a flex-row restructure. prefers-reduced-motion turns the spin off.

New client/src/loading.ts exports one shared LOADING_HINT constant so
every dynamic call site (Album/Artist/Library/Playlist/Series's own
route entry, Home's per-library carousels, a season switch reloading
its episode list, the player's route-level load) renders identical
markup. Shell.html's sidebar library list and Library.html's #grid
placeholder (both static templates, no JS import) got the same
markup pasted directly. Player.html's #loadingNotice (video
buffering) got the spinner too.

Left alone: a couple of static templates' own inert copies of this
same placeholder text (Playlist.html, Series.html) that are always
synchronously overwritten before ever painting - nothing a viewer
could ever actually see differently. Also left alone: the brief
button-label loading states elsewhere ("Ajout...", "Démarrage...") -
a different, smaller UI element, typically resolved within one REST
round trip.

Verified: full typecheck/build on the client (no server changes at
all). Visually confirmed in a real browser that the spinner renders
correctly inline with text and is genuinely animating.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-14 23:30:25 +02:00
parent 9b3e051385
commit 939beb6fa9
13 changed files with 104 additions and 11 deletions

17
client/src/loading.ts Normal file
View file

@ -0,0 +1,17 @@
// Shared "Chargement..." placeholder - spinner + text, see .spinner in
// styles/base.css for the actual CSS ring (no image/icon-font asset, just
// a bordered circle animated with a plain @keyframes rotation). One
// constant so every route/section that fetches before it has anything
// real to show (Album/Artist/Library/Playlist/Series's own route entry,
// Home's per-library carousels) renders the exact same markup instead of
// each hand-typing a slightly different one - a plain-text .hide/.hint
// mismatch is exactly the kind of drift a shared constant avoids.
//
// The sidebar's own library list (Shell.html) and Player.ts's video-
// buffering notice (Player.html's #loadingNotice) don't use this: the
// sidebar's markup is a static template with no JS import of its own
// (see Shell.html directly - same spinner markup, just pasted once by
// hand instead of imported), and the video notice is already a distinct,
// separately-styled centered overlay with its own text ("Chargement du
// flux..."), not a plain in-flow <p class="hint">.
export const LOADING_HINT = '<p class="hint"><span class="spinner"></span>Chargement...</p>';

View file

@ -4,7 +4,7 @@
<nav class="sidebarNav" id="sidebarNav"> <nav class="sidebarNav" id="sidebarNav">
<a href="#/home" class="navItem">Accueil</a> <a href="#/home" class="navItem">Accueil</a>
<div class="navSectionLabel">Bibliothèques</div> <div class="navSectionLabel">Bibliothèques</div>
<p class="hint" id="libStatus">Chargement...</p> <p class="hint" id="libStatus"><span class="spinner"></span>Chargement...</p>
</nav> </nav>
<button class="linklike sidebarLogout" id="logout"></button> <button class="linklike sidebarLogout" id="logout"></button>
</aside> </aside>

View file

@ -51,6 +51,33 @@ h2 { font-size: 16px; margin: 0 0 12px; }
.hint { color: var(--muted); font-size: 13px; margin: 0 0 16px; } .hint { color: var(--muted); font-size: 13px; margin: 0 0 16px; }
.error { color: var(--danger); font-size: 13px; min-height: 18px; } .error { color: var(--danger); font-size: 13px; min-height: 18px; }
/* Plain CSS ring, not an image/icon-font asset - `display: inline-block`
+ `vertical-align` so it drops into an existing text node (a .hint's
"Chargement...", Player.css's own .loadingNotice) without needing to
restructure the element into a flex row - see loading.ts's LOADING_HINT
for the one shared constant most call sites use instead of hand-typing
this markup. `prefers-reduced-motion` gets a static ring instead of an
endless spin, same reasoning as this app avoiding motion elsewhere. */
.spinner {
display: inline-block;
vertical-align: -3px;
width: 14px;
height: 14px;
margin-right: 8px;
border: 2px solid rgba(255, 255, 255, .25);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.spinner { animation: none; }
}
label { label {
display: block; display: block;
font-size: 13px; font-size: 13px;

View file

@ -5,6 +5,7 @@ import { getAlbumTracks, getItem } from '../../api/embyApi';
import { addTrackToQueue, createParty } from '../../api/party'; import { addTrackToQueue, createParty } from '../../api/party';
import { getActiveMusicPartyId, joinMusicParty } from '../../musicBar/MusicBar'; import { getActiveMusicPartyId, joinMusicParty } from '../../musicBar/MusicBar';
import { escapeHtml, formatDuration, wireSearchFilter } from '../../tiles'; import { escapeHtml, formatDuration, wireSearchFilter } from '../../tiles';
import { LOADING_HINT } from '../../loading';
import type { ItemDto, MusicTrackInput } from '../../types'; import type { ItemDto, MusicTrackInput } from '../../types';
import type { RouteParams } from '../../router'; import type { RouteParams } from '../../router';
@ -16,7 +17,7 @@ const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same
* series' seasons do). */ * series' seasons do). */
export async function renderAlbum(root: HTMLElement, params: RouteParams): Promise<void> { export async function renderAlbum(root: HTMLElement, params: RouteParams): Promise<void> {
const albumId = params.id as string; const albumId = params.id as string;
root.innerHTML = '<p class="hint">Chargement...</p>'; root.innerHTML = LOADING_HINT;
let album: ItemDto; let album: ItemDto;
let tracks: ItemDto[]; let tracks: ItemDto[];

View file

@ -2,6 +2,7 @@ import html from './Artist.html';
import { getArtistAlbums, getItem } from '../../api/embyApi'; import { getArtistAlbums, getItem } from '../../api/embyApi';
import type { RouteParams } from '../../router'; import type { RouteParams } from '../../router';
import { tileHtml, wireSearchFilter } from '../../tiles'; import { tileHtml, wireSearchFilter } from '../../tiles';
import { LOADING_HINT } from '../../loading';
/** Route `/artist/:id` - one artist's albums, reached from a music /** Route `/artist/:id` - one artist's albums, reached from a music
* library's top-level grid (see views/library) via a MusicArtist tile's * library's top-level grid (see views/library) via a MusicArtist tile's
@ -12,7 +13,7 @@ import { tileHtml, wireSearchFilter } from '../../tiles';
* artist has nothing itself to start a party from). */ * artist has nothing itself to start a party from). */
export async function renderArtist(root: HTMLElement, params: RouteParams): Promise<void> { export async function renderArtist(root: HTMLElement, params: RouteParams): Promise<void> {
const artistId = params.id as string; const artistId = params.id as string;
root.innerHTML = '<p class="hint">Chargement...</p>'; root.innerHTML = LOADING_HINT;
let artist; let artist;
let albums; let albums;

View file

@ -5,6 +5,7 @@ import { connectLobby, createParty, joinParty } from '../../api/party';
import { getActiveMusicPartyId } from '../../musicBar/MusicBar'; import { getActiveMusicPartyId } from '../../musicBar/MusicBar';
import { navigate } from '../../router'; import { navigate } from '../../router';
import { escapeHtml, tileHtml, wireTileButtons } from '../../tiles'; import { escapeHtml, tileHtml, wireTileButtons } from '../../tiles';
import { LOADING_HINT } from '../../loading';
import type { LobbyPartyDto } from '../../types'; import type { LobbyPartyDto } from '../../types';
export function renderHome(root: HTMLElement): () => void { export function renderHome(root: HTMLElement): () => void {
@ -148,7 +149,7 @@ async function loadRecentSections(root: HTMLElement): Promise<void> {
for (const lib of libraries) { for (const lib of libraries) {
const section = document.createElement('section'); const section = document.createElement('section');
section.className = 'section'; section.className = 'section';
section.innerHTML = `<h2>Ajouts récents - ${escapeHtml(lib.name)}</h2><div class="carousel"><p class="hint">Chargement...</p></div>`; section.innerHTML = `<h2>Ajouts récents - ${escapeHtml(lib.name)}</h2><div class="carousel">${LOADING_HINT}</div>`;
container.appendChild(section); container.appendChild(section);
getRecentItems(lib.id) getRecentItems(lib.id)

View file

@ -1,4 +1,4 @@
<div class="pageHeader"><h1></h1></div> <div class="pageHeader"><h1></h1></div>
<input id="searchInput" class="searchBar" type="search" placeholder="Rechercher dans cette bibliothèque..." /> <input id="searchInput" class="searchBar" type="search" placeholder="Rechercher dans cette bibliothèque..." />
<p class="hint searchEmpty hide">Aucun résultat.</p> <p class="hint searchEmpty hide">Aucun résultat.</p>
<div id="grid" class="grid"><p class="hint">Chargement...</p></div> <div id="grid" class="grid"><p class="hint"><span class="spinner"></span>Chargement...</p></div>

View file

@ -4,6 +4,7 @@ import { createParty } from '../../api/party';
import { navigate } from '../../router'; import { navigate } from '../../router';
import type { RouteParams } from '../../router'; import type { RouteParams } from '../../router';
import { tileHtml, wireSearchFilter, wireTileButtons } from '../../tiles'; import { tileHtml, wireSearchFilter, wireTileButtons } from '../../tiles';
import { LOADING_HINT } from '../../loading';
/** /**
* Generic "one library" page, reached from the sidebar. What it renders * Generic "one library" page, reached from the sidebar. What it renders
@ -18,7 +19,7 @@ import { tileHtml, wireSearchFilter, wireTileButtons } from '../../tiles';
* level further in (views/artist, then views/album). * level further in (views/artist, then views/album).
*/ */
export async function renderLibrary(root: HTMLElement, params: RouteParams): Promise<void> { export async function renderLibrary(root: HTMLElement, params: RouteParams): Promise<void> {
root.innerHTML = '<p class="hint">Chargement...</p>'; root.innerHTML = LOADING_HINT;
let libraries; let libraries;
try { try {

View file

@ -1,7 +1,7 @@
<div class="playerWrap"> <div class="playerWrap">
<video id="video" playsinline></video> <video id="video" playsinline></video>
<p id="loadingNotice" class="loadingNotice">Chargement du flux...</p> <p id="loadingNotice" class="loadingNotice"><span class="spinner"></span>Chargement du flux...</p>
<div id="playerError" class="playerError hide"> <div id="playerError" class="playerError hide">
<p id="playerErrorText"></p> <p id="playerErrorText"></p>
<button id="playerErrorRetry" class="linklike">Réessayer</button> <button id="playerErrorRetry" class="linklike">Réessayer</button>

View file

@ -7,6 +7,7 @@ import type { PartyConnection } from '../../api/party';
import { navigate } from '../../router'; import { navigate } from '../../router';
import type { CleanupFn, RouteParams } from '../../router'; import type { CleanupFn, RouteParams } from '../../router';
import { colorForName, escapeHtml, initialsFor } from '../../tiles'; import { colorForName, escapeHtml, initialsFor } from '../../tiles';
import { LOADING_HINT } from '../../loading';
import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequestedMsg, PlaybackOptionsDto, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg, TextSubtitleChangedMsg } from '../../types'; import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequestedMsg, PlaybackOptionsDto, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg, TextSubtitleChangedMsg } from '../../types';
import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar'; import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar';
@ -81,7 +82,7 @@ const PAUSE_ICON_SVG = `
*/ */
export function renderPartyPlayer(root: HTMLElement, params: RouteParams): CleanupFn { export function renderPartyPlayer(root: HTMLElement, params: RouteParams): CleanupFn {
const partyId = params.id as string; const partyId = params.id as string;
root.innerHTML = '<p class="hint">Chargement...</p>'; root.innerHTML = LOADING_HINT;
let cleanup: CleanupFn = () => {}; let cleanup: CleanupFn = () => {};
let cancelled = false; let cancelled = false;

View file

@ -6,6 +6,7 @@ import { joinMusicParty } from '../../musicBar/MusicBar';
import { navigate } from '../../router'; import { navigate } from '../../router';
import type { RouteParams } from '../../router'; import type { RouteParams } from '../../router';
import { escapeHtml, formatDuration } from '../../tiles'; import { escapeHtml, formatDuration } from '../../tiles';
import { LOADING_HINT } from '../../loading';
import type { ItemDto } from '../../types'; import type { ItemDto } from '../../types';
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations
@ -23,7 +24,7 @@ const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same
*/ */
export async function renderPlaylist(root: HTMLElement, params: RouteParams): Promise<void> { export async function renderPlaylist(root: HTMLElement, params: RouteParams): Promise<void> {
const playlistId = params.id as string; const playlistId = params.id as string;
root.innerHTML = '<p class="hint">Chargement...</p>'; root.innerHTML = LOADING_HINT;
let playlist: ItemDto; let playlist: ItemDto;
let items: ItemDto[]; let items: ItemDto[];

View file

@ -5,11 +5,12 @@ import { createParty } from '../../api/party';
import { navigate } from '../../router'; import { navigate } from '../../router';
import type { RouteParams } from '../../router'; import type { RouteParams } from '../../router';
import { escapeHtml } from '../../tiles'; import { escapeHtml } from '../../tiles';
import { LOADING_HINT } from '../../loading';
import type { ItemDto, SeasonDto } from '../../types'; import type { ItemDto, SeasonDto } from '../../types';
export async function renderSeries(root: HTMLElement, params: RouteParams): Promise<void> { export async function renderSeries(root: HTMLElement, params: RouteParams): Promise<void> {
const seriesId = params.id as string; const seriesId = params.id as string;
root.innerHTML = '<p class="hint">Chargement...</p>'; root.innerHTML = LOADING_HINT;
let series: ItemDto; let series: ItemDto;
let seasons: SeasonDto[]; let seasons: SeasonDto[];
@ -57,7 +58,7 @@ export async function renderSeries(root: HTMLElement, params: RouteParams): Prom
async function loadEpisodes(root: HTMLElement, seriesId: string, seasonId: string): Promise<void> { async function loadEpisodes(root: HTMLElement, seriesId: string, seasonId: string): Promise<void> {
const listEl = root.querySelector('#episodeList')!; const listEl = root.querySelector('#episodeList')!;
listEl.innerHTML = '<p class="hint">Chargement...</p>'; listEl.innerHTML = LOADING_HINT;
let episodes: ItemDto[]; let episodes: ItemDto[];
try { try {

View file

@ -2336,6 +2336,48 @@ response with neither header (most proxied paths) doesn't choke or
invent one; `EmbyClient.fetch` tests cover a 304 not hitting the invent one; `EmbyClient.fetch` tests cover a 304 not hitting the
failure log and the two new headers being sent only when given. failure log and the two new headers being sent only when given.
## A spinner on every loading state
Asked for directly - every "Chargement..." spot across the app (route
entry for Album/Artist/Library/Playlist/Series, the sidebar's own
library list, Home's per-library carousels, a season switch reloading
its episode list, the player's video-buffering notice) was plain
`.hint` text with no visual motion, easy to mistake for a stuck page
rather than one still fetching. New `.spinner` in `styles/base.css` - a
plain CSS ring (`border` + `border-top-color`, no image/icon-font
asset), `display: inline-block` with a small negative `vertical-align`
so it drops into an existing text node instead of needing the element
restructured into a flex row; `@media (prefers-reduced-motion: reduce)`
turns off the spin for a viewer who's asked for less motion, matching
this app's existing carefulness about motion elsewhere.
New `client/src/loading.ts` exports one shared `LOADING_HINT` constant
(`<p class="hint"><span class="spinner"></span>Chargement...</p>`) so
every dynamic call site renders identical markup instead of each
hand-typing a slightly different one; the handful of *static* HTML
templates that had their own inert copy of this same placeholder text
(never actually paintable - immediately overwritten synchronously by
the same script that just set it, so a viewer could never see the
old, spinner-less version even for a frame) were left alone rather
than churned for a change nobody could ever see; `Library.html`'s own
`#grid` placeholder was the one exception - genuinely visible while
its library's items load - and got the same markup pasted directly
(a static template, no JS import of its own).
Not touched: the brief button-label loading states elsewhere ("Ajout...",
"Démarrage..." while a party/queue-add request is in flight) - a
different, smaller UI element than a block-level `.hint`, and typically
resolved within one REST round trip, low enough value to add a second
spinner variant for.
Verified: full typecheck/build on the client (no server changes at
all - this is pure client markup/CSS). Visually confirmed in a real
browser (the compiled bundle's own `app.css`) that the spinner renders
correctly inline with text in both a plain `.hint` and the player's
`.loadingNotice` context, and is genuinely animating (`getComputedStyle`
sampled twice, 300ms apart, showed a different `transform` matrix each
time).
## Known limitations ## Known limitations
- Playback always goes through `hls.js` against whatever Emby's - Playback always goes through `hls.js` against whatever Emby's