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>
This commit is contained in:
parent
f5a941525f
commit
a7944538bc
7 changed files with 359 additions and 53 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(); }
|
||||
};
|
||||
|
|
|
|||
1
client/src/global.d.ts
vendored
1
client/src/global.d.ts
vendored
|
|
@ -101,6 +101,7 @@ interface CastContext {
|
|||
interface RemotePlayer {
|
||||
isPaused: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface RemotePlayerController {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -104,4 +119,5 @@
|
|||
<button id="fullscreenBtn" class="fsBtn" title="Plein écran" aria-label="Plein écran"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue