Replace native <video controls> with custom transport controls
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 7s

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>
This commit is contained in:
Nicolas 2026-09-15 11:30:50 +02:00
parent f5a941525f
commit a7944538bc
7 changed files with 359 additions and 53 deletions

View file

@ -311,6 +311,10 @@ Then put it behind your existing reverse proxy like everything else
real fullscreen toggle — fullscreen now targets the whole player,
not just the `<video>`, so the request buttons stay visible — see
docs/architecture.md
- [x] Retired the browser's native `<video controls>` (it was rendering
as its own always-on-top bar, duplicating the cast icon and
overlapping the new bottom bar in fullscreen) in favor of fully
custom play/pause, a seek bar, and mute — 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

@ -16,6 +16,10 @@
export interface PlaybackDriver {
currentTime: number;
readonly paused: boolean;
/** 0 until known (matches <video>'s own `duration` before metadata
* loads - NaN there, normalized to 0 here so a caller can use it
* directly as a divisor/percentage without its own NaN check). */
readonly duration: number;
play(): void;
pause(): void;
}
@ -27,6 +31,7 @@ export function localDriver(video: HTMLVideoElement): PlaybackDriver {
get currentTime() { return video.currentTime; },
set currentTime(seconds: number) { video.currentTime = seconds; },
get paused() { return video.paused; },
get duration() { return video.duration || 0; },
play() { video.play().catch(() => {}); },
pause() { video.pause(); }
};
@ -147,6 +152,7 @@ export async function startCast(contentUrl: string, title: string, onRemoteState
get currentTime() { return player.currentTime; },
set currentTime(seconds: number) { player.currentTime = seconds; controller.seek(); },
get paused() { return player.isPaused; },
get duration() { return player.duration || 0; },
play() { if (player.isPaused) controller.playOrPause(); },
pause() { if (!player.isPaused) controller.playOrPause(); }
};

View file

@ -101,6 +101,7 @@ interface CastContext {
interface RemotePlayer {
isPaused: boolean;
currentTime: number;
duration: number;
}
interface RemotePlayerController {

View file

@ -164,9 +164,14 @@
/* 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. */
"Épisode suivant" link in the top bar) AND the browser's native
<video controls> 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. Column layout (seek
bar row, then buttons row) so the two stack in normal flow instead of
two independently-guessed fixed offsets - same "stack, don't guess a
pixel offset" fix .topOverlay's own actionBar+presenceOverlay
grouping already needed (see its own comment). */
.bottomBar {
position: absolute;
left: 10px;
@ -174,19 +179,63 @@
bottom: 10px;
z-index: 6;
display: flex;
align-items: center;
gap: 10px;
flex-direction: column;
gap: 8px;
background: rgba(10, 8, 11, .8);
border: 1px solid rgba(255, 255, 255, .08);
border-radius: 12px;
padding: 8px 10px;
padding: 10px;
}
.bottomBarButtons { display: flex; align-items: center; gap: 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; }
/* Host: click/drag to seek (Player.ts). Follower: read-only, just a
position indicator - same "everyone sees the fill, only the host's
input actually does anything" split as the request buttons vs.
#ambientNotice above. The generous ::before hit-area (not just the
4px visual track) matters most for drag on a touch screen. */
.progressBar {
position: relative;
height: 4px;
border-radius: 3px;
background: rgba(255, 255, 255, .18);
cursor: pointer;
touch-action: none; /* this app's own pointer handlers drive the drag, not the browser's default touch scroll/zoom gestures */
}
.progressBar.readOnly { cursor: default; }
.progressBar::before {
content: '';
position: absolute;
top: -8px;
bottom: -8px;
left: 0;
right: 0;
}
.progressFill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 0%;
background: var(--accent);
border-radius: 3px;
pointer-events: none;
}
.timeLabel {
color: var(--muted);
font-size: 11.5px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* 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

View file

@ -1,5 +1,11 @@
<div class="playerWrap">
<video id="video" playsinline></video>
<!-- 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
@ -60,19 +66,28 @@
<!-- 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). -->
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>
@ -105,3 +120,4 @@
</div>
</div>
</div>
</div>

View file

@ -6,7 +6,7 @@ import { getParty, leaveParty, connectParty, issueCastToken, revokeCastToken } f
import type { PartyConnection } from '../../api/party';
import { navigate } from '../../router';
import type { CleanupFn, RouteParams } from '../../router';
import { colorForName, escapeHtml, initialsFor } from '../../tiles';
import { colorForName, escapeHtml, formatDuration, initialsFor } from '../../tiles';
import { LOADING_HINT } from '../../loading';
import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequestedMsg, PlaybackOptionsDto, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg, TextSubtitleChangedMsg } from '../../types';
import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar';
@ -74,6 +74,36 @@ const EXIT_FULLSCREEN_SVG = `
<path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>`;
// #playPauseBtn's icon, swapped in place (updatePlayPauseBtn) - the
// custom equivalent of the browser's own native <video controls> play/
// pause button, which this app never enables any more (see becomeHost's
// own comment for why: that native bar used to render as its own
// always-on-top layer fighting this one for the same space).
const PLAY_BTN_ICON_SVG = `
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<path d="M3 2 L14 8 L3 14 Z" fill="#fff"></path>
</svg>`;
const PAUSE_BTN_ICON_SVG = `
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<rect x="3" y="2" width="3.5" height="12" fill="#fff"></rect>
<rect x="9.5" y="2" width="3.5" height="12" fill="#fff"></rect>
</svg>`;
// #muteBtn's icon - local-only (video.muted directly, never synced -
// see updateMuteBtn's own comment), so unlike every other bottom-bar
// icon this one never reflects activeDriver/party state.
const UNMUTED_ICON_SVG = `
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<path d="M1 6h3l4-3.2v10.4l-4-3.2H1z" fill="#fff"></path>
<path d="M11 5.3a3 3 0 0 1 0 5.4" fill="none" stroke="#fff" stroke-width="1.3" stroke-linecap="round"></path>
</svg>`;
const MUTED_ICON_SVG = `
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<path d="M1 6h3l4-3.2v10.4l-4-3.2H1z" fill="#fff"></path>
<line x1="10.5" y1="5" x2="14.5" y2="9" stroke="#fff" stroke-width="1.3" stroke-linecap="round"></line>
<line x1="14.5" y1="5" x2="10.5" y2="9" stroke="#fff" stroke-width="1.3" stroke-linecap="round"></line>
</svg>`;
/**
* Route `/party/:id` - dispatches on the party's mediaType, fetched here
* once. A video party still gets the full-page theater-mode takeover
@ -166,6 +196,11 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
const burnInHint = root.querySelector('#burnInHint')!;
const castBtn = root.querySelector('#castBtn') as HTMLButtonElement;
const fullscreenBtn = root.querySelector('#fullscreenBtn') as HTMLButtonElement;
const progressBar = root.querySelector('#progressBar') as HTMLElement;
const progressFill = root.querySelector('#progressFill') as HTMLElement;
const playPauseBtn = root.querySelector('#playPauseBtn') as HTMLButtonElement;
const muteBtn = root.querySelector('#muteBtn') as HTMLButtonElement;
const timeLabel = root.querySelector('#timeLabel')!;
qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => `<option value="${q.value}">${q.label}</option>`).join('');
@ -238,6 +273,13 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
castBtn.setAttribute('aria-pressed', 'false');
castingScreen.classList.add('hide');
activeDriver = localDriver(video);
// Local muting is meaningful again now that the local <video> is
// what's actually producing sound - see updateMuteBtn's own comment
// for why it's hidden while casting instead of controlling the
// (silent) local element for nothing.
muteBtn.classList.remove('hide');
updatePlayPauseBtn();
updateProgressBar();
updateAmbientNotice();
stopCast();
revokeCastToken(partyId).catch(() => {});
@ -292,7 +334,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
const { token } = await issueCastToken(partyId);
const streamPath = burnInSubtitleIndex ? burnInStreamUrl(partyId) : partyStreamUrl(partyId);
const contentUrl = `${location.origin}${streamPath}?castToken=${encodeURIComponent(token)}`;
const driver = await startCast(contentUrl, itemTitleEl.textContent || 'Watch Party', sendLocalState);
const driver = await startCast(contentUrl, itemTitleEl.textContent || 'Watch Party', onDriverStateChanged);
if (hls) { hls.destroy(); hls = null; }
video.pause();
@ -300,8 +342,11 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
isCasting = true;
castBtn.setAttribute('aria-pressed', 'true');
castingScreen.classList.remove('hide');
// Nothing left for it to usefully control - the local <video> is
// paused/silent while the TV plays (see updateMuteBtn's own comment).
muteBtn.classList.add('hide');
updateAmbientNotice();
sendLocalState();
onDriverStateChanged();
} catch (err) {
// The user cancelling the device picker rejects requestSession() the
// same way a real failure does - neither is worth an alert() here,
@ -331,6 +376,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
}
if (msg.isPaused && !activeDriver.paused) activeDriver.pause();
if (!msg.isPaused && activeDriver.paused) activeDriver.play();
updateProgressBar(); // a follower's own read-only bar - see its own comment
setTimeout(() => { suppressLocalEvents = false; }, 250);
}
@ -345,6 +391,102 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
conn.sendPlaystate(activeDriver.paused, Math.round(activeDriver.currentTime * TICKS_PER_SECOND));
}
/** #playPauseBtn's own icon - the custom equivalent of the native
* <video controls> play/pause button (see becomeHost's comment for
* why that's never enabled any more). Host-only (the button itself is
* hidden for a follower), but harmless to call unconditionally - see
* onDriverStateChanged below. */
function updatePlayPauseBtn() {
playPauseBtn.innerHTML = activeDriver.paused ? PLAY_BTN_ICON_SVG : PAUSE_BTN_ICON_SVG;
}
/** #muteBtn - deliberately reads/writes `video.muted` directly, never
* activeDriver: muting is this device's own local presentation, not
* party state - there is nothing to sync, and no meaning to it while
* casting (the local <video> already produces no sound then - see
* startCasting/stopCasting, which hide this button entirely instead). */
function updateMuteBtn() {
muteBtn.innerHTML = video.muted ? MUTED_ICON_SVG : UNMUTED_ICON_SVG;
muteBtn.setAttribute('aria-pressed', String(video.muted));
}
/** The seek bar's fill + time label - reads activeDriver, so it
* already reflects whichever of local/cast is actually playing.
* Called continuously (progressTicker, below) so it keeps moving
* between discrete events, and once immediately after anything that
* changes position/duration outside that ticker's own cadence
* (seeking, a driver swap, a fresh applyRemoteState). */
function updateProgressBar() {
const duration = activeDriver.duration;
const current = activeDriver.currentTime;
progressFill.style.width = duration > 0 ? `${Math.min(100, (current / duration) * 100)}%` : '0%';
timeLabel.textContent = duration > 0 ? `${formatDuration(current)} / ${formatDuration(duration)}` : '';
}
/** Shared by every discrete play/pause/seek event this player reacts
* to - the local <video>'s own native events (becomeHost) and a cast
* session's RemotePlayerController events (startCasting's own
* onRemoteStateChanged callback) alike - so the button icon, the
* seek bar, and the party broadcast all stay in lockstep regardless
* of which driver or which gesture actually caused the change. */
function onDriverStateChanged() {
updatePlayPauseBtn();
updateProgressBar();
sendLocalState();
}
let isDraggingProgress = false;
function seekFromPointerEvent(e: PointerEvent) {
const duration = activeDriver.duration;
if (!duration) return;
const rect = progressBar.getBoundingClientRect();
const fraction = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
activeDriver.currentTime = fraction * duration;
onDriverStateChanged();
}
// Host-only (checked here, not just via the readOnly class - that one
// is purely a cursor/visual hint, see Player.css). Pointer events
// (not click/drag) so mouse and touch share one implementation, and
// setPointerCapture keeps the drag tracking even if the pointer leaves
// the bar's own bounds mid-gesture.
progressBar.addEventListener('pointerdown', (e) => {
if (!isHost) return;
isDraggingProgress = true;
progressBar.setPointerCapture(e.pointerId);
seekFromPointerEvent(e);
});
progressBar.addEventListener('pointermove', (e) => {
if (!isDraggingProgress) return;
seekFromPointerEvent(e);
});
progressBar.addEventListener('pointerup', () => { isDraggingProgress = false; });
progressBar.addEventListener('pointercancel', () => { isDraggingProgress = false; });
playPauseBtn.addEventListener('click', () => {
if (activeDriver.paused) activeDriver.play(); else activeDriver.pause();
});
muteBtn.addEventListener('click', () => {
video.muted = !video.muted;
updateMuteBtn();
});
// Ticks the seek bar/time label between discrete events - currentTime
// moves continuously while playing, with no event of its own for
// "still playing, time passed" the way play/pause/seeked have. Runs
// for everyone (host and follower alike - a follower's bar is
// read-only but still needs to visibly move), cheap no-op via the
// currentItemId guard whenever nothing's loaded at all.
let progressTicker: number | null = null;
progressTicker = window.setInterval(() => {
if (!currentItemId) return;
updateProgressBar();
}, 500);
updatePlayPauseBtn();
updateMuteBtn();
/**
* Promotes this client to host - called either at init (if we already
* were) or on a live "hostChanged" event (the previous host left and the
@ -359,17 +501,24 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
function becomeHost(notify: boolean) {
if (isHost) return;
isHost = true;
video.controls = true;
// Native <video controls> deliberately never enabled - see
// Player.html's own comment on #bottomBar: that native bar used to
// render as its own always-on-top layer, invisible in fullscreen
// before that was fixed and directly overlapping/duplicating this
// one (its own cast icon, its own fullscreen toggle) once it was.
// playPauseBtn/progressBar below are the custom replacement.
settingsWrap.classList.remove('hide');
requestToolbar.classList.add('hide'); // the host acts directly - no requests to make once promoted mid-session
nextBtn.classList.toggle('hide', !hasNextQueued);
playPauseBtn.classList.remove('hide');
progressBar.classList.remove('readOnly');
updateAmbientNotice();
video.addEventListener('play', sendLocalState);
video.addEventListener('pause', sendLocalState);
video.addEventListener('seeked', sendLocalState);
video.addEventListener('play', onDriverStateChanged);
video.addEventListener('pause', onDriverStateChanged);
video.addEventListener('seeked', onDriverStateChanged);
video.addEventListener('ended', () => { if (hasNextQueued) conn?.next(); });
if (!heartbeat) heartbeat = window.setInterval(sendLocalState, 3000);
if (!heartbeat) heartbeat = window.setInterval(onDriverStateChanged, 3000);
// Burn-in (image) subtitle options are host-only (see
// renderSubtitleOptions) - becoming host mid-session must unlock
@ -515,6 +664,10 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
currentItemId = itemId;
clearError();
loadingNotice.classList.remove('hide');
// Reset immediately rather than waiting for the next progressTicker
// tick (up to 500ms away) - the old item's leftover position/duration
// means nothing for whatever's loading now.
updateProgressBar();
if (hls) {
hls.destroy();
@ -844,6 +997,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
hasNextQueued = !!party.hasNext;
updateAmbientNotice();
requestToolbar.classList.toggle('hide', party.youAreHost);
progressBar.classList.toggle('readOnly', !party.youAreHost);
// Before loadTrackOptions: renderSubtitleOptions' host-only gating
// for burn-in options reads `isHost`, which becomeHost is what sets -
@ -892,6 +1046,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
// actual leak: previously, only the "Quitter" button ran any of this.
return function cleanup() {
if (heartbeat) clearInterval(heartbeat);
if (progressTicker) clearInterval(progressTicker);
if (requestBannerTimer) clearTimeout(requestBannerTimer);
if (hls) hls.destroy();
document.removeEventListener('click', onDocumentClick);

View file

@ -2659,6 +2659,81 @@ 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.
## The player's bottom bar was really two bars: replacing native `<video controls>`
A direct follow-up to the player rework above, reported right after
trying it for real: "two z-index layers, some elements duplicated,
only in fullscreen." The actual cause was never a bug in the new
bottom bar itself - it's that `video.controls = true` (set for the host
since the very first version of this player) has always rendered the
browser's own native control bar, a separate piece of UI that lives in
its own always-on-top compositing layer, entirely independent of this
app's DOM/CSS. That was never visually obvious before: fullscreen used
to show *only* the video (the bug the previous section fixed), so the
native bar was the only thing there was to look at. Once fullscreen
correctly started showing this app's own overlays too, the native bar
and the custom one both became visible at once - and specifically
*duplicated*, not just crowded: Chrome adds its own cast icon to a
`<video>`'s native controls automatically once it detects a Cast-
capable device, entirely separate from `#castBtn`'s own integration
(cast.ts) - "some elements duplicated, others not" was that icon
sitting right next to this app's own, while native play/pause/seek/
volume simply had no custom equivalent yet to *look* duplicated
against.
Two fixes, asked for as one:
- **`disableremoteplayback`** on `<video>` - the standard attribute
for exactly this situation (a page with its own Cast integration
telling the browser not to add its own). Should have been there from
the start of "Casting to a Chromecast" above; this is where it was
actually noticed missing.
- **Native `<video controls>` retired entirely**, replaced with a
custom transport bar - not just for fullscreen, everywhere, so there
is only ever one control surface instead of two that happen to
overlap less in the normal (windowed) case. `PlaybackDriver` (cast.ts)
gained `duration` alongside `currentTime`/`paused` - the one property
the existing abstraction didn't yet expose, needed for a seek bar to
compute a fraction or a target time regardless of which driver is
currently active.
**What `#bottomBar` gained**: a seek bar (`#progressBar`/`#progressFill`)
on its own row above the existing button row - column layout, so the
two stack in normal flow rather than two independently-guessed fixed
offsets (the same reasoning `.topOverlay`'s actionBar+presenceOverlay
grouping already used). Host: pointer events (not click/drag, so mouse
and touch share one implementation) seek by fraction of
`activeDriver.duration`; a follower's bar is the same element, just
read-only (`.readOnly` - a cursor/visual hint only, the actual pointerdown
handler checks `isHost` itself regardless). A `progressTicker`
(500ms) keeps the fill/time label moving between discrete events, since
`currentTime` changes continuously while playing with no event of its
own for that. `#playPauseBtn` (host-only, sharing the request buttons'
left zone with `#nextBtn` - never more than one of the three visible at
once) and `#muteBtn` (everyone, local-only - `video.muted` directly,
never synced, since unlike play/pause this has no party-wide meaning at
all - hidden while casting instead of controlling a local `<video>`
that's already silent then) round out the replacement.
One function, `onDriverStateChanged`, threads through every discrete
event that used to just call `sendLocalState` directly - the local
`<video>`'s own native play/pause/seeked events, and a cast session's
`RemotePlayerController` events (`startCast`'s own `onRemoteStateChanged`
callback) - so the button icon, the seek bar, and the party broadcast
stay in lockstep regardless of which driver or which gesture caused
the change, the same discipline every other party-wide state in this
app already follows.
Verified: full `typecheck`/`build` on the client. Checked in a real
browser against the compiled bundle - injected the new bottom bar
markup for both a "playing, unmuted" and a "paused, muted" state and
confirmed the icons/fill/time label/readOnly cursor all render as
designed. **Not verified**: an actual drag-to-seek gesture and real
play/pause/volume behavior against a real `<video>` with a real source -
this sandbox has no reachable Emby stream to attach one to, so the
event wiring is confirmed by reading, not by dragging a real seek bar.
Worth exercising for real once deployed.
## Known limitations
- Playback always goes through `hls.js` against whatever Emby's