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 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 never has its own image — now falls back to its album's) — see
docs/architecture.md 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 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 - [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
confirmed here — no real device/session) confirmed here — no real device/session)

View file

@ -141,33 +141,20 @@
.playerError p { color: var(--danger); font-size: 14px; margin: 0 0 10px; } .playerError p { color: var(--danger); font-size: 14px; margin: 0 0 10px; }
/* One column, bottom-anchored, gap between whichever children are /* Transient toast (see showRequestBanner in Player.ts) - positioned
actually visible - same "stack in normal flow instead of guessing a independently near the top so it never fights the persistent
fixed pixel offset per absolutely-positioned block" fix .topOverlay's .bottomBar below for space, and reads as a pop-in/fade-out
own actionBar+presenceOverlay grouping already needed (see its own notification rather than a fourth thing bolted onto a fixed layout.
comment, and docs/architecture.md for the exact bug this avoided: a Centered under .topOverlay's usual (single-line) height - see
<p>'s default margin silently throwing off a hand-picked bottom offset Player.html's own comment. */
on the element above it - not repeatable once nothing is positioned .requestBanner {
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 {
position: absolute; position: absolute;
bottom: 20px; top: 64px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
display: flex; z-index: 7;
flex-direction: column;
align-items: center;
gap: 10px;
z-index: 6;
max-width: calc(100% - 32px); max-width: calc(100% - 32px);
} text-align: center;
.requestBanner {
background: rgba(0, 0, 0, .75); background: rgba(0, 0, 0, .75);
color: white; color: white;
padding: 8px 16px; padding: 8px 16px;
@ -175,50 +162,124 @@
font-size: 13px; font-size: 13px;
} }
.followerNotice { /* Replaces what used to be three separate floating pieces (a centered
margin: 0; banner/notice column, a request toolbar cornered bottom-right, an
background: rgba(0,0,0,.6); "Épisode suivant" link in the top bar) with one persistent bar - see
padding: 8px 16px; Player.html's own comment on why staying inside .playerWrap (not a
border-radius: 20px; sibling positioned relative to it) also matters for plein écran. */
font-size: 13px; .bottomBar {
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 {
position: absolute; position: absolute;
right: 16px; left: 10px;
bottom: 20px; right: 10px;
bottom: 10px;
z-index: 6; z-index: 6;
display: flex; display: flex;
flex-direction: column; align-items: center;
align-items: flex-end; 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; gap: 8px;
max-width: calc(50% - 16px);
} }
.requestBtn { .requestBtn {
background: rgba(0, 0, 0, .6); display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, .1);
color: white; color: white;
padding: 8px 14px; padding: 7px 12px;
border-radius: 20px; border-radius: 20px;
font-size: 12.5px; font-size: 12px;
white-space: nowrap; /* keeps the pill shape even in .requestToolbar's own narrow corner on a phone-width screen */ 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) /* Shown to EVERY member (including the host and the requester themselves)
on the matching person's circle while their pause/resume request is on the matching person's circle while their pause/resume request is

View file

@ -1,6 +1,20 @@
<div class="playerWrap"> <div class="playerWrap">
<video id="video" playsinline></video> <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> <p id="loadingNotice" class="loadingNotice"><span class="spinner"></span>Chargement du flux...</p>
<div id="playerError" class="playerError hide"> <div id="playerError" class="playerError hide">
<p id="playerErrorText"></p> <p id="playerErrorText"></p>
@ -11,7 +25,6 @@
<div class="actionBar"> <div class="actionBar">
<span id="itemTitle" class="itemTitle"></span> <span id="itemTitle" class="itemTitle"></span>
<span id="members" class="members"></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"> <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"> <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
@ -38,24 +51,57 @@
<div id="presenceOverlay" class="presenceOverlay"></div> <div id="presenceOverlay" class="presenceOverlay"></div>
</div> </div>
<!-- Stacked as one column (same reasoning as .topOverlay's own <!-- Transient toast (see showRequestBanner in Player.ts) - positioned
actionBar+presenceOverlay grouping - see Player.css) so the independently of the bottom bar below, near the top, so it never
transient banner and the passive notice always sit in the right fights that persistent bar for space and reads as a pop-in/fade-out
order relative to each other regardless of which is visible at a notification rather than a fourth thing bolted onto a fixed layout. -->
given moment, instead of each guessing a fixed pixel offset from <div id="requestBanner" class="requestBanner hide"></div>
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">
<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>
<div id="requestToolbar" class="requestToolbar hide"> <!-- One bar, bottom-anchored, replacing what used to be three separate
<button id="requestPauseBtn" class="requestBtn">Demander une pause</button> floating pieces (a centered banner/notice column, a request
<button id="requestResumeBtn" class="requestBtn">Demander une reprise</button> 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">
<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>
</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> <rect x="6.1" y="1" width="2.4" height="8" fill="#fff"></rect>
</svg>`; </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 * Route `/party/:id` - dispatches on the party's mediaType, fetched here
* once. A video party still gets the full-page theater-mode takeover * 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 { function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPublicState): CleanupFn {
root.innerHTML = html; root.innerHTML = html;
const playerWrap = root.querySelector('.playerWrap') as HTMLElement;
const video = root.querySelector('#video') as HTMLVideoElement; const video = root.querySelector('#video') as HTMLVideoElement;
const castingScreen = root.querySelector('#castingScreen')!;
const loadingNotice = root.querySelector('#loadingNotice')!; const loadingNotice = root.querySelector('#loadingNotice')!;
const errorBox = root.querySelector('#playerError')!; const errorBox = root.querySelector('#playerError')!;
const errorText = root.querySelector('#playerErrorText')!; const errorText = root.querySelector('#playerErrorText')!;
@ -137,7 +153,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
const presenceOverlay = root.querySelector('#presenceOverlay')!; const presenceOverlay = root.querySelector('#presenceOverlay')!;
const requestBanner = root.querySelector('#requestBanner')!; const requestBanner = root.querySelector('#requestBanner')!;
const nextBtn = root.querySelector('#nextBtn') as HTMLButtonElement; const nextBtn = root.querySelector('#nextBtn') as HTMLButtonElement;
const followerNotice = root.querySelector('#followerNotice')!; const ambientNotice = root.querySelector('#ambientNotice')!;
const requestToolbar = root.querySelector('#requestToolbar')!; const requestToolbar = root.querySelector('#requestToolbar')!;
const requestPauseBtn = root.querySelector('#requestPauseBtn') as HTMLButtonElement; const requestPauseBtn = root.querySelector('#requestPauseBtn') as HTMLButtonElement;
const requestResumeBtn = root.querySelector('#requestResumeBtn') 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 qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement;
const burnInHint = root.querySelector('#burnInHint')!; const burnInHint = root.querySelector('#burnInHint')!;
const castBtn = root.querySelector('#castBtn') as HTMLButtonElement; 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(''); 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; if (!isCasting) return;
isCasting = false; isCasting = false;
castBtn.setAttribute('aria-pressed', 'false'); castBtn.setAttribute('aria-pressed', 'false');
castingNotice.classList.add('hide'); castingScreen.classList.add('hide');
activeDriver = localDriver(video); activeDriver = localDriver(video);
updateAmbientNotice();
stopCast(); stopCast();
revokeCastToken(partyId).catch(() => {}); 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 // 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) - // ending on its own (the receiver's remote, the TV losing power) -
// captures wherever the TV left off before tearing the cast driver // 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; activeDriver = driver;
isCasting = true; isCasting = true;
castBtn.setAttribute('aria-pressed', 'true'); castBtn.setAttribute('aria-pressed', 'true');
castingNotice.classList.remove('hide'); castingScreen.classList.remove('hide');
updateAmbientNotice();
sendLocalState(); sendLocalState();
} catch (err) { } catch (err) {
// The user cancelling the device picker rejects requestSession() the // The user cancelling the device picker rejects requestSession() the
@ -322,9 +361,9 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
isHost = true; isHost = true;
video.controls = true; video.controls = true;
settingsWrap.classList.remove('hide'); settingsWrap.classList.remove('hide');
followerNotice.classList.add('hide');
requestToolbar.classList.add('hide'); // the host acts directly - no requests to make once promoted mid-session requestToolbar.classList.add('hide'); // the host acts directly - no requests to make once promoted mid-session
nextBtn.classList.toggle('hide', !hasNextQueued); nextBtn.classList.toggle('hide', !hasNextQueued);
updateAmbientNotice();
video.addEventListener('play', sendLocalState); video.addEventListener('play', sendLocalState);
video.addEventListener('pause', sendLocalState); video.addEventListener('pause', sendLocalState);
@ -598,6 +637,44 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
else startCasting(); 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) { async function onItemChanged(msg: ItemChangedMsg) {
itemTitleEl.textContent = msg.itemName || ''; itemTitleEl.textContent = msg.itemName || '';
hasNextQueued = !!msg.hasNext; hasNextQueued = !!msg.hasNext;
@ -741,14 +818,13 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
showRequestBanner(msg.userId, msg.userName, 'une reprise'); showRequestBanner(msg.userId, msg.userName, 'une reprise');
} }
// The toolbar itself - see .requestToolbar in Player.css for why this // The toolbar itself - see .requestToolbar in Player.css/Player.html
// replaced the old "click your own avatar" affordance, and for why it // for why this replaced the old "click your own avatar" affordance,
// sits in its own bottom-right corner rather than centered-bottom like // and for why it now lives in the bottom bar's left zone instead of
// .bottomOverlay next to it (that's also where a native <track> // its own cornered overlay. Each button has its own client-side
// subtitle renders). Each button has its own client-side cooldown // cooldown (mirroring the server's own independent per-request
// (mirroring the server's own independent per-request throttle in // throttle in SocketGateway) purely so a rapid double-click doesn't
// SocketGateway) purely so a rapid double-click doesn't even bother // even bother emitting the second request.
// emitting the second request.
requestPauseBtn.addEventListener('click', () => { requestPauseBtn.addEventListener('click', () => {
const now = Date.now(); const now = Date.now();
if (now < pauseRequestCooldownUntil || !conn) return; if (now < pauseRequestCooldownUntil || !conn) return;
@ -766,7 +842,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
async function init() { async function init() {
itemTitleEl.textContent = party.itemName || ''; itemTitleEl.textContent = party.itemName || '';
hasNextQueued = !!party.hasNext; hasNextQueued = !!party.hasNext;
followerNotice.classList.toggle('hide', party.youAreHost); updateAmbientNotice();
requestToolbar.classList.toggle('hide', party.youAreHost); requestToolbar.classList.toggle('hide', party.youAreHost);
// Before loadTrackOptions: renderSubtitleOptions' host-only gating // Before loadTrackOptions: renderSubtitleOptions' host-only gating
@ -819,6 +895,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
if (requestBannerTimer) clearTimeout(requestBannerTimer); if (requestBannerTimer) clearTimeout(requestBannerTimer);
if (hls) hls.destroy(); if (hls) hls.destroy();
document.removeEventListener('click', onDocumentClick); document.removeEventListener('click', onDocumentClick);
document.removeEventListener('fullscreenchange', onFullscreenChange);
unsubscribeCastAvailability(); unsubscribeCastAvailability();
unsubscribeCastSessionEnded(); unsubscribeCastSessionEnded();
// Deliberately NOT stopping an active cast here - leaving this page // 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 against a mockup built from this app's own real theme tokens/typefaces
rather than a live instance. 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 ## Known limitations
- Playback always goes through `hls.js` against whatever Emby's - Playback always goes through `hls.js` against whatever Emby's