Rework the player's bottom bar and add a real fullscreen toggle
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:
parent
5a1bc8d932
commit
f5a941525f
5 changed files with 360 additions and 89 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 →</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
|
|
||||||
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>
|
<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">
|
<div id="requestToolbar" class="requestToolbar hide">
|
||||||
<button id="requestPauseBtn" class="requestBtn">Demander une pause</button>
|
<button id="requestPauseBtn" class="requestBtn">
|
||||||
<button id="requestResumeBtn" class="requestBtn">Demander une reprise</button>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue