Replace every blocking alert() with a light toast, fix a mobile grid bug #12

Merged
kyuno merged 2 commits from feature/toast-notifications into master 2026-09-16 18:33:02 +02:00
13 changed files with 228 additions and 12 deletions
Showing only changes of commit ff664f2c2b - Show all commits

View file

@ -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)

View file

@ -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: () => {}
});
}

View file

@ -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;
}

View file

@ -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';
}

46
client/src/toast.css Normal file
View file

@ -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; }
}

66
client/src/toast.ts Normal file
View file

@ -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);
}

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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;
}
});

View file

@ -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: () => {}
});
}

View file

@ -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;
}

View file

@ -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<void> {
@ -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';
}

View file

@ -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 `<iframe>` sized to each of
320/360/375/393/430/768/1200px (an iframe gets its own independent
viewport for `vw` units, which this sandbox's own unreliable
`resize_window` emulation can't provide - see the mobile-nav/music-UI
sections above) with the real compiled `tiles.css` loaded inside it,
checking the actual rendered column count at each. Every phone width
from 320 to 393px now gets 2 columns (previously 1), 430px gets 3, and
768/1200px match what the old fixed `160px` floor already gave -
confirmed empirically, not assumed from the formula alone.
## Known limitations
- Playback always goes through `hls.js` against whatever Emby's