Rework the player's bottom bar and add a real fullscreen toggle
All checks were successful
Publier l'image Docker / test (push) Successful in 19s
Publier l'image Docker / publish (push) Successful in 7s

Asked for directly - a UI rework of the video player in both normal and
fullscreen mode, with one concrete requirement: a viewer in fullscreen
still needs to see the pause/resume request buttons. Built a mockup
first and iterated it through feedback before touching any code - it
ended up covering three things, not the two first asked for.

The actual fullscreen bug, found while looking into the request: this
app never had its own fullscreen button - the only way in was the
browser's native affordance on the bare <video> itself (double-click,
or the host's own native <video controls> fullscreen button). Native
per-element fullscreen only renders that element's own subtree, and
every overlay - top bar, presence, and critically the request toolbar -
is a sibling of <video>, so all of it vanished the instant fullscreen
engaged. A follower in fullscreen had no way to reach the host at all.

Fix: a new #fullscreenBtn calls playerWrap.requestFullscreen() - the
whole player, video AND overlay chrome - never video.requestFullscreen().
The native per-video path is still reachable (nothing removed there -
the host keeps native play/pause/seek/volume exactly as before), so a
document-level fullscreenchange listener watches for
document.fullscreenElement === video specifically and corrects it:
exit, then re-enter on playerWrap instead.

Bottom bar rework: three previously-independent floating pieces (a
centered banner/notice column, a request toolbar cornered bottom-right,
an "Épisode suivant" text link in the top bar) become one #bottomBar
with three zones - left shows exactly one of #nextBtn (host, now a real
accent-green pill with a skip-next icon) or #requestToolbar (follower,
now icon + explicit "Demander pause"/"Demander reprise" labels) since
the two are mutually exclusive by construction; center is a single
#ambientNotice line (casting takes priority over "L'hôte contrôle la
lecture" - never both at once); right is the fullscreen toggle, for
everyone. The transient request banner stays independent near the top,
not folded into the persistent bar, so it still reads as a toast.

Also added #castingScreen: a plain icon + "Diffusion sur le Chromecast"
covering the video area while casting, instead of leaving the local
<video>'s last frame sitting there paused with no explanation.

Verified: full typecheck/build on the client (pure markup/CSS/TS, no
server changes). Checked in a real browser against the actual compiled
app.css/bundle - injected the new markup for the host, follower, and
casting views and confirmed each renders as designed; caught and fixed
a real sizing bug this way (.fsBtn grew to 32px instead of its declared
30px - a flex item's default min-width:auto let button's own base
padding win over the smaller explicit width - fixed with an explicit
padding:0). Not verified: the real Fullscreen API transition itself
needs a user gesture this sandbox can't exercise - worth trying once
deployed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-15 11:06:44 +02:00
parent 5a1bc8d932
commit f5a941525f
5 changed files with 360 additions and 89 deletions

View file

@ -306,6 +306,11 @@ Then put it behind your existing reverse proxy like everything else
album page's row grid) and its missing album art (a track almost
never has its own image — now falls back to its album's) — see
docs/architecture.md
- [x] Reworked the video player's bottom bar (episode/request actions/
fullscreen, one bar instead of three floating pieces) and added a
real fullscreen toggle — fullscreen now targets the whole player,
not just the `<video>`, so the request buttons stay visible — 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

@ -141,33 +141,20 @@
.playerError p { color: var(--danger); font-size: 14px; margin: 0 0 10px; }
/* One column, bottom-anchored, gap between whichever children are
actually visible - same "stack in normal flow instead of guessing a
fixed pixel offset per absolutely-positioned block" fix .topOverlay's
own actionBar+presenceOverlay grouping already needed (see its own
comment, and docs/architecture.md for the exact bug this avoided: a
<p>'s default margin silently throwing off a hand-picked bottom offset
on the element above it - not repeatable once nothing is positioned
independently any more). Centered on purpose - this is exactly where
a native <track> subtitle renders too, but both children here are
passive text (a transient banner, a static notice), not something a
viewer needs to click through subtitle text to reach - unlike
.requestToolbar below, which used to live in this same column and
was moved out for exactly that reason. */
.bottomOverlay {
/* Transient toast (see showRequestBanner in Player.ts) - positioned
independently near the top so it never fights the persistent
.bottomBar below for space, and reads as a pop-in/fade-out
notification rather than a fourth thing bolted onto a fixed layout.
Centered under .topOverlay's usual (single-line) height - see
Player.html's own comment. */
.requestBanner {
position: absolute;
bottom: 20px;
top: 64px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
z-index: 6;
z-index: 7;
max-width: calc(100% - 32px);
}
.requestBanner {
text-align: center;
background: rgba(0, 0, 0, .75);
color: white;
padding: 8px 16px;
@ -175,50 +162,124 @@
font-size: 13px;
}
.followerNotice {
margin: 0;
background: rgba(0,0,0,.6);
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
color: var(--muted);
}
/* The non-host's way to ask the host for something - see
Player.ts's requestPauseBtn/requestResumeBtn. Pill buttons matching
the banner/notice's own dark-overlay look above, not the plain-text
.linklike used in the (opaque) top action bar - this sits directly on
top of the video image, same as .bottomOverlay.
Its own bottom-RIGHT corner, independently of .bottomOverlay -
deliberately not centered-bottom any more (see .bottomOverlay's own
comment): that's also where a native <track> subtitle renders, and
these are real buttons someone needs to read and click, not passive
text a subtitle could just as easily sit behind. Stacked as a column
(not a row, unlike the old centered layout) so two pill buttons next
to each other never has to fight a narrow phone screen for width -
each just takes however much it needs, right-aligned under the other. */
.requestToolbar {
/* Replaces what used to be three separate floating pieces (a centered
banner/notice column, a request toolbar cornered bottom-right, an
"Épisode suivant" link in the top bar) with one persistent bar - see
Player.html's own comment on why staying inside .playerWrap (not a
sibling positioned relative to it) also matters for plein écran. */
.bottomBar {
position: absolute;
right: 16px;
bottom: 20px;
left: 10px;
right: 10px;
bottom: 10px;
z-index: 6;
display: flex;
flex-direction: column;
align-items: flex-end;
align-items: center;
gap: 10px;
background: rgba(10, 8, 11, .8);
border: 1px solid rgba(255, 255, 255, .08);
border-radius: 12px;
padding: 8px 10px;
}
.bottomBarZone { display: flex; align-items: center; gap: 8px; }
.bottomBarZone.left { flex-shrink: 0; }
.bottomBarZone.center { flex: 1; justify-content: center; min-width: 0; }
.bottomBarZone.right { flex-shrink: 0; }
/* Center zone: never more than one message at a time (see
updateAmbientNotice in Player.ts) - "L'hôte contrôle la lecture" for
a follower, or "Diffusion en cours sur le Chromecast" while casting
(that one takes priority - the more useful thing to know in the
moment), empty for a host who isn't casting. */
.ambientNotice {
color: var(--muted);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Nested in .bottomBarZone.left now, not its own cornered overlay - see
Player.html. Never shown alongside #nextBtn below - that same zone
shows exactly one of the two (host vs follower), never both. */
.requestToolbar {
display: flex;
gap: 8px;
max-width: calc(50% - 16px);
}
.requestBtn {
background: rgba(0, 0, 0, .6);
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, .1);
color: white;
padding: 8px 14px;
padding: 7px 12px;
border-radius: 20px;
font-size: 12.5px;
white-space: nowrap; /* keeps the pill shape even in .requestToolbar's own narrow corner on a phone-width screen */
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.requestBtn:hover { background: rgba(0, 0, 0, .8); }
.requestBtn:hover { background: rgba(255, 255, 255, .18); }
.requestBtn svg { flex-shrink: 0; }
/* Host-only, sharing the request buttons' left zone (see .requestToolbar
above) - a real pill button now instead of the old plain underlined
top-bar link. Accent green, not the neutral grey .requestBtn uses -
this is a direct, primary action, not a muted ask. */
.nextBtn {
display: inline-flex;
align-items: center;
gap: 7px;
background: var(--accent);
color: white;
padding: 7px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.nextBtn:hover { background: var(--accent-bright); }
.nextBtn svg { flex-shrink: 0; }
.fsBtn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0; /* base.css's own `button` rule defaults to 12px 16px - wider than this button's declared box, and a flex item's default min-width:auto would otherwise let that padding grow it past 30px */
border-radius: 8px;
background: rgba(255, 255, 255, .1);
color: white;
flex-shrink: 0;
}
.fsBtn:hover { background: rgba(255, 255, 255, .18); }
/* Replaces the video content while casting - the local <video> is
paused/torn down (see Player.ts's startCasting/cast.ts), so leaving
its last frame frozen with no explanation would just look stuck. */
.castingScreen {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: rgba(255, 255, 255, .4);
text-align: center;
}
.castingScreen strong {
color: rgba(255, 255, 255, .85);
font-size: 14px;
font-weight: 600;
}
/* Shown to EVERY member (including the host and the requester themselves)
on the matching person's circle while their pause/resume request is

View file

@ -1,6 +1,20 @@
<div class="playerWrap">
<video id="video" playsinline></video>
<!-- Replaces the video content while casting - the local <video> is
paused/torn down (see Player.ts's startCasting/cast.ts), so
leaving its last frame frozen with no explanation would just look
stuck. See docs/architecture.md's "Casting to a Chromecast". -->
<div id="castingScreen" class="castingScreen hide">
<svg viewBox="0 0 24 24" width="40" height="40" aria-hidden="true">
<rect x="1" y="4" width="22" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"></rect>
<path d="M4 15a5 5 0 0 1 5 5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"></path>
<path d="M4 11a9 9 0 0 1 9 9" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"></path>
<circle cx="4" cy="20" r="1.3" fill="currentColor"></circle>
</svg>
<strong>Diffusion sur le Chromecast</strong>
</div>
<p id="loadingNotice" class="loadingNotice"><span class="spinner"></span>Chargement du flux...</p>
<div id="playerError" class="playerError hide">
<p id="playerErrorText"></p>
@ -11,7 +25,6 @@
<div class="actionBar">
<span id="itemTitle" class="itemTitle"></span>
<span id="members" class="members"></span>
<button id="nextBtn" class="linklike hide">Épisode suivant &rarr;</button>
<button id="castBtn" class="iconBtn hide" title="Caster sur un Chromecast" aria-label="Caster sur un Chromecast" aria-pressed="false">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
@ -38,24 +51,57 @@
<div id="presenceOverlay" class="presenceOverlay"></div>
</div>
<!-- Stacked as one column (same reasoning as .topOverlay's own
actionBar+presenceOverlay grouping - see Player.css) so the
transient banner and the passive notice always sit in the right
order relative to each other regardless of which is visible at a
given moment, instead of each guessing a fixed pixel offset from
the bottom edge. The request toolbar (the actual buttons) used to
live here too, third in this same column - moved out to its own
bottom-right corner instead (see .requestToolbar in Player.css):
centered-bottom is also where a native <track> subtitle renders,
and the buttons sat right on top of it. -->
<div class="bottomOverlay">
<!-- Transient toast (see showRequestBanner in Player.ts) - positioned
independently of the bottom bar below, near the top, so it never
fights that persistent bar for space and reads as a pop-in/fade-out
notification rather than a fourth thing bolted onto a fixed layout. -->
<div id="requestBanner" class="requestBanner hide"></div>
<p id="followerNotice" class="followerNotice hide">L'hôte contrôle la lecture.</p>
<p id="castingNotice" class="followerNotice hide">Diffusion en cours sur le Chromecast.</p>
</div>
<!-- One bar, bottom-anchored, replacing what used to be three separate
floating pieces (a centered banner/notice column, a request
toolbar cornered bottom-right, an "Épisode suivant" link up in the
top bar) - see Player.css. The left zone shows exactly one of
#nextBtn/#requestToolbar - host or follower, never both, since
only the host ever has a queued item to advance and only a
follower ever has someone else's playback to request against.
Center is a single ambient status line (see updateAmbientNotice in
Player.ts - never more than one message at a time). Right is the
fullscreen toggle, for everyone. Staying INSIDE .playerWrap - not
a sibling positioned relative to it - is what keeps all of this
visible and clickable once plein écran targets the whole wrap
instead of just the <video> (see Player.ts's fullscreen handling
and docs/architecture.md). -->
<div id="bottomBar" class="bottomBar">
<div class="bottomBarZone left">
<button id="nextBtn" class="nextBtn hide">
<svg width="12" height="10" viewBox="0 0 14 10" aria-hidden="true">
<path d="M1 1 L6 5 L1 9 Z" fill="#fff"></path>
<path d="M7 1 L12 5 L7 9 Z" fill="#fff"></path>
<rect x="12.4" y="0.5" width="1.6" height="9" fill="#fff"></rect>
</svg>
Épisode suivant
</button>
<div id="requestToolbar" class="requestToolbar hide">
<button id="requestPauseBtn" class="requestBtn">Demander une pause</button>
<button id="requestResumeBtn" class="requestBtn">Demander une reprise</button>
<button id="requestPauseBtn" class="requestBtn">
<svg width="9" height="9" viewBox="0 0 10 10" aria-hidden="true">
<rect x="1.5" y="1" width="2.4" height="8" fill="#fff"></rect>
<rect x="6.1" y="1" width="2.4" height="8" fill="#fff"></rect>
</svg>
Demander pause
</button>
<button id="requestResumeBtn" class="requestBtn">
<svg width="9" height="9" viewBox="0 0 10 10" aria-hidden="true">
<path d="M1.5 1 L8.5 5 L1.5 9 Z" fill="#fff"></path>
</svg>
Demander reprise
</button>
</div>
</div>
<div class="bottomBarZone center">
<span id="ambientNotice" class="ambientNotice hide"></span>
</div>
<div class="bottomBarZone right">
<button id="fullscreenBtn" class="fsBtn" title="Plein écran" aria-label="Plein écran"></button>
</div>
</div>
</div>

View file

@ -60,6 +60,20 @@ const PAUSE_ICON_SVG = `
<rect x="6.1" y="1" width="2.4" height="8" fill="#fff"></rect>
</svg>`;
// #fullscreenBtn's icon, swapped in place (updateFullscreenBtn) rather
// than kept as two separate elements - same "one small SVG string
// constant" pattern as PAUSE_ICON_SVG above, just two of them since this
// one's shape genuinely changes (outward vs inward corners), not just a
// class toggle.
const ENTER_FULLSCREEN_SVG = `
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>`;
const EXIT_FULLSCREEN_SVG = `
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>`;
/**
* Route `/party/:id` - dispatches on the party's mediaType, fetched here
* once. A video party still gets the full-page theater-mode takeover
@ -128,7 +142,9 @@ export function renderPartyPlayer(root: HTMLElement, params: RouteParams): Clean
function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPublicState): CleanupFn {
root.innerHTML = html;
const playerWrap = root.querySelector('.playerWrap') as HTMLElement;
const video = root.querySelector('#video') as HTMLVideoElement;
const castingScreen = root.querySelector('#castingScreen')!;
const loadingNotice = root.querySelector('#loadingNotice')!;
const errorBox = root.querySelector('#playerError')!;
const errorText = root.querySelector('#playerErrorText')!;
@ -137,7 +153,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
const presenceOverlay = root.querySelector('#presenceOverlay')!;
const requestBanner = root.querySelector('#requestBanner')!;
const nextBtn = root.querySelector('#nextBtn') as HTMLButtonElement;
const followerNotice = root.querySelector('#followerNotice')!;
const ambientNotice = root.querySelector('#ambientNotice')!;
const requestToolbar = root.querySelector('#requestToolbar')!;
const requestPauseBtn = root.querySelector('#requestPauseBtn') as HTMLButtonElement;
const requestResumeBtn = root.querySelector('#requestResumeBtn') as HTMLButtonElement;
@ -149,7 +165,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement;
const burnInHint = root.querySelector('#burnInHint')!;
const castBtn = root.querySelector('#castBtn') as HTMLButtonElement;
const castingNotice = root.querySelector('#castingNotice')!;
const fullscreenBtn = root.querySelector('#fullscreenBtn') as HTMLButtonElement;
qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => `<option value="${q.value}">${q.label}</option>`).join('');
@ -220,12 +236,34 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
if (!isCasting) return;
isCasting = false;
castBtn.setAttribute('aria-pressed', 'false');
castingNotice.classList.add('hide');
castingScreen.classList.add('hide');
activeDriver = localDriver(video);
updateAmbientNotice();
stopCast();
revokeCastToken(partyId).catch(() => {});
}
/**
* The bottom bar's center zone (see Player.html/Player.css) - never
* more than one message at a time. Casting takes priority over "L'hôte
* contrôle la lecture": it's the more useful thing to know in the
* moment (it explains why the local video area looks different), and
* showing both at once would just be two lines fighting a one-line
* slot. Called whenever either input changes - becomeHost, startCasting/
* stopCasting, and once at init.
*/
function updateAmbientNotice() {
if (isCasting) {
ambientNotice.textContent = 'Diffusion en cours sur le Chromecast';
ambientNotice.classList.remove('hide');
} else if (!isHost) {
ambientNotice.textContent = "L'hôte contrôle la lecture";
ambientNotice.classList.remove('hide');
} else {
ambientNotice.classList.add('hide');
}
}
// Used by both the cast button's own "stop casting" click and a session
// ending on its own (the receiver's remote, the TV losing power) -
// captures wherever the TV left off before tearing the cast driver
@ -261,7 +299,8 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
activeDriver = driver;
isCasting = true;
castBtn.setAttribute('aria-pressed', 'true');
castingNotice.classList.remove('hide');
castingScreen.classList.remove('hide');
updateAmbientNotice();
sendLocalState();
} catch (err) {
// The user cancelling the device picker rejects requestSession() the
@ -322,9 +361,9 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
isHost = true;
video.controls = true;
settingsWrap.classList.remove('hide');
followerNotice.classList.add('hide');
requestToolbar.classList.add('hide'); // the host acts directly - no requests to make once promoted mid-session
nextBtn.classList.toggle('hide', !hasNextQueued);
updateAmbientNotice();
video.addEventListener('play', sendLocalState);
video.addEventListener('pause', sendLocalState);
@ -598,6 +637,44 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
else startCasting();
});
function updateFullscreenBtn() {
const isFullscreen = document.fullscreenElement === playerWrap;
fullscreenBtn.innerHTML = isFullscreen ? EXIT_FULLSCREEN_SVG : ENTER_FULLSCREEN_SVG;
fullscreenBtn.title = isFullscreen ? 'Quitter le plein écran' : 'Plein écran';
}
/**
* Fullscreen always targets .playerWrap - the whole player, video AND
* overlay chrome - never the bare <video>. Native per-video fullscreen
* (the browser's own double-click gesture on the video, or the host's
* own native <video controls> fullscreen button - see becomeHost) only
* ever shows that element's own subtree: every sibling overlay (the
* top bar, presence, and critically the request toolbar) would vanish,
* leaving a follower with no way to ask the host for anything. This
* listener fires on ANY fullscreen change, not just ones #fullscreenBtn
* itself caused - if that's what just happened (fullscreenElement is
* the bare <video>, not .playerWrap), it immediately corrects course:
* exit, then re-enter on the wrap instead. See docs/architecture.md's
* "Lecteur vidéo" section for the full reasoning.
*/
function onFullscreenChange() {
if (document.fullscreenElement === video) {
document.exitFullscreen().then(() => playerWrap.requestFullscreen()).catch(() => {});
return;
}
updateFullscreenBtn();
}
document.addEventListener('fullscreenchange', onFullscreenChange);
fullscreenBtn.addEventListener('click', () => {
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
} else {
playerWrap.requestFullscreen().catch(() => {});
}
});
updateFullscreenBtn();
async function onItemChanged(msg: ItemChangedMsg) {
itemTitleEl.textContent = msg.itemName || '';
hasNextQueued = !!msg.hasNext;
@ -741,14 +818,13 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
showRequestBanner(msg.userId, msg.userName, 'une reprise');
}
// The toolbar itself - see .requestToolbar in Player.css for why this
// replaced the old "click your own avatar" affordance, and for why it
// sits in its own bottom-right corner rather than centered-bottom like
// .bottomOverlay next to it (that's also where a native <track>
// subtitle renders). Each button has its own client-side cooldown
// (mirroring the server's own independent per-request throttle in
// SocketGateway) purely so a rapid double-click doesn't even bother
// emitting the second request.
// The toolbar itself - see .requestToolbar in Player.css/Player.html
// for why this replaced the old "click your own avatar" affordance,
// and for why it now lives in the bottom bar's left zone instead of
// its own cornered overlay. Each button has its own client-side
// cooldown (mirroring the server's own independent per-request
// throttle in SocketGateway) purely so a rapid double-click doesn't
// even bother emitting the second request.
requestPauseBtn.addEventListener('click', () => {
const now = Date.now();
if (now < pauseRequestCooldownUntil || !conn) return;
@ -766,7 +842,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
async function init() {
itemTitleEl.textContent = party.itemName || '';
hasNextQueued = !!party.hasNext;
followerNotice.classList.toggle('hide', party.youAreHost);
updateAmbientNotice();
requestToolbar.classList.toggle('hide', party.youAreHost);
// Before loadTrackOptions: renderSubtitleOptions' host-only gating
@ -819,6 +895,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
if (requestBannerTimer) clearTimeout(requestBannerTimer);
if (hls) hls.destroy();
document.removeEventListener('click', onDocumentClick);
document.removeEventListener('fullscreenchange', onFullscreenChange);
unsubscribeCastAvailability();
unsubscribeCastSessionEnded();
// Deliberately NOT stopping an active cast here - leaving this page

View file

@ -2577,6 +2577,88 @@ to see the queue/card layout itself, so those were checked visually
against a mockup built from this app's own real theme tokens/typefaces
rather than a live instance.
## Reworking the player: a unified bottom bar, and a fullscreen that doesn't hide it
Asked for directly - a UI rework of the video player, "in both normal
and fullscreen mode", with one concrete requirement: a viewer in
fullscreen still needs to see the pause/resume request buttons. A
mockup was built first (this app's own theme tokens, not a generic
one) and iterated through a few rounds of feedback before any of this
landed - the mockup itself ended up covering three things, not the two
first asked for.
**The actual fullscreen bug, found while investigating the request.**
This app never had its own fullscreen button - the only way in was the
browser's native affordance on the bare `<video>` element itself
(double-click, or the host's own native `<video controls>` fullscreen
button - see `becomeHost`). Native per-element fullscreen only ever
renders that element's own subtree: every sibling overlay - the top
bar, presence avatars, and critically the request toolbar - is a
sibling of `<video>` in the DOM, so all of it disappeared the instant
fullscreen engaged. A follower in fullscreen had no way to reach the
host at all.
**The fix**: a new `#fullscreenBtn` that calls
`playerWrap.requestFullscreen()` - the whole player, video AND overlay
chrome - never `video.requestFullscreen()`. Since a native per-video
path is still reachable too (the same double-click/host-native-controls
gestures as before - nothing about those was removed, so the host keeps
native play/pause/seek/volume exactly as today), a document-level
`fullscreenchange` listener watches for `document.fullscreenElement ===
video` specifically and corrects it: exit, then immediately re-enter on
`playerWrap` instead. This runs regardless of which gesture triggered
it, so the overlay chrome is never actually lost for more than the
listener's own tick.
**The bottom bar rework.** What used to be three independent floating
pieces - a centered column (`.bottomOverlay`: the transient request
banner, a static "L'hôte contrôle la lecture" notice, a separate
Chromecast notice), a request toolbar cornered bottom-right, and an
"Épisode suivant" text link up in the top bar - are now one bar
(`#bottomBar`, three flex zones):
- **Left**: exactly one of `#nextBtn` (host, now a real accent-green
pill button with a skip-next icon, not a plain underlined link) or
`#requestToolbar` (follower, `#requestPauseBtn`/`#requestResumeBtn`,
each now labeled "Demander pause"/"Demander reprise" with a small
icon). The two are mutually exclusive by construction - only the host
ever has a queued item to advance, only a follower ever has someone
else's playback to request against - so there's never a conflict for
the same slot.
- **Center**: a single `#ambientNotice` line, replacing the two
separate static notices - `updateAmbientNotice()` shows "Diffusion en
cours sur le Chromecast" while casting (checked first - it's the more
useful thing to know in the moment), else "L'hôte contrôle la
lecture" for a follower, else nothing for a host who isn't casting.
- **Right**: `#fullscreenBtn`, for everyone, always.
The transient request banner stays independent, near the top rather
than folded into the persistent bar - it's a pop-in/fade-out toast
(`showRequestBanner`), and merging it into a bar that's otherwise always
present would have made it read as just another static line rather
than something that needs attention right now.
**A visible cue for casting.** `#castingScreen` now covers the video
area whenever `isCasting` is true - the local `<video>` is paused/torn
down while casting (see "Casting to a Chromecast" above), so leaving
its last frame sitting there with no explanation would just look
stuck. A plain icon + "Diffusion sur le Chromecast", not an attempt to
mirror the TV's own picture.
Verified: full `typecheck`/`build` on the client (no server changes at
all - this is pure client markup/CSS/TS). Checked in a real browser
against the actual compiled `app.css`/bundle (not just the mockup) -
injected the new markup with its real classes for the host view, the
follower view (with the toast), and the casting view, and confirmed
each renders as designed; caught and fixed one real sizing bug this
way (`.fsBtn` grew to 32px instead of its declared 30px - a flex item's
default `min-width: auto` was letting `button`'s own base 12px/16px
padding win over the smaller explicit width; fixed with an explicit
`padding: 0` on the icon-only button). **Not verified**: the Fullscreen
API itself needs a real user gesture and an actual browser fullscreen
transition, neither of which this sandbox can exercise - worth trying
for real once deployed, same as this project's other can't-verify-
without-the-real-thing items.
## Known limitations
- Playback always goes through `hls.js` against whatever Emby's