Auto-hide the bottom bar down to just its seek line
All checks were successful
Publier l'image Docker / test (push) Successful in 20s
Publier l'image Docker / publish (push) Successful in 6s

Reported right after the native-controls replacement shipped: the
bottom bar, now always visible, sits exactly where a native <track>
subtitle renders and was masking it. Same fix every real video player
already uses: collapse to a minimal state after a stretch of no
activity, expand again on the slightest interaction.

#bottomBar gets a .minimal class (3s of no activity) that transitions
away its padding/background/border and the buttons row's height/
opacity - #progressBar itself (and its own generous invisible seek
hit-area) is untouched, so a thin line is always there, just without
the box around it once minimal. Expands on any pointer activity
anywhere in the player, or while the pointer sits on the bar itself
(hovering pauses the countdown entirely rather than racing it). Stays
expanded outright while genuinely paused, and every discrete playback
event re-arms the countdown too - not just raw pointer movement - so
resuming from pause restarts it instead of leaving the bar stuck open.
Applies to host and follower alike.

Verified: full typecheck/build. Confirmed in a real browser against
the compiled bundle, side by side with an actual subtitle-shaped
overlay: the expanded bar visibly overlaps it, the minimal state
leaves it fully readable. Not driven through a real wall-clock wait
for the timer itself - the class-toggling/transition logic was
checked directly instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-15 11:39:32 +02:00
parent a7944538bc
commit d3e2811bd0
4 changed files with 128 additions and 3 deletions

View file

@ -309,7 +309,9 @@ Then put it behind your existing reverse proxy like everything else
- [x] Reworked the video player's bottom bar (episode/request actions/ - [x] Reworked the video player's bottom bar (episode/request actions/
fullscreen, one bar instead of three floating pieces) and added a fullscreen, one bar instead of three floating pieces) and added a
real fullscreen toggle — fullscreen now targets the whole player, real fullscreen toggle — fullscreen now targets the whole player,
not just the `<video>`, so the request buttons stay visible — see not just the `<video>`, so the request buttons stay visible; the
bar now also auto-hides down to just its seek line after a
stretch of no activity, so it stops masking subtitles — see
docs/architecture.md docs/architecture.md
- [x] Retired the browser's native `<video controls>` (it was rendering - [x] Retired the browser's native `<video controls>` (it was rendering
as its own always-on-top bar, duplicating the cast icon and as its own always-on-top bar, duplicating the cast icon and

View file

@ -180,14 +180,43 @@
z-index: 6; z-index: 6;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px;
background: rgba(10, 8, 11, .8); background: rgba(10, 8, 11, .8);
border: 1px solid rgba(255, 255, 255, .08); border: 1px solid rgba(255, 255, 255, .08);
border-radius: 12px; border-radius: 12px;
padding: 10px; padding: 10px;
transition: padding .25s ease, background-color .25s ease, border-color .25s ease;
} }
.bottomBarButtons { display: flex; align-items: center; gap: 10px; } /* Collapsed down to just the seek bar after a stretch of no activity
(see Player.ts's scheduleControlsHide) - asked for directly: the
full bar, always visible, sits right where a native <track> subtitle
renders (applySubtitleTrack) and was masking it. Only the padding/
background/border and the buttons row transition - #progressBar
itself (and its own generous invisible hit-area, see below) stays
exactly where and how big it already was either way. */
.bottomBar.minimal {
padding: 0;
background: transparent;
border-color: transparent;
}
.bottomBarButtons {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px; /* not .bottomBar's own gap - so .minimal can collapse just this row's own spacing, not the seek bar's position */
max-height: 40px;
opacity: 1;
overflow: hidden;
transition: max-height .25s ease, opacity .2s ease, margin-top .25s ease;
}
.bottomBar.minimal .bottomBarButtons {
max-height: 0;
opacity: 0;
margin-top: 0;
pointer-events: none; /* never clickable while invisible - see Player.ts's own comment on why this needs no extra guarding at the click handlers themselves */
}
.bottomBarZone { display: flex; align-items: center; gap: 8px; } .bottomBarZone { display: flex; align-items: center; gap: 8px; }
.bottomBarZone.left { flex-shrink: 0; } .bottomBarZone.left { flex-shrink: 0; }
.bottomBarZone.center { flex: 1; justify-content: center; min-width: 0; } .bottomBarZone.center { flex: 1; justify-content: center; min-width: 0; }

View file

@ -17,6 +17,7 @@ const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
const DRIFT_TOLERANCE_SECONDS = 1.5; const DRIFT_TOLERANCE_SECONDS = 1.5;
const REQUEST_COOLDOWN_MS = 5000; // matches the server's own throttle in SocketGateway - shared by both request types, each throttled independently const REQUEST_COOLDOWN_MS = 5000; // matches the server's own throttle in SocketGateway - shared by both request types, each throttled independently
const REQUEST_HIGHLIGHT_MS = 4000; // how long the banner + the orange badge stay up const REQUEST_HIGHLIGHT_MS = 4000; // how long the banner + the orange badge stay up
const CONTROLS_HIDE_DELAY_MS = 3000; // how long #bottomBar stays fully expanded after the last bit of activity, before collapsing to just its seek bar
// hls.js's own defaults (10s manifest/level/frag load timeouts) assume a // hls.js's own defaults (10s manifest/level/frag load timeouts) assume a
// file server or a source already being direct-played/remuxed - too tight // file server or a source already being direct-played/remuxed - too tight
@ -196,6 +197,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
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 fullscreenBtn = root.querySelector('#fullscreenBtn') as HTMLButtonElement; const fullscreenBtn = root.querySelector('#fullscreenBtn') as HTMLButtonElement;
const bottomBar = root.querySelector('#bottomBar') as HTMLElement;
const progressBar = root.querySelector('#progressBar') as HTMLElement; const progressBar = root.querySelector('#progressBar') as HTMLElement;
const progressFill = root.querySelector('#progressFill') as HTMLElement; const progressFill = root.querySelector('#progressFill') as HTMLElement;
const playPauseBtn = root.querySelector('#playPauseBtn') as HTMLButtonElement; const playPauseBtn = root.querySelector('#playPauseBtn') as HTMLButtonElement;
@ -231,6 +233,8 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
let pauseRequestCooldownUntil = 0; let pauseRequestCooldownUntil = 0;
let resumeRequestCooldownUntil = 0; let resumeRequestCooldownUntil = 0;
let requestBannerTimer: number | null = null; let requestBannerTimer: number | null = null;
let controlsHideTimer: number | null = null;
let isPointerOverBottomBar = false;
// The party's shared self-hosted-ffmpeg burn-in choice (see // The party's shared self-hosted-ffmpeg burn-in choice (see
// TranscodeService) - null means "no burn-in, everyone streams their // TranscodeService) - null means "no burn-in, everyone streams their
// own normal per-viewer Emby-proxied copy" - initialized from the // own normal per-viewer Emby-proxied copy" - initialized from the
@ -433,6 +437,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
updatePlayPauseBtn(); updatePlayPauseBtn();
updateProgressBar(); updateProgressBar();
sendLocalState(); sendLocalState();
scheduleControlsHide(); // e.g. resuming from pause must re-arm the countdown, not leave the bar stuck open forever
} }
let isDraggingProgress = false; let isDraggingProgress = false;
@ -487,6 +492,55 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
updatePlayPauseBtn(); updatePlayPauseBtn();
updateMuteBtn(); updateMuteBtn();
/**
* Auto-hides #bottomBar down to just its seek bar (see .bottomBar
* .minimal in Player.css) after a stretch of no activity - asked for
* directly: the full bar, always visible, sits right where a native
* <track> subtitle renders (see applySubtitleTrack) and was masking
* it. Applies to everyone, host or follower - the bar exists (and
* would mask subtitles) for both. Stays expanded while genuinely
* paused (nothing playing to hide from) or while the pointer is
* sitting on the bar itself, and re-arms on every discrete playback
* event too (onDriverStateChanged above), not just raw pointer
* activity - resuming from pause has to restart the countdown, not
* leave the bar stuck open forever.
*/
function clearControlsHideTimer() {
if (controlsHideTimer) {
clearTimeout(controlsHideTimer);
controlsHideTimer = null;
}
}
function scheduleControlsHide() {
clearControlsHideTimer();
controlsHideTimer = window.setTimeout(() => {
if (activeDriver.paused || isPointerOverBottomBar) return;
bottomBar.classList.add('minimal');
}, CONTROLS_HIDE_DELAY_MS);
}
function showControls() {
bottomBar.classList.remove('minimal');
scheduleControlsHide();
}
function onBottomBarPointerEnter() {
isPointerOverBottomBar = true;
showControls();
}
function onBottomBarPointerLeave() {
isPointerOverBottomBar = false;
scheduleControlsHide();
}
playerWrap.addEventListener('pointermove', showControls);
playerWrap.addEventListener('pointerdown', showControls);
bottomBar.addEventListener('mouseenter', onBottomBarPointerEnter);
bottomBar.addEventListener('mouseleave', onBottomBarPointerLeave);
scheduleControlsHide();
/** /**
* Promotes this client to host - called either at init (if we already * 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 * were) or on a live "hostChanged" event (the previous host left and the
@ -1048,9 +1102,14 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
if (heartbeat) clearInterval(heartbeat); if (heartbeat) clearInterval(heartbeat);
if (progressTicker) clearInterval(progressTicker); if (progressTicker) clearInterval(progressTicker);
if (requestBannerTimer) clearTimeout(requestBannerTimer); if (requestBannerTimer) clearTimeout(requestBannerTimer);
clearControlsHideTimer();
if (hls) hls.destroy(); if (hls) hls.destroy();
document.removeEventListener('click', onDocumentClick); document.removeEventListener('click', onDocumentClick);
document.removeEventListener('fullscreenchange', onFullscreenChange); document.removeEventListener('fullscreenchange', onFullscreenChange);
playerWrap.removeEventListener('pointermove', showControls);
playerWrap.removeEventListener('pointerdown', showControls);
bottomBar.removeEventListener('mouseenter', onBottomBarPointerEnter);
bottomBar.removeEventListener('mouseleave', onBottomBarPointerLeave);
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

@ -2734,6 +2734,41 @@ 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. event wiring is confirmed by reading, not by dragging a real seek bar.
Worth exercising for real once deployed. Worth exercising for real once deployed.
## The bottom bar auto-hides down to just the seek bar
Reported right after the previous section shipped: the bottom bar,
now always visible, sits exactly where a native `<track>` subtitle
renders (`applySubtitleTrack`) - and was masking it. The standard fix
every real video player already uses: collapse to a minimal state
after a stretch of no activity, expand again on the slightest
interaction.
`#bottomBar` gets a `.minimal` class (`scheduleControlsHide`,
`CONTROLS_HIDE_DELAY_MS` = 3s of no activity) that transitions away its
padding/background/border and the buttons row's height/opacity -
`#progressBar` itself is untouched by any of this (including its own
generous invisible hit-area for seeking), so a thin seek line is always
there, just without the box around it once minimal. Expands again on
any `pointermove`/`pointerdown` anywhere in `.playerWrap`, or while the
pointer is sitting on the bar itself (`mouseenter`/`mouseleave` pause
the countdown entirely rather than just resetting it, so hovering the
bar can never lose a race with its own timer). Stays expanded outright
while genuinely paused - nothing playing to hide the bar *from* - and
every discrete playback event (`onDriverStateChanged`) re-arms the
countdown too, not just raw pointer activity, so resuming from a pause
correctly restarts it rather than leaving the bar stuck open forever.
Applies to everyone, host or follower alike - the bar (and the
subtitle-masking problem) exists for both.
Verified: full `typecheck`/`build`. Confirmed in a real browser against
the compiled bundle, side by side with an actual subtitle-shaped
overlay in the same position: the expanded bar visibly overlaps it,
the `.minimal` state (just the thin progress line) leaves it fully
readable. The *timer* itself (does it actually fire after 3s of real
inactivity, does hovering genuinely prevent it) wasn't driven through
real wall-clock waits here - the CSS transition and class-toggling
logic were checked directly instead.
## Known limitations ## Known limitations
- Playback always goes through `hls.js` against whatever Emby's - Playback always goes through `hls.js` against whatever Emby's