From 939beb6fa93782b732a5fbe044e61c46f77df0f5 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 14 Sep 2026 23:30:25 +0200 Subject: [PATCH] Add a spinner to every loading state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/src/loading.ts | 17 +++++++++++ client/src/shell/Shell.html | 2 +- client/src/styles/base.css | 27 +++++++++++++++++ client/src/views/album/Album.ts | 3 +- client/src/views/artist/Artist.ts | 3 +- client/src/views/home/Home.ts | 3 +- client/src/views/library/Library.html | 2 +- client/src/views/library/Library.ts | 3 +- client/src/views/player/Player.html | 2 +- client/src/views/player/Player.ts | 3 +- client/src/views/playlist/Playlist.ts | 3 +- client/src/views/series/Series.ts | 5 ++-- docs/architecture.md | 42 +++++++++++++++++++++++++++ 13 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 client/src/loading.ts diff --git a/client/src/loading.ts b/client/src/loading.ts new file mode 100644 index 0000000..e70c326 --- /dev/null +++ b/client/src/loading.ts @@ -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

. +export const LOADING_HINT = '

Chargement...

'; diff --git a/client/src/shell/Shell.html b/client/src/shell/Shell.html index de9bd4e..9fa156a 100644 --- a/client/src/shell/Shell.html +++ b/client/src/shell/Shell.html @@ -4,7 +4,7 @@ diff --git a/client/src/styles/base.css b/client/src/styles/base.css index 92e550d..5748950 100644 --- a/client/src/styles/base.css +++ b/client/src/styles/base.css @@ -51,6 +51,33 @@ h2 { font-size: 16px; margin: 0 0 12px; } .hint { color: var(--muted); font-size: 13px; margin: 0 0 16px; } .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 { display: block; font-size: 13px; diff --git a/client/src/views/album/Album.ts b/client/src/views/album/Album.ts index eafdf1c..7876e3b 100644 --- a/client/src/views/album/Album.ts +++ b/client/src/views/album/Album.ts @@ -5,6 +5,7 @@ import { getAlbumTracks, getItem } from '../../api/embyApi'; import { addTrackToQueue, createParty } from '../../api/party'; import { getActiveMusicPartyId, joinMusicParty } from '../../musicBar/MusicBar'; import { escapeHtml, formatDuration, wireSearchFilter } from '../../tiles'; +import { LOADING_HINT } from '../../loading'; import type { ItemDto, MusicTrackInput } from '../../types'; import type { RouteParams } from '../../router'; @@ -16,7 +17,7 @@ const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same * series' seasons do). */ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promise { const albumId = params.id as string; - root.innerHTML = '

Chargement...

'; + root.innerHTML = LOADING_HINT; let album: ItemDto; let tracks: ItemDto[]; diff --git a/client/src/views/artist/Artist.ts b/client/src/views/artist/Artist.ts index 62a0d96..43e7644 100644 --- a/client/src/views/artist/Artist.ts +++ b/client/src/views/artist/Artist.ts @@ -2,6 +2,7 @@ import html from './Artist.html'; import { getArtistAlbums, getItem } from '../../api/embyApi'; import type { RouteParams } from '../../router'; import { tileHtml, wireSearchFilter } from '../../tiles'; +import { LOADING_HINT } from '../../loading'; /** Route `/artist/:id` - one artist's albums, reached from a music * 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). */ export async function renderArtist(root: HTMLElement, params: RouteParams): Promise { const artistId = params.id as string; - root.innerHTML = '

Chargement...

'; + root.innerHTML = LOADING_HINT; let artist; let albums; diff --git a/client/src/views/home/Home.ts b/client/src/views/home/Home.ts index 45af6e7..9086edf 100644 --- a/client/src/views/home/Home.ts +++ b/client/src/views/home/Home.ts @@ -5,6 +5,7 @@ import { connectLobby, createParty, joinParty } from '../../api/party'; import { getActiveMusicPartyId } from '../../musicBar/MusicBar'; import { navigate } from '../../router'; import { escapeHtml, tileHtml, wireTileButtons } from '../../tiles'; +import { LOADING_HINT } from '../../loading'; import type { LobbyPartyDto } from '../../types'; export function renderHome(root: HTMLElement): () => void { @@ -148,7 +149,7 @@ async function loadRecentSections(root: HTMLElement): Promise { for (const lib of libraries) { const section = document.createElement('section'); section.className = 'section'; - section.innerHTML = `

Ajouts récents - ${escapeHtml(lib.name)}

`; + section.innerHTML = `

Ajouts récents - ${escapeHtml(lib.name)}

`; container.appendChild(section); getRecentItems(lib.id) diff --git a/client/src/views/library/Library.html b/client/src/views/library/Library.html index 434a9df..69365c4 100644 --- a/client/src/views/library/Library.html +++ b/client/src/views/library/Library.html @@ -1,4 +1,4 @@

Aucun résultat.

-

Chargement...

+

Chargement...

diff --git a/client/src/views/library/Library.ts b/client/src/views/library/Library.ts index bf43972..9d3d336 100644 --- a/client/src/views/library/Library.ts +++ b/client/src/views/library/Library.ts @@ -4,6 +4,7 @@ import { createParty } from '../../api/party'; import { navigate } from '../../router'; import type { RouteParams } from '../../router'; import { tileHtml, wireSearchFilter, wireTileButtons } from '../../tiles'; +import { LOADING_HINT } from '../../loading'; /** * 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). */ export async function renderLibrary(root: HTMLElement, params: RouteParams): Promise { - root.innerHTML = '

Chargement...

'; + root.innerHTML = LOADING_HINT; let libraries; try { diff --git a/client/src/views/player/Player.html b/client/src/views/player/Player.html index fa79e74..1cdc540 100644 --- a/client/src/views/player/Player.html +++ b/client/src/views/player/Player.html @@ -1,7 +1,7 @@
-

Chargement du flux...

+

Chargement du flux...

diff --git a/client/src/views/player/Player.ts b/client/src/views/player/Player.ts index ae37a15..b20c9a0 100644 --- a/client/src/views/player/Player.ts +++ b/client/src/views/player/Player.ts @@ -7,6 +7,7 @@ import type { PartyConnection } from '../../api/party'; import { navigate } from '../../router'; import type { CleanupFn, RouteParams } from '../../router'; 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 { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar'; @@ -81,7 +82,7 @@ const PAUSE_ICON_SVG = ` */ export function renderPartyPlayer(root: HTMLElement, params: RouteParams): CleanupFn { const partyId = params.id as string; - root.innerHTML = '

Chargement...

'; + root.innerHTML = LOADING_HINT; let cleanup: CleanupFn = () => {}; let cancelled = false; diff --git a/client/src/views/playlist/Playlist.ts b/client/src/views/playlist/Playlist.ts index 0d3bb8d..73f85f3 100644 --- a/client/src/views/playlist/Playlist.ts +++ b/client/src/views/playlist/Playlist.ts @@ -6,6 +6,7 @@ import { joinMusicParty } from '../../musicBar/MusicBar'; import { navigate } from '../../router'; import type { RouteParams } from '../../router'; import { escapeHtml, formatDuration } from '../../tiles'; +import { LOADING_HINT } from '../../loading'; import type { ItemDto } from '../../types'; 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 { const playlistId = params.id as string; - root.innerHTML = '

Chargement...

'; + root.innerHTML = LOADING_HINT; let playlist: ItemDto; let items: ItemDto[]; diff --git a/client/src/views/series/Series.ts b/client/src/views/series/Series.ts index 146255f..63dc39c 100644 --- a/client/src/views/series/Series.ts +++ b/client/src/views/series/Series.ts @@ -5,11 +5,12 @@ import { createParty } from '../../api/party'; import { navigate } from '../../router'; import type { RouteParams } from '../../router'; import { escapeHtml } from '../../tiles'; +import { LOADING_HINT } from '../../loading'; import type { ItemDto, SeasonDto } from '../../types'; export async function renderSeries(root: HTMLElement, params: RouteParams): Promise { const seriesId = params.id as string; - root.innerHTML = '

Chargement...

'; + root.innerHTML = LOADING_HINT; let series: ItemDto; 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 { const listEl = root.querySelector('#episodeList')!; - listEl.innerHTML = '

Chargement...

'; + listEl.innerHTML = LOADING_HINT; let episodes: ItemDto[]; try { diff --git a/docs/architecture.md b/docs/architecture.md index 8ada078..63fa476 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 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 +(`

Chargement...

`) 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 - Playback always goes through `hls.js` against whatever Emby's