styleui-components
Version:
Lightweight, modular UI component library with zero dependencies
311 lines (260 loc) • 12.2 kB
JavaScript
(function() {
if (!window.UI) window.UI = {};
/**
* Creates a video player widget with custom controls and StyleUI components.
* @param {object} config - Configuration options.
* @param {string} config.src - Video source URL.
* @param {string} [config.poster] - Poster image URL.
* @param {boolean} [config.autoplay=false] - Whether to autoplay.
* @param {boolean} [config.loop=false] - Whether to loop the video.
* @returns {HTMLElement} Video player element.
*/
UI.videoPlayer = function(config = {}) {
const { src, poster, autoplay = false, loop = false, fps = 24 } = config;
if (!src) {
throw new Error('UI.videoPlayer requires a "src" option.');
}
/* ---------------- Layout ---------------- */
const wrapper = document.createElement('div');
wrapper.className = 'video-player';
wrapper.style.position = 'relative';
const thumbnailPreview = document.createElement('img');
thumbnailPreview.className = 'video-thumbnail-preview';
wrapper.appendChild(thumbnailPreview);
const video = document.createElement('video');
video.src = src;
if (poster) video.poster = poster;
video.playsInline = true;
video.preload = 'metadata';
video.style.width = '100%';
if (loop) video.loop = true;
wrapper.appendChild(video);
/* ---------------- Overlay Canvas ---------------- */
const overlay = document.createElement('canvas');
overlay.className = 'video-overlay';
overlay.style.position = 'absolute';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.pointerEvents = 'none';
wrapper.appendChild(overlay);
const ctx = overlay.getContext('2d');
const strokesByFrame = new Map(); // frame -> array of strokes (each stroke array of points)
const resizeOverlay = () => {
const rect = video.getBoundingClientRect();
overlay.style.width = rect.width + 'px';
overlay.style.height = rect.height + 'px';
overlay.width = rect.width;
overlay.height = rect.height;
redrawCurrentFrame();
};
const controls = document.createElement('div');
controls.className = 'video-controls';
wrapper.appendChild(controls);
/* ---------------- Draw Button ---------------- */
const getCSSVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
const DRAW_COLOR_VARS = ['--error', '--warning', '--success', '--info'];
const DRAW_COLORS = DRAW_COLOR_VARS.map(v => getCSSVar(v) || 'red');
let drawColorIndex = 0;
let drawingEnabled = true; // Drawing is now always on
let currentStroke = null;
const drawButton = UI.button({
icon: 'edit',
tooltip: 'Next Draw Color',
onclick: () => {
drawColorIndex = (drawColorIndex + 1) % DRAW_COLORS.length;
drawButton.style.color = DRAW_COLORS[drawColorIndex];
}
});
drawButton.style.color = DRAW_COLORS[drawColorIndex];
overlay.style.pointerEvents = 'auto'; // Always listen for pointer events
// Play / Pause button
const playBtn = UI.button({ icon: 'play', variant: 'ghost' });
controls.appendChild(playBtn);
// Step backward / forward buttons (append later)
const stepBackBtn = UI.button({ icon: 'skip-back', variant: 'ghost' });
const stepFwdBtn = UI.button({ icon: 'skip-forward', variant: 'ghost' });
// Loop toggle using iconToggle (append later)
let loopToggle;
const setLoopStyle = (enabled) => {
loopToggle.classList.toggle('btn-primary', enabled);
};
loopToggle = UI.iconToggle({
iconOn: 'infinity',
iconOff: 'rotate-ccw',
tooltip: 'Toggle Loop',
initialState: () => loop,
onchange: (enabled) => {
video.loop = enabled;
setTimeout(() => setLoopStyle(enabled), 0);
}
});
// Apply initial style
setLoopStyle(loop);
// Append step, loop, draw buttons after timeline
controls.appendChild(stepBackBtn);
controls.appendChild(stepFwdBtn);
controls.appendChild(loopToggle);
controls.appendChild(drawButton);
const timelineHolder = document.createElement('div');
timelineHolder.style.flex = '1';
timelineHolder.style.margin = '0 var(--space-2)';
controls.appendChild(timelineHolder);
// Time label
const timeLabel = document.createElement('span');
timeLabel.className = 'video-time-label font-mono';
timeLabel.style.minWidth = '72px';
timeLabel.style.textAlign = 'right';
controls.appendChild(timeLabel);
UI.deferIcons();
/* ---------------- Helpers ---------------- */
const formatTime = (secs) => {
if (isNaN(secs)) return '0:00';
const minutes = Math.floor(secs / 60);
const seconds = Math.floor(secs % 60).toString().padStart(2, '0');
return `${minutes}:${seconds}`;
};
let timeline = null;
let totalFrames = 0;
const initTimeline = (duration) => {
totalFrames = Math.round(duration * fps);
// Clear old timeline from holder
timelineHolder.innerHTML = '';
timeline = UI.timeline({
duration: totalFrames,
onchange: (frame) => {
video.currentTime = frame / fps;
},
onMarkerHover: (frame, markerElement) => {
if (frame !== null && strokesByFrame.has(frame)) {
const frameData = strokesByFrame.get(frame);
if (frameData.thumbnail) {
const markerRect = markerElement.getBoundingClientRect();
const wrapperRect = wrapper.getBoundingClientRect();
thumbnailPreview.src = frameData.thumbnail;
thumbnailPreview.style.display = 'block';
const top = markerRect.top - wrapperRect.top - 100; // 90px height + 10px margin
const left = markerRect.left - wrapperRect.left + (markerRect.width / 2) - 60; // 120px width / 2
thumbnailPreview.style.top = `${top}px`;
thumbnailPreview.style.left = `${left}px`;
}
} else {
thumbnailPreview.style.display = 'none';
}
}
});
timelineHolder.appendChild(timeline);
};
/* ---------------- Events ---------------- */
playBtn.onclick = () => {
if (video.paused) {
video.play();
} else {
video.pause();
}
};
const STEP_SECONDS = 1 / fps;
stepBackBtn.onclick = () => {
video.currentTime = Math.max(0, video.currentTime - STEP_SECONDS);
};
stepFwdBtn.onclick = () => {
video.currentTime = Math.min(video.duration, video.currentTime + STEP_SECONDS);
};
const updatePlayIcon = () => {
const iconName = video.paused ? 'play' : 'pause';
playBtn.innerHTML = `<i data-lucide="${iconName}"></i>`;
lucide.createIcons({ nodes: [playBtn] });
};
video.addEventListener('play', updatePlayIcon);
video.addEventListener('pause', updatePlayIcon);
updatePlayIcon();
video.addEventListener('loadedmetadata', () => {
initTimeline(video.duration);
const paddedTotal = String(totalFrames).padStart(4, '0');
timeLabel.textContent = `${formatTime(video.currentTime)} / ${formatTime(video.duration)} (0000/${paddedTotal})`;
if (timeline) timeline.setRange(0, totalFrames);
if (autoplay) video.play();
});
video.addEventListener('timeupdate', () => {
const currentFrame = Math.round(video.currentTime * fps);
if (timeline) timeline.setPosition(currentFrame);
const paddedFrame = String(currentFrame).padStart(4, '0');
const paddedTotal = String(totalFrames).padStart(4, '0');
timeLabel.textContent = `${formatTime(video.currentTime)} / ${formatTime(video.duration)} (${paddedFrame}/${paddedTotal})`;
redrawCurrentFrame();
});
/* ---------------- Drawing Logic ---------------- */
const getCurrentFrame = () => Math.round(video.currentTime * fps);
const DASH_PATTERN = [4, 4];
const addPoint = (x, y) => {
if (!currentStroke) return;
currentStroke.points.push({ x, y });
};
const pointerDownDraw = (e) => {
if (!drawingEnabled) return;
e.preventDefault();
video.pause();
const rect = overlay.getBoundingClientRect();
currentStroke = {
color: DRAW_COLORS[drawColorIndex],
colorVar: `var(${DRAW_COLOR_VARS[drawColorIndex]})`,
points: []
};
addPoint(e.clientX - rect.left, e.clientY - rect.top);
overlay.addEventListener('pointermove', pointerMoveDraw);
document.addEventListener('pointerup', pointerUpDraw, { once: true });
};
const pointerMoveDraw = (e) => {
const rect = overlay.getBoundingClientRect();
addPoint(e.clientX - rect.left, e.clientY - rect.top);
redrawCurrentFrame();
};
const pointerUpDraw = () => {
overlay.removeEventListener('pointermove', pointerMoveDraw);
if (currentStroke && currentStroke.points.length > 1) {
const frame = getCurrentFrame();
if (!strokesByFrame.has(frame)) {
strokesByFrame.set(frame, { strokes: [], thumbnail: null });
}
const frameData = strokesByFrame.get(frame);
frameData.strokes.push(currentStroke);
if (timeline && typeof timeline.addMarker === 'function') {
timeline.addMarker(frame, currentStroke.colorVar);
}
currentStroke = null;
redrawCurrentFrame();
frameData.thumbnail = overlay.toDataURL('image/png');
}
currentStroke = null;
};
const redrawCurrentFrame = () => {
ctx.clearRect(0, 0, overlay.width, overlay.height);
const frame = getCurrentFrame();
const frameData = strokesByFrame.get(frame);
const strokes = frameData ? frameData.strokes : [];
const drawStroke = (stroke) => {
if (!stroke || stroke.points.length < 2) return;
ctx.strokeStyle = stroke.color;
ctx.lineWidth = 2;
ctx.setLineDash(DASH_PATTERN);
ctx.beginPath();
stroke.points.forEach((pt, idx) => {
if (idx === 0) ctx.moveTo(pt.x, pt.y);
else ctx.lineTo(pt.x, pt.y);
});
ctx.stroke();
};
// Draw completed strokes for the current frame
strokes.forEach(drawStroke);
// Draw the live, in-progress stroke
drawStroke(currentStroke);
ctx.setLineDash([]); // reset
};
overlay.addEventListener('pointerdown', pointerDownDraw);
// Resize observer to keep canvas in sync
const resizeObs = new ResizeObserver(resizeOverlay);
resizeObs.observe(video);
// Initial overlay sizing after DOM ready
setTimeout(resizeOverlay, 0);
return wrapper;
};
})();