From ff664f2c2b3cd28a9684e825f41eb1e4bfba7f66 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 16 Sep 2026 17:34:10 +0200 Subject: [PATCH] Replace every blocking alert() with a light toast, fix a mobile grid bug Every message this app ever showed the client - the host-handoff notice, every "couldn't start/join a party" error - went through a blocking alert(), freezing the tab for what's usually just a heads-up. New toast.ts is a self-mounting singleton (no per-party state, so it doesn't need Shell.ts wiring like musicBar/MusicBar does) styled after Player.css's own .requestBanner, with an 'error' variant that's still just a plain left accent, not a red banner - even a failed click doesn't need this app to suddenly shout. All ten alert() call sites across Player/MusicBar/tiles/Home/Series/Playlist/Artist/Album now go through it. Also fixes a real reported bug while in the area: the library/artist grid's minmax(160px, 1fr) floor left no room for a second column once a real phone's padding was subtracted, unlike everything else mobile in this app (the sidebar, the music bar) which already got a narrow-screen treatment. Switched to a fluid clamp(100px, 28vw, 160px) floor instead of a fixed breakpoint, confirmed at seven real widths via an iframe (its own independent viewport for vw units, sidestepping this sandbox's own unreliable viewport-resize emulation). Co-Authored-By: Claude Sonnet 5 --- README.md | 6 ++ client/src/musicBar/MusicBar.ts | 3 +- client/src/tiles.css | 11 +++- client/src/tiles.ts | 3 +- client/src/toast.css | 46 +++++++++++++++ client/src/toast.ts | 66 ++++++++++++++++++++++ client/src/views/album/Album.ts | 5 +- client/src/views/artist/Artist.ts | 3 +- client/src/views/home/Home.ts | 3 +- client/src/views/player/Player.ts | 7 ++- client/src/views/playlist/Playlist.ts | 3 +- client/src/views/series/Series.ts | 3 +- docs/architecture.md | 81 +++++++++++++++++++++++++++ 13 files changed, 228 insertions(+), 12 deletions(-) create mode 100644 client/src/toast.css create mode 100644 client/src/toast.ts diff --git a/README.md b/README.md index e2493f2..be29cf2 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,12 @@ Then put it behind your existing reverse proxy like everything else movie-poster tiles — circular artist tiles, square album tiles, a real hero header, and a mobile "now playing" full-screen player — see docs/architecture.md +- [x] Every blocking `alert()` (host handoff, every "couldn't start/join + a party" error) replaced with a light, auto-dismissing toast — see + docs/architecture.md +- [x] Library/artist grid tiles shrink fluidly on a narrow phone instead + of a fixed 160px floor collapsing to one column per row — 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) diff --git a/client/src/musicBar/MusicBar.ts b/client/src/musicBar/MusicBar.ts index e735f37..e9a7165 100644 --- a/client/src/musicBar/MusicBar.ts +++ b/client/src/musicBar/MusicBar.ts @@ -6,6 +6,7 @@ import { partyStreamUrl } from '../api/embyApi'; import { connectParty, issueCastToken, leaveParty, revokeCastToken } from '../api/party'; import type { PartyConnection } from '../api/party'; import { colorForName, escapeHtml, formatDuration, initialsFor } from '../tiles'; +import { showToast } from '../toast'; import type { MusicCurrentPublic, MusicPublicState, PartyPublicState, SocketMember } from '../types'; import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../cast'; import type { PlaybackDriver } from '../cast'; @@ -233,7 +234,7 @@ export function joinMusicParty(partyId: string, party: PartyPublicState): void { if (takeOver) conn?.confirmDeviceTakeover(); else leaveMusicParty(); }, - onError: (message) => alert(message), + onError: (message) => showToast(message, { variant: 'error' }), onClose: () => {} }); } diff --git a/client/src/tiles.css b/client/src/tiles.css index b1df249..b2ed905 100644 --- a/client/src/tiles.css +++ b/client/src/tiles.css @@ -20,9 +20,18 @@ .searchEmpty { margin-top: 8px; } +/* The 160px floor was too wide for a real phone once .mainContent's own + side padding is subtracted - reported directly: a ~360-393px-wide + viewport (a common real Android width, not a rare edge case) only + ever fit one column. clamp(), not a max-width media query breakpoint: + this scales the minimum continuously with the viewport instead of + jumping at one arbitrary width, so it's already right at every size + in between, not just "mobile" and "desktop" - 28vw lands close to + 100px on a narrow phone and hits the 160px ceiling (today's exact + desktop value, unchanged) well before any actual desktop width. */ .grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 28vw, 160px), 1fr)); gap: 20px 16px; } diff --git a/client/src/tiles.ts b/client/src/tiles.ts index 3552540..7ac8233 100644 --- a/client/src/tiles.ts +++ b/client/src/tiles.ts @@ -2,6 +2,7 @@ // grid, and the artist page, so they all stay visually/behaviorally // identical instead of drifting. import './tiles.css'; +import { showToast } from './toast'; import type { ItemDto } from './types'; export function escapeHtml(str: string): string { @@ -136,7 +137,7 @@ export function wireTileButtons(container: ParentNode, onStart: (itemId: string) try { await onStart(btn.dataset.itemId as string); } catch (err) { - alert((err as Error).message); + showToast((err as Error).message, { variant: 'error' }); btn.disabled = false; btn.textContent = 'Lancer une watch party'; } diff --git a/client/src/toast.css b/client/src/toast.css new file mode 100644 index 0000000..dc72eb6 --- /dev/null +++ b/client/src/toast.css @@ -0,0 +1,46 @@ +/* ---- Global toast notifications - see toast.ts. Same light-pill, + auto-dismiss visual language as views/player/Player.css's own + .requestBanner, generalized to work from any page (position: fixed + against the viewport, not the player specifically) since this is + used well beyond it now (host handoff, every "couldn't start/join a + party" error that used to be a blocking alert()). ---- */ +.toastContainer { + position: fixed; + top: max(16px, env(safe-area-inset-top, 0px)); + left: 50%; + transform: translateX(-50%); + z-index: 100; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + max-width: calc(100% - 32px); + pointer-events: none; +} + +.toast { + max-width: 100%; + background: var(--bg-header); + color: var(--text); + padding: 10px 18px; + border-radius: 20px; + font-size: 13px; + text-align: center; + box-shadow: 0 4px 16px rgba(0, 0, 0, .35); + opacity: 0; + transform: translateY(-8px); + transition: opacity .2s ease, transform .2s ease; +} + +.toast.toastIn { opacity: 1; transform: translateY(0); } +.toast.toastOut { opacity: 0; } + +/* Only variant beyond the default light/neutral pill above - a plain + left accent, not a loud red fill: still meant to be noticed without + the app suddenly shouting at you (see toast.ts's own comment on why + this stays this restrained even for errors). */ +.toast--error { border-left: 3px solid var(--danger); } + +@media (prefers-reduced-motion: reduce) { + .toast { transition: none; transform: none; } +} diff --git a/client/src/toast.ts b/client/src/toast.ts new file mode 100644 index 0000000..f62fd6b --- /dev/null +++ b/client/src/toast.ts @@ -0,0 +1,66 @@ +import './toast.css'; + +/** + * Global, light-touch notifications - replaces the blocking `alert()` + * this app used to reach for everywhere, from "you're now the host" + * (a status update, not something to interrupt anyone for) down to + * "couldn't start that party" (still just a message, not worth + * freezing the whole page over). Self-mounting (appends its own + * container to `document.body` on first use) rather than something + * Shell.ts wires up like musicBar/MusicBar - there's no per-party + * state to attach here, just a place to drop text that outlives + * whichever view happened to trigger it, so a plain module-level + * singleton is all this needs. + */ + +let container: HTMLElement | null = null; + +function getContainer(): HTMLElement { + if (!container) { + container = document.createElement('div'); + container.className = 'toastContainer'; + document.body.appendChild(container); + } + return container; +} + +export type ToastVariant = 'info' | 'error'; + +export interface ToastOptions { + /** 'error' adds a left accent - still the same restrained pill, not + * a red banner, since even a failed "start a party" click doesn't + * warrant this app suddenly shouting. Default 'info'. */ + variant?: ToastVariant; + /** Milliseconds before it fades back out on its own - there's no + * manual dismiss button, same as Player.ts's own .requestBanner. */ + duration?: number; +} + +const DEFAULT_DURATION_MS = 4500; + +export function showToast(message: string, options: ToastOptions = {}): void { + const { variant = 'info', duration = DEFAULT_DURATION_MS } = options; + + const el = document.createElement('div'); + el.className = `toast${variant === 'error' ? ' toast--error' : ''}`; + el.textContent = message; + getContainer().appendChild(el); + + // Two rAFs, not one: the element needs a full layout/style pass in + // its *starting* state (opacity 0) before the class add is what + // triggers the transition - adding it in the very same frame the + // node was inserted can get coalesced with the insert itself, and + // the "fade in" never visibly happens. + requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('toastIn'))); + + setTimeout(() => { + el.classList.remove('toastIn'); + el.classList.add('toastOut'); + el.addEventListener('transitionend', () => el.remove(), { once: true }); + // Belt-and-suspenders for prefers-reduced-motion (transition: none + // there - see toast.css) - 'transitionend' never fires with no + // transition running, which would otherwise leave this in the DOM + // forever instead of just skipping straight to removed. + setTimeout(() => el.remove(), 300); + }, duration); +} diff --git a/client/src/views/album/Album.ts b/client/src/views/album/Album.ts index a79cb9d..9a942cc 100644 --- a/client/src/views/album/Album.ts +++ b/client/src/views/album/Album.ts @@ -6,6 +6,7 @@ import { addTrackToQueue, createParty, queueItem } from '../../api/party'; import { getActiveMusicPartyId, joinMusicParty } from '../../musicBar/MusicBar'; import { escapeHtml, formatDuration, wireSearchFilter } from '../../tiles'; import { LOADING_HINT } from '../../loading'; +import { showToast } from '../../toast'; import type { ItemDto, MusicTrackInput } from '../../types'; import type { RouteParams } from '../../router'; @@ -130,7 +131,7 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi btn.innerHTML = CHECK_ICON_SVG; setTimeout(() => { btn.disabled = false; refreshLabels(); }, 1500); } catch (err) { - alert((err as Error).message); + showToast((err as Error).message, { variant: 'error' }); btn.disabled = false; refreshLabels(); } @@ -158,7 +159,7 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi launchBtnLabel.textContent = 'Ajouté !'; setTimeout(() => { launchBtn.disabled = false; refreshLabels(); }, 1500); } catch (err) { - alert((err as Error).message); + showToast((err as Error).message, { variant: 'error' }); launchBtn.disabled = false; refreshLabels(); } diff --git a/client/src/views/artist/Artist.ts b/client/src/views/artist/Artist.ts index 0846091..e53a8ad 100644 --- a/client/src/views/artist/Artist.ts +++ b/client/src/views/artist/Artist.ts @@ -6,6 +6,7 @@ import { getActiveMusicPartyId, joinMusicParty } from '../../musicBar/MusicBar'; import type { RouteParams } from '../../router'; import { tileHtml, wireSearchFilter } from '../../tiles'; import { LOADING_HINT } from '../../loading'; +import { showToast } from '../../toast'; // Same icon, same "small local constant" convention as musicBar/MusicBar.ts's // own PLAY_ICON_SVG - not shared from there since neither file otherwise @@ -86,7 +87,7 @@ export async function renderArtist(root: HTMLElement, params: RouteParams): Prom launchBtnLabel.textContent = 'Ajouté !'; setTimeout(() => { launchBtn.disabled = false; refreshLabel(); }, 1500); } catch (err) { - alert((err as Error).message); + showToast((err as Error).message, { variant: 'error' }); launchBtn.disabled = false; refreshLabel(); } diff --git a/client/src/views/home/Home.ts b/client/src/views/home/Home.ts index 748a256..a52e09c 100644 --- a/client/src/views/home/Home.ts +++ b/client/src/views/home/Home.ts @@ -6,6 +6,7 @@ import { getActiveMusicPartyId } from '../../musicBar/MusicBar'; import { navigate } from '../../router'; import { escapeHtml, tileHtml, wireTileButtons } from '../../tiles'; import { LOADING_HINT } from '../../loading'; +import { showToast } from '../../toast'; import type { LobbyPartyDto } from '../../types'; export function renderHome(root: HTMLElement): () => void { @@ -83,7 +84,7 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void { await joinParty(btn.dataset.partyId as string); navigate(`/party/${btn.dataset.partyId}`); } catch (err) { - alert((err as Error).message); + showToast((err as Error).message, { variant: 'error' }); btn.disabled = false; } }); diff --git a/client/src/views/player/Player.ts b/client/src/views/player/Player.ts index b05174b..b0ab655 100644 --- a/client/src/views/player/Player.ts +++ b/client/src/views/player/Player.ts @@ -8,6 +8,7 @@ import { navigate } from '../../router'; import type { CleanupFn, RouteParams } from '../../router'; import { colorForName, escapeHtml, formatDuration, initialsFor } from '../../tiles'; import { LOADING_HINT } from '../../loading'; +import { showToast } from '../../toast'; import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequestedMsg, PlaybackMarkersDto, PlaybackOptionsDto, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg, TextSubtitleChangedMsg } from '../../types'; import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar'; import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../../cast'; @@ -149,7 +150,7 @@ export function renderPartyPlayer(root: HTMLElement, params: RouteParams): Clean // rendering bug in renderVideoPlayer itself, not just a genuine "party // not found", so the stack trace matters here. console.error('[watchparty player] failed to open party', err); - alert(err.message); + showToast(err.message, { variant: 'error' }); navigate('/home'); }); @@ -679,7 +680,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli renderSubtitleOptions(); applyStreamOptionsGating(); - if (notify) alert("L'hôte précédent a quitté la party - tu es maintenant l'hôte."); + if (notify) showToast("L'hôte précédent a quitté la party - tu es maintenant l'hôte."); } async function loadTrackOptions(itemId: string) { @@ -1239,7 +1240,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli if (takeOver) conn?.confirmDeviceTakeover(); else navigate('/home'); }, - onError: (message) => alert(message), + onError: (message) => showToast(message, { variant: 'error' }), onClose: () => {} }); } diff --git a/client/src/views/playlist/Playlist.ts b/client/src/views/playlist/Playlist.ts index 8de4629..760d200 100644 --- a/client/src/views/playlist/Playlist.ts +++ b/client/src/views/playlist/Playlist.ts @@ -7,6 +7,7 @@ import { navigate } from '../../router'; import type { RouteParams } from '../../router'; import { escapeHtml, formatDuration } from '../../tiles'; import { LOADING_HINT } from '../../loading'; +import { showToast } from '../../toast'; import type { ItemDto } from '../../types'; const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations @@ -93,7 +94,7 @@ export async function renderPlaylist(root: HTMLElement, params: RouteParams): Pr navigate(`/party/${party.partyId}`); } } catch (err) { - alert((err as Error).message); + showToast((err as Error).message, { variant: 'error' }); btn.disabled = false; btn.textContent = idleLabel; } diff --git a/client/src/views/series/Series.ts b/client/src/views/series/Series.ts index 63dc39c..69eb9fb 100644 --- a/client/src/views/series/Series.ts +++ b/client/src/views/series/Series.ts @@ -6,6 +6,7 @@ import { navigate } from '../../router'; import type { RouteParams } from '../../router'; import { escapeHtml } from '../../tiles'; import { LOADING_HINT } from '../../loading'; +import { showToast } from '../../toast'; import type { ItemDto, SeasonDto } from '../../types'; export async function renderSeries(root: HTMLElement, params: RouteParams): Promise { @@ -95,7 +96,7 @@ async function loadEpisodes(root: HTMLElement, seriesId: string, seasonId: strin const party = await createParty(btn.dataset.itemId as string); navigate(`/party/${party.partyId}`); } catch (err) { - alert((err as Error).message); + showToast((err as Error).message, { variant: 'error' }); btn.disabled = false; btn.textContent = 'Lancer une watch party'; } diff --git a/docs/architecture.md b/docs/architecture.md index ca01269..9cc70cd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3775,6 +3775,87 @@ genuinely narrow viewport. **Not verified**: any of this against a real Emby server's actual artist/album images and names, or on a real phone. +## Toast notifications, replacing every blocking `alert()` + +Asked for directly, prompted by the host-handoff message specifically +(`"L'hôte précédent a quitté la party - tu es maintenant l'hôte."`) - +a blocking `alert()` freezes the whole tab and demands a click to +dismiss, for what's genuinely just a heads-up. Once looking at that +one, every OTHER `alert()` in the client turned out to be the exact +same pattern (`alert((err as Error).message)` after a failed +create/join, or a bare `onError: (message) => alert(message)` on a +socket connection) - ten call sites total, across `Player.ts`, +`MusicBar.ts`, `tiles.ts`'s shared `wireTileButtons`, and every one of +`Home`/`Series`/`Playlist`/`Artist`/`Album`'s own "start a party" +buttons. All ten now go through one new module instead. + +**New `toast.ts`/`toast.css`** - a plain module-level singleton, not +something Shell.ts mounts like `musicBar/MusicBar` (there's no +per-party state here, just somewhere to drop text), so it lazily +creates its own fixed-position container on first use and works from +literally any file that imports `showToast()`. Visually the same +light pill/auto-dismiss language `views/player/Player.css`'s own +`.requestBanner` already used for pause/resume requests, generalized +to `position: fixed` against the viewport (not `.playerWrap` +specifically) so it works from any page, stacked (a flex column with +`gap`, so an error and a moment-later status update don't clobber each +other) and layered above everything else in the app (`z-index: 100`, +comfortably clear of the highest anything else currently uses). + +**Two variants, one restrained look** - `variant: 'info'` (default, +the host-handoff message, every status-only case) and `variant: +'error'` (every migrated `alert()` above) differ by exactly one thing, +a plain `border-left` accent in `var(--danger)`. Deliberately not a +red banner/fill even for errors: a failed "start a party" click is +still just a message, not something this app should suddenly shout +about - matching the restraint the rest of this app's UI already +favors (subtle background tints, not loud fills, for e.g. +`.avatarActionRequested`). No manual dismiss button, same as +`.requestBanner` before it - every toast fades itself out after +~4.5s, respecting `prefers-reduced-motion` (an instant show/hide +instead of the opacity/transform fade, same guard `.spinner` already +uses elsewhere). + +Verified: full `typecheck`/`build` (client only). Confirmed there are +no `alert(` calls left in `client/src` beyond comments that merely +mention the word. The fade-in double-`requestAnimationFrame` trick +(needed because adding the "visible" class in the same frame as the +insert can get coalesced with it, silently skipping the transition) +was checked directly against a real element in a real browser rather +than assumed from the general pattern. + +## Library/artist grid: the tile minimum was too wide for a real phone + +Reported directly, against a real device: a phone in the +~360-393px-wide range (an ordinary width, not a rare one) was showing +exactly one tile per row in every library/artist grid. The grid's +`minmax(160px, 1fr))` floor was never wrong on its own terms - it's +just wider than what's left once `.mainContent`'s own side padding is +subtracted from a real phone's width, and there was no narrower-screen +override for it at all (unlike the sidebar/nav or the music bar, +`tiles.css`'s `.grid` had never gotten one). + +Fixed with `minmax(clamp(100px, 28vw, 160px), 1fr)` - a fluid minimum, +not a `max-width` breakpoint. `clamp()` scales continuously with the +real viewport instead of jumping at one arbitrary width, so every size +in between "phone" and "desktop" (a tablet, a half-width browser +window) already gets a reasonable column count too, not just the two +sizes a breakpoint would have explicitly targeted - and it reduces to +today's exact `160px` past whatever width `28vw` alone would already +exceed it, so desktop is provably unchanged rather than just "probably +fine." + +Verified: full `typecheck`/`build` (client only). Confirmed in a real +browser, not just computed by hand: an `