Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 110 additions & 21 deletions web/packages/core/src/internal/player/inner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ interface ContextMenuItem {
* @default true
*/
enabled?: boolean;

/**
* Whether this item has a checkmark next to it.
* When defined, a checkmark column is shown for all items in the group.
*/
checked?: boolean;
}

/**
Expand Down Expand Up @@ -178,6 +184,9 @@ export class InnerPlayer {
// Allows the user to permanently disable the context menu.
private contextMenuForceDisabled = false;

// The client position where the custom context menu was last opened.
private contextMenuOpenPosition: { x: number; y: number } | null = null;

// Whether the most recent pointer event was from a touch (or pen).
private isTouch = false;
// Whether this device sends contextmenu events.
Expand All @@ -187,6 +196,7 @@ export class InnerPlayer {
// When set to `true`, the next context menu event will
// not show the context menu.
private _suppressContextMenu = false;
private hideContextMenuOnWheel: ((event: WheelEvent) => void) | null = null;

// The effective config loaded upon `.load()`.
public loadedConfig?: URLLoadOptions | DataLoadOptions;
Expand Down Expand Up @@ -382,7 +392,7 @@ export class InnerPlayer {
}
};

modalElement.parentNode!.addEventListener("click", hideModal);
modalElement.addEventListener("click", hideModal);
const modalArea = modalElement.querySelector(".modal-area");
if (modalArea) {
modalArea.addEventListener("click", (event) =>
Expand Down Expand Up @@ -447,6 +457,10 @@ export class InnerPlayer {
volumeMuteCheckbox.checked = this.volumeSettings.isMuted;
volumeSlider.disabled = volumeMuteCheckbox.checked;
volumeSlider.valueAsNumber = this.volumeSettings.volume;
volumeSlider.style.setProperty(
"--volume-pct",
`${this.volumeSettings.volume}%`,
);
volumeSliderText.textContent = volumeSlider.value + "%";
setVolumeIcon();

Expand All @@ -459,6 +473,10 @@ export class InnerPlayer {
});
volumeSlider.addEventListener("input", () => {
volumeSliderText.textContent = volumeSlider.value + "%";
volumeSlider.style.setProperty(
"--volume-pct",
`${volumeSlider.value}%`,
);
this.volumeSettings.volume = volumeSlider.valueAsNumber;
this.instance?.set_volume(this.volumeSettings.get_volume());
setVolumeIcon();
Expand Down Expand Up @@ -750,7 +768,19 @@ export class InnerPlayer {

this.rendererDebugInfo = this.instance!.renderer_debug_info();

if (this.rendererDebugInfo.includes("Adapter Device Type: Cpu")) {
// Show a hardware acceleration warning when software rendering is detected.
// The device type is unreliable through WebGL/ANGLE (always "Other"), so we
// also match known software renderer names (WARP, SwiftShader, Mesa llvmpipe).
const isSoftwareRenderer =
this.rendererDebugInfo.includes("Adapter Device Type: Cpu") ||
this.rendererDebugInfo.includes(
"Adapter Device Type: VirtualGpu",
) ||
this.rendererDebugInfo.includes("Microsoft Basic Render Driver") ||
this.rendererDebugInfo.includes("SwiftShader") ||
this.rendererDebugInfo.includes("llvmpipe") ||
this.rendererDebugInfo.includes("softpipe");
if (isSoftwareRenderer) {
this.container.addEventListener(
"mouseover",
this.openHardwareAccelerationModal.bind(this),
Expand Down Expand Up @@ -1503,7 +1533,6 @@ export class InnerPlayer {
}

private contextMenuItems(): Array<ContextMenuItem | null> {
const CHECKMARK = String.fromCharCode(0x2713);
const items: Array<ContextMenuItem | null> = [];
const addSeparator = () => {
// Don't start with or duplicate separators.
Expand All @@ -1524,12 +1553,11 @@ export class InnerPlayer {
addSeparator();
}
items.push({
// TODO: better checkboxes
text:
item.caption + (item.checked ? ` (${CHECKMARK})` : ``),
text: item.caption,
onClick: async () =>
this.instance?.run_context_menu_callback(index),
enabled: item.enabled,
checked: item.checked,
});
});

Expand Down Expand Up @@ -1669,6 +1697,22 @@ export class InnerPlayer {
return;
}

// If the custom menu is already open and the user right-clicks again
// at roughly the same position, hide our menu and let the browser show
// its native context menu. A 8px threshold accounts for slight mouse drift.
if (
event.type === "contextmenu" &&
!this.contextMenuOverlay.classList.contains("hidden") &&
this.contextMenuOpenPosition !== null
) {
const dx = event.clientX - this.contextMenuOpenPosition.x;
const dy = event.clientY - this.contextMenuOpenPosition.y;
if (dx * dx + dy * dy <= 64) {
this.hideContextMenu();
return;
}
}

event.preventDefault();

if (this._suppressContextMenu) {
Expand Down Expand Up @@ -1700,6 +1744,20 @@ export class InnerPlayer {
);
event.stopPropagation();
}
this.hideContextMenuOnWheel = (event: WheelEvent) => {
const rect = this.contextMenuElement.getBoundingClientRect();
if (
event.clientX < rect.left ||
event.clientX > rect.right ||
event.clientY < rect.top ||
event.clientY > rect.bottom
) {
this.hideContextMenu();
}
};
document.addEventListener("wheel", this.hideContextMenuOnWheel, {
capture: true,
});

if (
[false, ContextMenu.Off].includes(
Expand All @@ -1720,22 +1778,32 @@ export class InnerPlayer {
);
}

const items = this.contextMenuItems();
const hasCheckmarks = items.some(
(item) => item !== null && item.checked !== undefined,
);
this.contextMenuElement.classList.toggle(
"has-checkmarks",
hasCheckmarks,
);

// Populate context menu items.
for (const item of this.contextMenuItems()) {
for (const item of items) {
if (item === null) {
this.contextMenuElement.appendChild(
<li class="menu-separator">
<hr />
</li>,
);
} else {
const { text, onClick, enabled } = item;
const { text, onClick, enabled, checked } = item;

const menuItem = (
<li
class={{
"menu-item": true,
disabled: enabled === false,
checked: checked === true,
}}
data-text={text}
>
Expand Down Expand Up @@ -1771,6 +1839,8 @@ export class InnerPlayer {

this.contextMenuOverlay.classList.remove("hidden");

this.contextMenuOpenPosition = { x: event.clientX, y: event.clientY };

const playerRect = this.element.getBoundingClientRect();
const contextMenuRect = this.contextMenuElement.getBoundingClientRect();

Expand All @@ -1786,19 +1856,31 @@ export class InnerPlayer {
// mode and get the body when it's null.
const viewportElement = document.scrollingElement || document.body;

// Keep the entire context menu inside the viewport.
const overflowX = Math.max(
0,
event.clientX + contextMenuRect.width - viewportElement.clientWidth,
);
const overflowY = Math.max(
0,
event.clientY +
contextMenuRect.height -
viewportElement.clientHeight,
);
const x = event.clientX - playerRect.x - overflowX;
const y = event.clientY - playerRect.y - overflowY;
const menuWidth = contextMenuRect.width;
const menuHeight = contextMenuRect.height;
const vw = viewportElement.clientWidth;
const vh = viewportElement.clientHeight;

// Flip the menu above/left of the cursor (like native context menus)
// when it would overflow, falling back to clamping if there's no room.
let cx = event.clientX;
if (cx + menuWidth > vw) {
cx =
event.clientX - menuWidth >= 0
? event.clientX - menuWidth
: vw - menuWidth;
}

let cy = event.clientY;
if (cy + menuHeight > vh) {
cy =
event.clientY - menuHeight >= 0
? event.clientY - menuHeight
: vh - menuHeight;
}

const x = cx - playerRect.x;
const y = cy - playerRect.y;

const isRtl =
getComputedStyle(this.contextMenuElement).direction === "rtl";
Expand All @@ -1816,6 +1898,13 @@ export class InnerPlayer {
private hideContextMenu(): void {
this.instance?.clear_custom_menu_items();
this.contextMenuOverlay.classList.add("hidden");
this.contextMenuOpenPosition = null;
if (this.hideContextMenuOnWheel) {
document.removeEventListener("wheel", this.hideContextMenuOnWheel, {
capture: true,
});
this.hideContextMenuOnWheel = null;
}
}

/**
Expand Down
42 changes: 32 additions & 10 deletions web/packages/core/src/internal/ui/panic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ function createPanicAction({
errorText: string;
}) {
if (action.type === "show_details") {
const onClick = () => {
const onClick = (event: MouseEvent) => {
event.preventDefault();
showDetails();
return false;
};
return (
<li>
Expand Down Expand Up @@ -396,21 +396,17 @@ export function showPanicScreen(
const errorText = errorArray.join("");
const { body, actions } = createPanicError(error);

const panicBody = createRef<HTMLDivElement>();
const detailsModal = createRef<HTMLDivElement>();
const copyButton = createRef<HTMLSpanElement>();
const showDetails = () => {
panicBody.current!.classList.add("details");
panicBody.current!.replaceChildren(
<textarea readOnly>{errorText}</textarea>,
);
detailsModal.current!.classList.remove("hidden");
};

container.textContent = "";
container.appendChild(
<div id="panic">
<div id="panic-title">{text("panic-title")}</div>
<div id="panic-body" ref={panicBody}>
{body}
</div>
<div id="panic-body">{body}</div>
<div id="panic-footer">
<ul>
{actions.map((action) =>
Expand All @@ -424,6 +420,32 @@ export function showPanicScreen(
)}
</ul>
</div>
<div id="panic-details-modal" class="hidden" ref={detailsModal}>
<div id="panic-details-content">
<span
class="panic-copy-button"
title="Copy to clipboard"
ref={copyButton}
onClick={() => {
if (!copyButton.current) {
return;
}
navigator.clipboard?.writeText(errorText);
copyButton.current.classList.add("copied");
setTimeout(() => {
copyButton.current?.classList.remove("copied");
}, 2000);
}}
></span>
<span
class="close-modal"
onClick={() =>
detailsModal.current!.classList.add("hidden")
}
></span>
<textarea readOnly>{errorText}</textarea>
</div>
</div>
</div>,
);
}
Loading
Loading