Merge pull request 'Replace every blocking alert() with a light toast, fix a mobile grid bug' (#12) from feature/toast-notifications into master
This commit is contained in:
commit
0a9b3fb498
13 changed files with 228 additions and 12 deletions
|
|
@ -361,6 +361,12 @@ Then put it behind your existing reverse proxy like everything else
|
|||
- [x] Mobile menu's bottom sheet is now swipe-to-close (a real drag
|
||||
handle, not just a decorative grabber) instead of fighting the
|
||||
browser's own pull-to-refresh gesture — 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)
|
||||
|
|
|
|||
|
|
@ -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: () => {}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
46
client/src/toast.css
Normal 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
66
client/src/toast.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: () => {}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3830,6 +3830,87 @@ mouse or dispatched `PointerEvent`) on a real phone - no device
|
|||
reachable from this sandbox, same standing gap as this project's other
|
||||
touch-specific work (the video/music seek bars' own drag handling).
|
||||
|
||||
## 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue