watchparty/client/src/views/player/Player.html
Nicolas a7944538bc
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 7s
Replace native <video controls> with custom transport controls
Reported right after trying the player rework: the action bar looked
like two z-index layers with some elements duplicated, only in
fullscreen. Root cause: video.controls = true (set for the host since
this player's first version) has always rendered the browser's own
native control bar - a separate piece of UI in its own always-on-top
compositing layer, independent of this app's DOM/CSS. That was never
visible before, since fullscreen used to show only the video; once it
correctly started showing this app's own overlays too, the native bar
and the custom one both became visible, and specifically duplicated -
Chrome adds its own cast icon to a <video>'s native controls once it
detects a Cast-capable device, entirely separate from #castBtn's own
integration.

Two fixes:
- disableremoteplayback on <video> - the standard attribute telling
  the browser not to add its own cast icon, since this app has its own.
  Should have shipped with the original Chromecast feature.
- Native <video controls> retired entirely, everywhere (not just
  fullscreen), replaced with a custom transport bar - asked for
  directly rather than just suppressing the symptom in fullscreen.

PlaybackDriver (cast.ts) gained `duration` alongside currentTime/paused -
the one thing a seek bar needs that the existing local/cast abstraction
didn't yet expose. #bottomBar gained a seek row (progressBar/
progressFill) above the existing button row - host drags/clicks to seek
by fraction of activeDriver.duration (pointer events, mouse and touch
share one path); a follower's bar is the same element, read-only. A
500ms ticker keeps the fill/time label moving between discrete events.
#playPauseBtn (host-only) and #muteBtn (everyone, local-only - never
synced, hidden while casting since the local <video> is silent then)
round out the replacement. One function, onDriverStateChanged, now
threads through every discrete play/pause/seek event (the local
<video>'s native events, and a cast session's RemotePlayerController
events) so the button icon, the seek bar, and the party broadcast stay
in lockstep regardless of which driver or gesture caused the change.

Verified: full typecheck/build. Checked in a real browser against the
compiled bundle - injected the new markup for both a playing/unmuted
and a paused/muted state, confirmed icons/fill/time label/readOnly
cursor all render as designed. Not verified: a real drag-to-seek
gesture or real playback - no reachable Emby stream in this sandbox to
attach a real <video> source to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 11:30:50 +02:00

123 lines
6.5 KiB
HTML

<div class="playerWrap">
<!-- disableremoteplayback: without it, Chrome adds its OWN native cast
icon to this element's native controls (see becomeHost in
Player.ts) the moment it detects a Cast-capable device on the
network - entirely separate from, and visually duplicating,
#castBtn's own custom integration (cast.ts). Standard attribute
for exactly this - see docs/architecture.md. -->
<video id="video" playsinline disableremoteplayback></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>
<button id="playerErrorRetry" class="linklike">Réessayer</button>
</div>
<div class="topOverlay">
<div class="actionBar">
<span id="itemTitle" class="itemTitle"></span>
<span id="members" class="members"></span>
<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">
<rect x="1" y="4" width="22" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2"></rect>
<path d="M4 15a5 5 0 0 1 5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
<path d="M4 11a9 9 0 0 1 9 9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
<circle cx="4" cy="20" r="1.5" fill="currentColor"></circle>
</svg>
</button>
<div id="settingsWrap" class="settingsWrap hide">
<button id="settingsBtn" class="iconBtn" title="Paramètres de lecture" aria-label="Paramètres de lecture">&#9881;</button>
<div id="settingsPanel" class="settingsPanel hide">
<label>Langue <select id="audioSelect"></select></label>
<label>Sous-titres <select id="subtitleSelect"><option value="">Aucun</option></select></label>
<label>Qualité <select id="qualitySelect"></select></label>
<p id="burnInHint" class="hint burnInHint hide">Flux incrusté actif pour toute la party - langue et qualité fixées tant qu'il l'est.</p>
</div>
</div>
<button id="leaveBtn" class="linklike">Quitter</button>
</div>
<div id="presenceOverlay" class="presenceOverlay"></div>
</div>
<!-- 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>
<!-- 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) AND the browser's own native <video controls> (see
becomeHost in Player.ts - never enabled any more, precisely
because that native bar used to render as its own always-on-top
layer fighting this one for the same space, especially visible
once plein écran started showing this bar too - see
docs/architecture.md). A seek bar on top, buttons below - 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>. -->
<div id="bottomBar" class="bottomBar">
<!-- Host: click/drag to seek (see Player.ts's pointerdown/move/up
handlers). Follower: purely a read-only position indicator,
ticked from activeDriver just like the fill. -->
<div id="progressBar" class="progressBar">
<div id="progressFill" class="progressFill"></div>
</div>
<div class="bottomBarButtons">
<div class="bottomBarZone left">
<button id="playPauseBtn" class="iconBtn hide" title="Lecture / pause" aria-label="Lecture / pause"></button>
<button id="muteBtn" class="iconBtn" title="Muet" aria-label="Muet"></button>
<span id="timeLabel" class="timeLabel"></span>
<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>