feat(components): add large set of new UI components and demos, reorganize groups, and bump a few dependencies
This commit is contained in:
506
ts_web/elements/00group-media/dees-video-viewer/component.ts
Normal file
506
ts_web/elements/00group-media/dees-video-viewer/component.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
import {
|
||||
DeesElement,
|
||||
html,
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
state,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import '../../00group-utility/dees-icon/dees-icon.js';
|
||||
import { demo } from './demo.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-video-viewer': DeesVideoViewer;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-video-viewer')
|
||||
export class DeesVideoViewer extends DeesElement {
|
||||
public static demo = demo;
|
||||
public static demoGroups = ['Media'];
|
||||
|
||||
@property()
|
||||
accessor src: string = '';
|
||||
|
||||
@property()
|
||||
accessor poster: string = '';
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor showControls: boolean = true;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor autoplay: boolean = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor loop: boolean = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor muted: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor isPlaying: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor currentTime: number = 0;
|
||||
|
||||
@state()
|
||||
accessor duration: number = 0;
|
||||
|
||||
@state()
|
||||
accessor volume: number = 1;
|
||||
|
||||
@state()
|
||||
accessor loading: boolean = true;
|
||||
|
||||
@state()
|
||||
accessor error: string = '';
|
||||
|
||||
@state()
|
||||
accessor isFullscreen: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor controlsVisible: boolean = true;
|
||||
|
||||
private hideControlsTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private videoElement: HTMLVideoElement | null = null;
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.center-play {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
opacity: ${this.isPlaying ? 0 : 1};
|
||||
pointer-events: ${this.isPlaying ? 'none' : 'auto'};
|
||||
}
|
||||
|
||||
.center-play dees-icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.center-play:hover {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
}
|
||||
|
||||
.controls-bar {
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||
padding: 24px 12px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
opacity: ${this.controlsVisible || !this.isPlaying ? 1 : 0};
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.seekbar-row {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.seekbar-row:hover {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.seekbar-progress {
|
||||
height: 100%;
|
||||
background: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ctrl-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #ffffff;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ctrl-button dees-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ctrl-button:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.time-display {
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.volume-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
width: 60px;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.volume-slider::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #ffffff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.error-overlay dees-icon {
|
||||
color: #f87171;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div
|
||||
class="video-container"
|
||||
@mousemove=${this.handleMouseMove}
|
||||
@mouseleave=${this.handleMouseLeave}
|
||||
>
|
||||
<video
|
||||
.src=${this.src}
|
||||
.poster=${this.poster}
|
||||
.muted=${this.muted}
|
||||
.loop=${this.loop}
|
||||
?autoplay=${this.autoplay}
|
||||
?controls=${!this.showControls}
|
||||
playsinline
|
||||
@loadedmetadata=${this.handleLoadedMetadata}
|
||||
@play=${this.handlePlay}
|
||||
@pause=${this.handlePause}
|
||||
@ended=${this.handleEnded}
|
||||
@timeupdate=${this.handleTimeUpdate}
|
||||
@error=${this.handleError}
|
||||
@waiting=${() => { this.loading = true; }}
|
||||
@canplay=${() => { this.loading = false; }}
|
||||
></video>
|
||||
|
||||
${this.showControls ? html`
|
||||
<div class="overlay" @click=${this.handleOverlayClick}>
|
||||
<div class="center-play">
|
||||
<dees-icon icon="lucide:Play"></dees-icon>
|
||||
</div>
|
||||
|
||||
<div class="controls-bar" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="seekbar-row" @click=${this.handleSeek}>
|
||||
<div class="seekbar-progress" style="width: ${this.duration ? (this.currentTime / this.duration) * 100 : 0}%"></div>
|
||||
</div>
|
||||
|
||||
<div class="controls-row">
|
||||
<button class="ctrl-button" @click=${this.togglePlay}>
|
||||
<dees-icon icon="lucide:${this.isPlaying ? 'Pause' : 'Play'}"></dees-icon>
|
||||
</button>
|
||||
|
||||
<span class="time-display">
|
||||
${this.formatTime(this.currentTime)} / ${this.formatTime(this.duration)}
|
||||
</span>
|
||||
|
||||
<span class="spacer"></span>
|
||||
|
||||
<div class="volume-group">
|
||||
<button class="ctrl-button" @click=${this.toggleMute}>
|
||||
<dees-icon icon="lucide:${this.muted || this.volume === 0 ? 'VolumeX' : this.volume < 0.5 ? 'Volume1' : 'Volume2'}"></dees-icon>
|
||||
</button>
|
||||
<input
|
||||
class="volume-slider"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
.value=${String(this.muted ? 0 : this.volume)}
|
||||
@input=${this.handleVolumeChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button class="ctrl-button" @click=${this.toggleFullscreen} title="Fullscreen">
|
||||
<dees-icon icon="lucide:${this.isFullscreen ? 'Minimize' : 'Maximize'}"></dees-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this.loading && !this.error ? html`
|
||||
<div class="loading-overlay">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this.error ? html`
|
||||
<div class="error-overlay">
|
||||
<dees-icon icon="lucide:VideoOff"></dees-icon>
|
||||
<span class="error-text">${this.error}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public async firstUpdated(): Promise<void> {
|
||||
this.videoElement = this.shadowRoot?.querySelector('video') || null;
|
||||
document.addEventListener('fullscreenchange', this.handleFullscreenChange);
|
||||
}
|
||||
|
||||
public async disconnectedCallback(): Promise<void> {
|
||||
await super.disconnectedCallback();
|
||||
document.removeEventListener('fullscreenchange', this.handleFullscreenChange);
|
||||
if (this.hideControlsTimer) {
|
||||
clearTimeout(this.hideControlsTimer);
|
||||
}
|
||||
}
|
||||
|
||||
public play(): void {
|
||||
this.videoElement?.play();
|
||||
}
|
||||
|
||||
public pause(): void {
|
||||
this.videoElement?.pause();
|
||||
}
|
||||
|
||||
public togglePlay(): void {
|
||||
if (this.isPlaying) {
|
||||
this.pause();
|
||||
} else {
|
||||
this.play();
|
||||
}
|
||||
}
|
||||
|
||||
public seek(time: number): void {
|
||||
if (this.videoElement) {
|
||||
this.videoElement.currentTime = time;
|
||||
}
|
||||
}
|
||||
|
||||
public setVolume(v: number): void {
|
||||
this.volume = Math.max(0, Math.min(1, v));
|
||||
if (this.videoElement) {
|
||||
this.videoElement.volume = this.volume;
|
||||
}
|
||||
}
|
||||
|
||||
public toggleFullscreen(): void {
|
||||
const container = this.shadowRoot?.querySelector('.video-container') as HTMLElement;
|
||||
if (!container) return;
|
||||
|
||||
if (this.isFullscreen) {
|
||||
document.exitFullscreen?.();
|
||||
} else {
|
||||
container.requestFullscreen?.();
|
||||
}
|
||||
}
|
||||
|
||||
private handleLoadedMetadata(): void {
|
||||
if (this.videoElement) {
|
||||
this.duration = this.videoElement.duration;
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private handlePlay(): void {
|
||||
this.isPlaying = true;
|
||||
this.scheduleHideControls();
|
||||
}
|
||||
|
||||
private handlePause(): void {
|
||||
this.isPlaying = false;
|
||||
this.controlsVisible = true;
|
||||
}
|
||||
|
||||
private handleEnded(): void {
|
||||
this.isPlaying = false;
|
||||
this.controlsVisible = true;
|
||||
}
|
||||
|
||||
private handleTimeUpdate(): void {
|
||||
if (this.videoElement) {
|
||||
this.currentTime = this.videoElement.currentTime;
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(): void {
|
||||
this.error = 'Failed to load video';
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
private handleOverlayClick(): void {
|
||||
this.togglePlay();
|
||||
}
|
||||
|
||||
private handleSeek(e: MouseEvent): void {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const ratio = (e.clientX - rect.left) / rect.width;
|
||||
this.seek(ratio * this.duration);
|
||||
}
|
||||
|
||||
private handleVolumeChange(e: Event): void {
|
||||
const value = parseFloat((e.target as HTMLInputElement).value);
|
||||
this.setVolume(value);
|
||||
this.muted = value === 0;
|
||||
}
|
||||
|
||||
private toggleMute(): void {
|
||||
this.muted = !this.muted;
|
||||
if (this.videoElement) {
|
||||
this.videoElement.muted = this.muted;
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseMove(): void {
|
||||
this.controlsVisible = true;
|
||||
this.scheduleHideControls();
|
||||
}
|
||||
|
||||
private handleMouseLeave(): void {
|
||||
if (this.isPlaying) {
|
||||
this.controlsVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleHideControls(): void {
|
||||
if (this.hideControlsTimer) {
|
||||
clearTimeout(this.hideControlsTimer);
|
||||
}
|
||||
if (this.isPlaying) {
|
||||
this.hideControlsTimer = setTimeout(() => {
|
||||
this.controlsVisible = false;
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
private handleFullscreenChange = (): void => {
|
||||
this.isFullscreen = !!document.fullscreenElement;
|
||||
};
|
||||
|
||||
private formatTime(seconds: number): string {
|
||||
if (!isFinite(seconds) || seconds < 0) return '0:00';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
63
ts_web/elements/00group-media/dees-video-viewer/demo.ts
Normal file
63
ts_web/elements/00group-media/dees-video-viewer/demo.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { html, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
export const demo = () => html`
|
||||
<style>
|
||||
.demo-container {
|
||||
padding: 48px;
|
||||
background: ${cssManager.bdTheme('#f8f9fa', '#0a0a0a')};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.section {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.section-description {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<div class="section">
|
||||
<div class="section-title">Video with Custom Controls</div>
|
||||
<div class="section-description">A video player with overlay controls, seeking, and volume adjustment.</div>
|
||||
<dees-video-viewer
|
||||
src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
|
||||
poster="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/BigBuckBunny.jpg"
|
||||
></dees-video-viewer>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Autoplay Muted</div>
|
||||
<div class="section-description">Video that autoplays muted, commonly used for previews.</div>
|
||||
<dees-video-viewer
|
||||
src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4"
|
||||
poster="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/ElephantsDream.jpg"
|
||||
.autoplay=${true}
|
||||
.muted=${true}
|
||||
></dees-video-viewer>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Native Controls</div>
|
||||
<div class="section-description">Video using browser-native controls instead of custom overlay.</div>
|
||||
<dees-video-viewer
|
||||
src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4"
|
||||
.showControls=${false}
|
||||
></dees-video-viewer>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
1
ts_web/elements/00group-media/dees-video-viewer/index.ts
Normal file
1
ts_web/elements/00group-media/dees-video-viewer/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './component.js';
|
||||
Reference in New Issue
Block a user