forked from EXT/VR180-Web-Player
Folder organisation
This commit is contained in:
111
src/vr180player/dom/dom.ts
Normal file
111
src/vr180player/dom/dom.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { createLucideIcon, type LucideIconName } from './icons.js';
|
||||
|
||||
export function injectPlayerStyles(playerBase: string): void {
|
||||
if (document.querySelector('link[data-vr-web-player-stylesheet]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = playerBase + 'vr180-player.css';
|
||||
link.dataset.vrWebPlayerStylesheet = 'true';
|
||||
|
||||
if (document.head) {
|
||||
document.head.appendChild(link);
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', () => document.head.appendChild(link), { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function createPlayButton(): HTMLButtonElement {
|
||||
const playButton = document.createElement('button');
|
||||
playButton.type = 'button';
|
||||
playButton.className = 'vrwp-play-button';
|
||||
playButton.setAttribute('aria-label', 'Play video');
|
||||
playButton.appendChild(createLucideIcon('circle-play'));
|
||||
|
||||
return playButton;
|
||||
}
|
||||
|
||||
export function create2DControlPanel(): HTMLDivElement {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'vrwp-panel';
|
||||
|
||||
const status = document.createElement('div');
|
||||
status.className = 'vrwp-status';
|
||||
|
||||
const videoTitle = document.createElement('p');
|
||||
videoTitle.className = 'vrwp-video-title';
|
||||
videoTitle.textContent = 'Title';
|
||||
|
||||
const progress = document.createElement('div');
|
||||
progress.className = 'vrwp-progress';
|
||||
|
||||
const currentTime = document.createElement('p');
|
||||
currentTime.className = 'vrwp-current-time';
|
||||
currentTime.textContent = '00:00:00';
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'vrwp-bar';
|
||||
|
||||
const played = document.createElement('div');
|
||||
played.className = 'vrwp-played';
|
||||
bar.appendChild(played);
|
||||
|
||||
const totalTime = document.createElement('p');
|
||||
totalTime.className = 'vrwp-total-time';
|
||||
totalTime.textContent = '00:00:00';
|
||||
|
||||
progress.appendChild(currentTime);
|
||||
progress.appendChild(bar);
|
||||
progress.appendChild(totalTime);
|
||||
|
||||
status.appendChild(videoTitle);
|
||||
status.appendChild(progress);
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'vrwp-controls';
|
||||
|
||||
const fullscreenBtn = createControlButton('vrwp-fullscreen', 'Toggle fullscreen', 'maximize');
|
||||
|
||||
const nav = document.createElement('div');
|
||||
nav.className = 'vrwp-nav';
|
||||
|
||||
const backBtn = createControlButton('vrwp-back', 'Back 15 seconds', 'rotate-ccw', '15');
|
||||
|
||||
const play2Btn = createControlButton('vrwp-play-toggle', 'Play or pause', 'play');
|
||||
|
||||
const forwardBtn = createControlButton('vrwp-forward', 'Forward 15 seconds', 'rotate-cw', '15');
|
||||
|
||||
nav.appendChild(backBtn);
|
||||
nav.appendChild(play2Btn);
|
||||
nav.appendChild(forwardBtn);
|
||||
|
||||
const muteBtn = createControlButton('vrwp-mute', 'Toggle mute', 'volume-2');
|
||||
|
||||
controls.appendChild(fullscreenBtn);
|
||||
controls.appendChild(nav);
|
||||
controls.appendChild(muteBtn);
|
||||
|
||||
panel.appendChild(status);
|
||||
panel.appendChild(controls);
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
function createControlButton(className: string, label: string, iconName: LucideIconName, skipLabel?: string): HTMLButtonElement {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = className;
|
||||
button.setAttribute('aria-label', label);
|
||||
button.appendChild(createLucideIcon(iconName));
|
||||
|
||||
if (skipLabel) {
|
||||
const labelEl = document.createElement('span');
|
||||
labelEl.className = 'vrwp-skip-label';
|
||||
labelEl.textContent = skipLabel;
|
||||
button.appendChild(labelEl);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
171
src/vr180player/dom/icons.ts
Normal file
171
src/vr180player/dom/icons.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
export type LucideIconName =
|
||||
| 'circle-play'
|
||||
| 'play'
|
||||
| 'pause'
|
||||
| 'maximize'
|
||||
| 'rotate-ccw'
|
||||
| 'rotate-cw'
|
||||
| 'volume-2'
|
||||
| 'volume-x'
|
||||
| 'log-out';
|
||||
|
||||
type IconAttrs = Record<string, string>;
|
||||
type IconNode = readonly [tagName: string, attrs: IconAttrs];
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
const ICONS: Record<LucideIconName, readonly IconNode[]> = {
|
||||
'circle-play': [
|
||||
['circle', { cx: '12', cy: '12', r: '10' }],
|
||||
['polygon', { points: '10 8 16 12 10 16 10 8' }]
|
||||
],
|
||||
play: [
|
||||
['polygon', { points: '6 3 20 12 6 21 6 3' }]
|
||||
],
|
||||
pause: [
|
||||
['rect', { x: '14', y: '4', width: '4', height: '16', rx: '1' }],
|
||||
['rect', { x: '6', y: '4', width: '4', height: '16', rx: '1' }]
|
||||
],
|
||||
maximize: [
|
||||
['path', { d: 'M8 3H5a2 2 0 0 0-2 2v3' }],
|
||||
['path', { d: 'M21 8V5a2 2 0 0 0-2-2h-3' }],
|
||||
['path', { d: 'M3 16v3a2 2 0 0 0 2 2h3' }],
|
||||
['path', { d: 'M16 21h3a2 2 0 0 0 2-2v-3' }]
|
||||
],
|
||||
'rotate-ccw': [
|
||||
['path', { d: 'M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8' }],
|
||||
['path', { d: 'M3 3v5h5' }]
|
||||
],
|
||||
'rotate-cw': [
|
||||
['path', { d: 'M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8' }],
|
||||
['path', { d: 'M21 3v5h-5' }]
|
||||
],
|
||||
'volume-2': [
|
||||
['path', { d: 'M11 4.702a1 1 0 0 0-1.664-.747L5.5 7.5H4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h1.5l3.836 3.545A1 1 0 0 0 11 19.298z' }],
|
||||
['path', { d: 'M16 9a5 5 0 0 1 0 6' }],
|
||||
['path', { d: 'M19.364 18.364a9 9 0 0 0 0-12.728' }]
|
||||
],
|
||||
'volume-x': [
|
||||
['path', { d: 'M11 4.702a1 1 0 0 0-1.664-.747L5.5 7.5H4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h1.5l3.836 3.545A1 1 0 0 0 11 19.298z' }],
|
||||
['line', { x1: '22', y1: '9', x2: '16', y2: '15' }],
|
||||
['line', { x1: '16', y1: '9', x2: '22', y2: '15' }]
|
||||
],
|
||||
'log-out': [
|
||||
['path', { d: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4' }],
|
||||
['polyline', { points: '16 17 21 12 16 7' }],
|
||||
['line', { x1: '21', y1: '12', x2: '9', y2: '12' }]
|
||||
]
|
||||
};
|
||||
|
||||
export function createLucideIcon(name: LucideIconName, className = 'vrwp-icon'): SVGSVGElement {
|
||||
const svg = document.createElementNS(SVG_NS, 'svg');
|
||||
svg.setAttribute('class', className);
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('stroke', 'currentColor');
|
||||
svg.setAttribute('stroke-width', '2');
|
||||
svg.setAttribute('stroke-linecap', 'round');
|
||||
svg.setAttribute('stroke-linejoin', 'round');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
svg.setAttribute('focusable', 'false');
|
||||
|
||||
for (const [tagName, attrs] of ICONS[name]) {
|
||||
const node = document.createElementNS(SVG_NS, tagName);
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
node.setAttribute(key, value);
|
||||
}
|
||||
svg.appendChild(node);
|
||||
}
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
export function setLucideIcon(target: HTMLElement, name: LucideIconName): void {
|
||||
const existingIcon = target.querySelector('.vrwp-icon');
|
||||
if (existingIcon) {
|
||||
existingIcon.replaceWith(createLucideIcon(name));
|
||||
return;
|
||||
}
|
||||
target.prepend(createLucideIcon(name));
|
||||
}
|
||||
|
||||
export function drawLucideIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
name: LucideIconName,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
color = '#ffffff',
|
||||
strokeWidth = 2
|
||||
): void {
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.scale(size / 24, size / 24);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = strokeWidth;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
for (const [tagName, attrs] of ICONS[name]) {
|
||||
drawIconNode(ctx, tagName, attrs);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawIconNode(ctx: CanvasRenderingContext2D, tagName: string, attrs: IconAttrs): void {
|
||||
switch (tagName) {
|
||||
case 'path':
|
||||
ctx.stroke(new Path2D(attrs.d));
|
||||
break;
|
||||
case 'line':
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(Number(attrs.x1), Number(attrs.y1));
|
||||
ctx.lineTo(Number(attrs.x2), Number(attrs.y2));
|
||||
ctx.stroke();
|
||||
break;
|
||||
case 'polyline':
|
||||
case 'polygon':
|
||||
drawPoints(ctx, attrs.points, tagName === 'polygon');
|
||||
break;
|
||||
case 'rect':
|
||||
drawRect(ctx, attrs);
|
||||
break;
|
||||
case 'circle':
|
||||
ctx.beginPath();
|
||||
ctx.arc(Number(attrs.cx), Number(attrs.cy), Number(attrs.r), 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function drawPoints(ctx: CanvasRenderingContext2D, pointsAttr: string, closePath: boolean): void {
|
||||
const points = pointsAttr.trim().split(/\s+/).map((pair) => pair.split(',').map(Number));
|
||||
if (points.length === 0) return;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0][0], points[0][1]);
|
||||
for (const [x, y] of points.slice(1)) {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
if (closePath) {
|
||||
ctx.closePath();
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawRect(ctx: CanvasRenderingContext2D, attrs: IconAttrs): void {
|
||||
const x = Number(attrs.x);
|
||||
const y = Number(attrs.y);
|
||||
const width = Number(attrs.width);
|
||||
const height = Number(attrs.height);
|
||||
const radius = Number(attrs.rx || 0);
|
||||
|
||||
ctx.beginPath();
|
||||
if (radius > 0 && typeof ctx.roundRect === 'function') {
|
||||
ctx.roundRect(x, y, width, height, radius);
|
||||
} else {
|
||||
ctx.rect(x, y, width, height);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
200
src/vr180player/dom/two-d-control-panel.ts
Normal file
200
src/vr180player/dom/two-d-control-panel.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { setLucideIcon } from './icons.js';
|
||||
import { formatTime } from '../utils/time.js';
|
||||
|
||||
type TwoDControlPanelCallbacks = {
|
||||
onForward: () => void;
|
||||
onMute: () => void;
|
||||
onPlayPause: () => void;
|
||||
onRewind: () => void;
|
||||
onSeek: (progress: number) => void;
|
||||
};
|
||||
|
||||
type TwoDControlPanelOptions = {
|
||||
callbacks: TwoDControlPanelCallbacks;
|
||||
fullscreenTarget: HTMLElement;
|
||||
getIsActive: () => boolean;
|
||||
playerContainer: HTMLElement;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const CONTROL_PANEL_HIDE_DELAY = 3000;
|
||||
|
||||
export class TwoDControlPanel {
|
||||
private readonly callbacks: TwoDControlPanelCallbacks;
|
||||
private readonly fullscreenTarget: HTMLElement;
|
||||
private readonly getIsActive: () => boolean;
|
||||
private readonly playerContainer: HTMLElement;
|
||||
private controlPanel: HTMLElement | null;
|
||||
private currentTimeDisplay: HTMLElement | null;
|
||||
private hideTimeout: number | undefined;
|
||||
private playedBar: HTMLElement | null;
|
||||
private progressBar: HTMLElement | null;
|
||||
private totalTimeDisplay: HTMLElement | null;
|
||||
private playButton: HTMLButtonElement | null;
|
||||
private muteButton: HTMLButtonElement | null;
|
||||
|
||||
constructor({ callbacks, fullscreenTarget, getIsActive, playerContainer, title }: TwoDControlPanelOptions) {
|
||||
this.callbacks = callbacks;
|
||||
this.fullscreenTarget = fullscreenTarget;
|
||||
this.getIsActive = getIsActive;
|
||||
this.playerContainer = playerContainer;
|
||||
|
||||
this.controlPanel = playerContainer.querySelector('.vrwp-panel');
|
||||
const videoTitle = playerContainer.querySelector<HTMLElement>('.vrwp-video-title');
|
||||
this.currentTimeDisplay = playerContainer.querySelector('.vrwp-current-time');
|
||||
this.totalTimeDisplay = playerContainer.querySelector('.vrwp-total-time');
|
||||
this.progressBar = playerContainer.querySelector('.vrwp-bar');
|
||||
this.playedBar = playerContainer.querySelector('.vrwp-played');
|
||||
this.playButton = playerContainer.querySelector('.vrwp-play-toggle');
|
||||
this.muteButton = playerContainer.querySelector('.vrwp-mute');
|
||||
|
||||
if (!this.controlPanel) {
|
||||
console.error('VR_WEB_PLAYER_DOM: 2D control panel was not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoTitle) {
|
||||
videoTitle.textContent = title;
|
||||
}
|
||||
|
||||
this.bindControls(playerContainer);
|
||||
}
|
||||
|
||||
show(): void {
|
||||
if (!this.getIsActive() || !this.controlPanel) return;
|
||||
|
||||
this.clearHideTimeout();
|
||||
this.controlPanel.classList.add('visible');
|
||||
this.hideTimeout = window.setTimeout(() => this.hide(), CONTROL_PANEL_HIDE_DELAY);
|
||||
}
|
||||
|
||||
showPersistent(): void {
|
||||
if (!this.getIsActive() || !this.controlPanel) return;
|
||||
|
||||
this.clearHideTimeout();
|
||||
this.controlPanel.classList.add('visible');
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
if (!this.controlPanel) return;
|
||||
|
||||
this.clearHideTimeout();
|
||||
this.controlPanel.classList.remove('visible');
|
||||
}
|
||||
|
||||
position(canvas: HTMLElement): void {
|
||||
if (!this.getIsActive() || !this.controlPanel) return;
|
||||
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
const containerRect = this.playerContainer.getBoundingClientRect();
|
||||
const bottomOffset = canvasRect.height * 0.1;
|
||||
const panelHeight = this.controlPanel.offsetHeight;
|
||||
const topPosition = (canvasRect.bottom - containerRect.top) - bottomOffset - panelHeight;
|
||||
|
||||
this.controlPanel.style.position = 'absolute';
|
||||
this.controlPanel.style.top = `${topPosition}px`;
|
||||
this.controlPanel.style.bottom = 'auto';
|
||||
this.controlPanel.style.left = '50%';
|
||||
this.controlPanel.style.transform = 'translateX(-50%)';
|
||||
this.controlPanel.style.zIndex = '1000';
|
||||
}
|
||||
|
||||
updateMuteButton(isMuted: boolean): void {
|
||||
if (!this.getIsActive() || !this.muteButton) return;
|
||||
|
||||
if (isMuted) {
|
||||
this.muteButton.classList.remove('muted');
|
||||
this.muteButton.classList.add('unmuted');
|
||||
setLucideIcon(this.muteButton, 'volume-x');
|
||||
} else {
|
||||
this.muteButton.classList.remove('unmuted');
|
||||
this.muteButton.classList.add('muted');
|
||||
setLucideIcon(this.muteButton, 'volume-2');
|
||||
}
|
||||
}
|
||||
|
||||
updatePlaybackButton(isPausedOrEnded: boolean): void {
|
||||
if (!this.getIsActive() || !this.playButton) return;
|
||||
|
||||
if (isPausedOrEnded) {
|
||||
this.playButton.classList.remove('playing');
|
||||
this.playButton.classList.add('paused');
|
||||
setLucideIcon(this.playButton, 'play');
|
||||
} else {
|
||||
this.playButton.classList.remove('paused');
|
||||
this.playButton.classList.add('playing');
|
||||
setLucideIcon(this.playButton, 'pause');
|
||||
}
|
||||
}
|
||||
|
||||
updateTime(currentTime: number, duration: number): void {
|
||||
if (!this.getIsActive()) return;
|
||||
|
||||
if (this.currentTimeDisplay) {
|
||||
this.currentTimeDisplay.textContent = formatTime(currentTime);
|
||||
}
|
||||
|
||||
if (this.totalTimeDisplay && isFinite(duration)) {
|
||||
this.totalTimeDisplay.textContent = formatTime(duration);
|
||||
}
|
||||
|
||||
if (this.playedBar && isFinite(duration) && duration > 0) {
|
||||
const progress = (currentTime / duration) * 100;
|
||||
this.playedBar.style.width = `${progress}%`;
|
||||
}
|
||||
}
|
||||
|
||||
private bindControls(playerContainer: HTMLElement): void {
|
||||
playerContainer.querySelector('.vrwp-fullscreen')?.addEventListener('click', () => {
|
||||
this.toggleFullscreen();
|
||||
});
|
||||
|
||||
playerContainer.querySelector('.vrwp-back')?.addEventListener('click', () => {
|
||||
this.callbacks.onRewind();
|
||||
this.show();
|
||||
});
|
||||
|
||||
this.playButton?.addEventListener('click', () => {
|
||||
this.callbacks.onPlayPause();
|
||||
this.show();
|
||||
});
|
||||
|
||||
playerContainer.querySelector('.vrwp-forward')?.addEventListener('click', () => {
|
||||
this.callbacks.onForward();
|
||||
this.show();
|
||||
});
|
||||
|
||||
this.muteButton?.addEventListener('click', () => {
|
||||
this.callbacks.onMute();
|
||||
this.show();
|
||||
});
|
||||
|
||||
this.progressBar?.addEventListener('click', (event) => {
|
||||
const rect = this.progressBar?.getBoundingClientRect();
|
||||
if (rect && rect.width > 0) {
|
||||
this.callbacks.onSeek((event.clientX - rect.left) / rect.width);
|
||||
}
|
||||
this.show();
|
||||
});
|
||||
}
|
||||
|
||||
private clearHideTimeout(): void {
|
||||
if (this.hideTimeout !== undefined) {
|
||||
clearTimeout(this.hideTimeout);
|
||||
this.hideTimeout = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private toggleFullscreen(): void {
|
||||
if (!document.fullscreenElement) {
|
||||
this.fullscreenTarget.requestFullscreen().catch((err) => {
|
||||
console.error('Error attempting to enable fullscreen:', err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
document.exitFullscreen().catch((err) => {
|
||||
console.error('Error attempting to exit fullscreen:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user